From e3e77c9e9915fd9c6355cce361ee07f2d0696347 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Fri, 17 Jul 2026 12:55:21 -0700 Subject: [PATCH] test: implement internalBinding('http2') for the vendored node test shim `internal/test/binding` threw for "http2", so every vendored node http2 test gated on `--expose-internals` died on the import instead of running. Add the case, backed by real state where Bun has it: - `constants` is the public node:http2 table (all 240 keys already match node v26.3.0 exactly, verified against the v26.3.0 binary) - `nghttp2ErrorString` transcribes nghttp2's `nghttp2_strerror` - `Http2Session`/`Http2Stream` are inert stand-ins for the native handle layer Bun does not have, so a test stubbing a handle method ends up asserting Bun's real behavior instead of a native return code Vendor the two node v26.3.0 tests this unblocks, byte-identical: test-http2-binding and test-http2-respond-errors. --- src/js/internal/test/binding.ts | 67 +++++ .../node/test/parallel/test-http2-binding.js | 262 ++++++++++++++++++ .../parallel/test-http2-respond-errors.js | 52 ++++ 3 files changed, 381 insertions(+) create mode 100644 test/js/node/test/parallel/test-http2-binding.js create mode 100644 test/js/node/test/parallel/test-http2-respond-errors.js diff --git a/src/js/internal/test/binding.ts b/src/js/internal/test/binding.ts index d6996dc570b6..96d8d2600c7e 100644 --- a/src/js/internal/test/binding.ts +++ b/src/js/internal/test/binding.ts @@ -40,6 +40,64 @@ class TestTCPWrap { } } +// Transcription of nghttp2's `nghttp2_strerror`, which is what Node's binding +// returns here. Bun's engine emits only a few of these codes; those carry the +// same messages (kNghttp2ErrorMessages in node/http2.ts). +const nghttp2ErrorMessages: Record = { + [0]: "Success", + [-501]: "Invalid argument", + [-502]: "Out of buffer space", + [-503]: "Unsupported SPDY version", + [-504]: "Operation would block", + [-505]: "Protocol error", + [-506]: "Invalid frame octets", + [-507]: "EOF", + [-508]: "Data transfer deferred", + [-509]: "No more Stream ID available", + [-510]: "Stream was already closed or invalid", + [-511]: "Stream is closing", + [-512]: "The transmission is not allowed for this stream", + [-513]: "Stream ID is invalid", + [-514]: "Invalid stream state", + [-515]: "Another DATA frame has already been deferred", + [-516]: "request HEADERS is not allowed", + [-517]: "GOAWAY has already been sent", + [-518]: "Invalid header block", + [-519]: "Invalid state", + [-521]: "The user callback function failed due to the temporal error", + [-522]: "The length of the frame is invalid", + [-523]: "Header compression/decompression error", + [-524]: "Flow control error", + [-525]: "Insufficient buffer size given to function", + [-526]: "Callback was paused by the application", + [-527]: "Too many inflight SETTINGS", + [-528]: "Server push is disabled by peer", + [-529]: "DATA or HEADERS frame has already been submitted for the stream", + [-530]: "The current session is closing", + [-531]: "Invalid HTTP header field was received", + [-532]: "Violation in HTTP messaging rule", + [-533]: "Stream was refused", + [-534]: "Internal error", + [-535]: "Cancel", + [-536]: "When a local endpoint expects to receive SETTINGS frame, it receives an other type of frame", + [-537]: "SETTINGS frame contained more than the maximum allowed entries", + [-901]: "Out of memory", + [-902]: "The user callback function failed", + [-903]: "Received bad client magic byte string", + [-904]: "Flooding was detected in this HTTP/2 session, and it must be closed", + [-905]: "Too many CONTINUATION frames following a HEADER frame", +}; + +function nghttp2ErrorString(code: number) { + return nghttp2ErrorMessages[code] || "Unknown error code"; +} + +// Node's `internalBinding('http2')` exposes the native handle classes beneath +// Http2Session/Http2Stream; Bun drives the Rust engine directly and has no such +// layer. These stand-ins give tests that stub a handle method something inert. +class Http2Session {} +class Http2Stream {} + function internalBinding(name: string) { switch (name) { case "trace_events": @@ -67,6 +125,15 @@ function internalBinding(name: string) { UV_ENOTSOCK: isWindows ? -4050 : -errno.ENOTSOCK, }; } + // The constants table is the very same one node:http2 exposes publicly, so + // the vendored tests check Bun's real values. + case "http2": + return { + Http2Session, + Http2Stream, + constants: require("node:http2").constants, + nghttp2ErrorString, + }; case "udp_wrap": return { UDP: require("internal/dgram").UDP }; case "tcp_wrap": diff --git a/test/js/node/test/parallel/test-http2-binding.js b/test/js/node/test/parallel/test-http2-binding.js new file mode 100644 index 000000000000..7a91b2ba7406 --- /dev/null +++ b/test/js/node/test/parallel/test-http2-binding.js @@ -0,0 +1,262 @@ +// Flags: --expose-internals +'use strict'; + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); +const assert = require('assert'); +const { internalBinding } = require('internal/test/binding'); + +const binding = internalBinding('http2'); +const http2 = require('http2'); + +assert(binding.Http2Session); +assert.strictEqual(typeof binding.Http2Session, 'function'); + +const settings = http2.getDefaultSettings(); +assert.strictEqual(settings.headerTableSize, 4096); +assert.strictEqual(settings.enablePush, true); +assert.strictEqual(settings.maxConcurrentStreams, 4294967295); +assert.strictEqual(settings.initialWindowSize, 65535); +assert.strictEqual(settings.maxFrameSize, 16384); + +assert.strictEqual(binding.nghttp2ErrorString(-517), + 'GOAWAY has already been sent'); + +// Assert constants are present +assert(binding.constants); +assert.strictEqual(typeof binding.constants, 'object'); +const constants = binding.constants; + +const expectedStatusCodes = { + HTTP_STATUS_CONTINUE: 100, + HTTP_STATUS_SWITCHING_PROTOCOLS: 101, + HTTP_STATUS_PROCESSING: 102, + HTTP_STATUS_EARLY_HINTS: 103, + HTTP_STATUS_OK: 200, + HTTP_STATUS_CREATED: 201, + HTTP_STATUS_ACCEPTED: 202, + HTTP_STATUS_NON_AUTHORITATIVE_INFORMATION: 203, + HTTP_STATUS_NO_CONTENT: 204, + HTTP_STATUS_RESET_CONTENT: 205, + HTTP_STATUS_PARTIAL_CONTENT: 206, + HTTP_STATUS_MULTI_STATUS: 207, + HTTP_STATUS_ALREADY_REPORTED: 208, + HTTP_STATUS_IM_USED: 226, + HTTP_STATUS_MULTIPLE_CHOICES: 300, + HTTP_STATUS_MOVED_PERMANENTLY: 301, + HTTP_STATUS_FOUND: 302, + HTTP_STATUS_SEE_OTHER: 303, + HTTP_STATUS_NOT_MODIFIED: 304, + HTTP_STATUS_USE_PROXY: 305, + HTTP_STATUS_TEMPORARY_REDIRECT: 307, + HTTP_STATUS_PERMANENT_REDIRECT: 308, + HTTP_STATUS_BAD_REQUEST: 400, + HTTP_STATUS_UNAUTHORIZED: 401, + HTTP_STATUS_PAYMENT_REQUIRED: 402, + HTTP_STATUS_FORBIDDEN: 403, + HTTP_STATUS_NOT_FOUND: 404, + HTTP_STATUS_METHOD_NOT_ALLOWED: 405, + HTTP_STATUS_NOT_ACCEPTABLE: 406, + HTTP_STATUS_PROXY_AUTHENTICATION_REQUIRED: 407, + HTTP_STATUS_REQUEST_TIMEOUT: 408, + HTTP_STATUS_CONFLICT: 409, + HTTP_STATUS_GONE: 410, + HTTP_STATUS_LENGTH_REQUIRED: 411, + HTTP_STATUS_PRECONDITION_FAILED: 412, + HTTP_STATUS_PAYLOAD_TOO_LARGE: 413, + HTTP_STATUS_URI_TOO_LONG: 414, + HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE: 415, + HTTP_STATUS_RANGE_NOT_SATISFIABLE: 416, + HTTP_STATUS_EXPECTATION_FAILED: 417, + HTTP_STATUS_TEAPOT: 418, + HTTP_STATUS_MISDIRECTED_REQUEST: 421, + HTTP_STATUS_UNPROCESSABLE_ENTITY: 422, + HTTP_STATUS_LOCKED: 423, + HTTP_STATUS_FAILED_DEPENDENCY: 424, + HTTP_STATUS_TOO_EARLY: 425, + HTTP_STATUS_UPGRADE_REQUIRED: 426, + HTTP_STATUS_PRECONDITION_REQUIRED: 428, + HTTP_STATUS_TOO_MANY_REQUESTS: 429, + HTTP_STATUS_REQUEST_HEADER_FIELDS_TOO_LARGE: 431, + HTTP_STATUS_UNAVAILABLE_FOR_LEGAL_REASONS: 451, + HTTP_STATUS_INTERNAL_SERVER_ERROR: 500, + HTTP_STATUS_NOT_IMPLEMENTED: 501, + HTTP_STATUS_BAD_GATEWAY: 502, + HTTP_STATUS_SERVICE_UNAVAILABLE: 503, + HTTP_STATUS_GATEWAY_TIMEOUT: 504, + HTTP_STATUS_HTTP_VERSION_NOT_SUPPORTED: 505, + HTTP_STATUS_VARIANT_ALSO_NEGOTIATES: 506, + HTTP_STATUS_INSUFFICIENT_STORAGE: 507, + HTTP_STATUS_LOOP_DETECTED: 508, + HTTP_STATUS_BANDWIDTH_LIMIT_EXCEEDED: 509, + HTTP_STATUS_NOT_EXTENDED: 510, + HTTP_STATUS_NETWORK_AUTHENTICATION_REQUIRED: 511 +}; + +const expectedHeaderNames = { + HTTP2_HEADER_STATUS: ':status', + HTTP2_HEADER_METHOD: ':method', + HTTP2_HEADER_AUTHORITY: ':authority', + HTTP2_HEADER_SCHEME: ':scheme', + HTTP2_HEADER_PATH: ':path', + HTTP2_HEADER_PROTOCOL: ':protocol', + HTTP2_HEADER_DATE: 'date', + HTTP2_HEADER_ACCEPT_CHARSET: 'accept-charset', + HTTP2_HEADER_ACCEPT_ENCODING: 'accept-encoding', + HTTP2_HEADER_ACCEPT_LANGUAGE: 'accept-language', + HTTP2_HEADER_ACCEPT_RANGES: 'accept-ranges', + HTTP2_HEADER_ACCEPT: 'accept', + HTTP2_HEADER_ACCESS_CONTROL_ALLOW_CREDENTIALS: 'access-control-allow-credentials', + HTTP2_HEADER_ACCESS_CONTROL_ALLOW_HEADERS: 'access-control-allow-headers', + HTTP2_HEADER_ACCESS_CONTROL_ALLOW_METHODS: 'access-control-allow-methods', + HTTP2_HEADER_ACCESS_CONTROL_ALLOW_ORIGIN: 'access-control-allow-origin', + HTTP2_HEADER_ACCESS_CONTROL_EXPOSE_HEADERS: 'access-control-expose-headers', + HTTP2_HEADER_ACCESS_CONTROL_MAX_AGE: 'access-control-max-age', + HTTP2_HEADER_ACCESS_CONTROL_REQUEST_HEADERS: 'access-control-request-headers', + HTTP2_HEADER_ACCESS_CONTROL_REQUEST_METHOD: 'access-control-request-method', + HTTP2_HEADER_AGE: 'age', + HTTP2_HEADER_ALLOW: 'allow', + HTTP2_HEADER_AUTHORIZATION: 'authorization', + HTTP2_HEADER_CACHE_CONTROL: 'cache-control', + HTTP2_HEADER_CONTENT_DISPOSITION: 'content-disposition', + HTTP2_HEADER_CONTENT_ENCODING: 'content-encoding', + HTTP2_HEADER_CONTENT_LANGUAGE: 'content-language', + HTTP2_HEADER_CONTENT_LENGTH: 'content-length', + HTTP2_HEADER_CONTENT_LOCATION: 'content-location', + HTTP2_HEADER_CONTENT_RANGE: 'content-range', + HTTP2_HEADER_CONTENT_TYPE: 'content-type', + HTTP2_HEADER_COOKIE: 'cookie', + HTTP2_HEADER_CONNECTION: 'connection', + HTTP2_HEADER_DNT: 'dnt', + HTTP2_HEADER_ETAG: 'etag', + HTTP2_HEADER_EXPECT: 'expect', + HTTP2_HEADER_EXPIRES: 'expires', + HTTP2_HEADER_FORWARDED: 'forwarded', + HTTP2_HEADER_FROM: 'from', + HTTP2_HEADER_HOST: 'host', + HTTP2_HEADER_IF_MATCH: 'if-match', + HTTP2_HEADER_IF_MODIFIED_SINCE: 'if-modified-since', + HTTP2_HEADER_IF_NONE_MATCH: 'if-none-match', + HTTP2_HEADER_IF_RANGE: 'if-range', + HTTP2_HEADER_IF_UNMODIFIED_SINCE: 'if-unmodified-since', + HTTP2_HEADER_LAST_MODIFIED: 'last-modified', + HTTP2_HEADER_LINK: 'link', + HTTP2_HEADER_LOCATION: 'location', + HTTP2_HEADER_MAX_FORWARDS: 'max-forwards', + HTTP2_HEADER_PREFER: 'prefer', + HTTP2_HEADER_PROXY_AUTHENTICATE: 'proxy-authenticate', + HTTP2_HEADER_PROXY_AUTHORIZATION: 'proxy-authorization', + HTTP2_HEADER_PROXY_CONNECTION: 'proxy-connection', + HTTP2_HEADER_RANGE: 'range', + HTTP2_HEADER_REFERER: 'referer', + HTTP2_HEADER_REFRESH: 'refresh', + HTTP2_HEADER_RETRY_AFTER: 'retry-after', + HTTP2_HEADER_SERVER: 'server', + HTTP2_HEADER_SET_COOKIE: 'set-cookie', + HTTP2_HEADER_STRICT_TRANSPORT_SECURITY: 'strict-transport-security', + HTTP2_HEADER_TRAILER: 'trailer', + HTTP2_HEADER_TRANSFER_ENCODING: 'transfer-encoding', + HTTP2_HEADER_TK: 'tk', + HTTP2_HEADER_UPGRADE_INSECURE_REQUESTS: 'upgrade-insecure-requests', + HTTP2_HEADER_USER_AGENT: 'user-agent', + HTTP2_HEADER_VARY: 'vary', + HTTP2_HEADER_VIA: 'via', + HTTP2_HEADER_WARNING: 'warning', + HTTP2_HEADER_WWW_AUTHENTICATE: 'www-authenticate', + HTTP2_HEADER_X_CONTENT_TYPE_OPTIONS: 'x-content-type-options', + HTTP2_HEADER_X_FRAME_OPTIONS: 'x-frame-options', + HTTP2_HEADER_KEEP_ALIVE: 'keep-alive', + HTTP2_HEADER_CONTENT_MD5: 'content-md5', + HTTP2_HEADER_TE: 'te', + HTTP2_HEADER_UPGRADE: 'upgrade', + HTTP2_HEADER_HTTP2_SETTINGS: 'http2-settings', + HTTP2_HEADER_X_XSS_PROTECTION: 'x-xss-protection', + HTTP2_HEADER_ALT_SVC: 'alt-svc', + HTTP2_HEADER_CONTENT_SECURITY_POLICY: 'content-security-policy', + HTTP2_HEADER_EARLY_DATA: 'early-data', + HTTP2_HEADER_EXPECT_CT: 'expect-ct', + HTTP2_HEADER_ORIGIN: 'origin', + HTTP2_HEADER_PURPOSE: 'purpose', + HTTP2_HEADER_TIMING_ALLOW_ORIGIN: 'timing-allow-origin', + HTTP2_HEADER_X_FORWARDED_FOR: 'x-forwarded-for', + HTTP2_HEADER_PRIORITY: 'priority', +}; + +const expectedNGConstants = { + NGHTTP2_SESSION_SERVER: 0, + NGHTTP2_SESSION_CLIENT: 1, + NGHTTP2_STREAM_STATE_IDLE: 1, + NGHTTP2_STREAM_STATE_OPEN: 2, + NGHTTP2_STREAM_STATE_RESERVED_LOCAL: 3, + NGHTTP2_STREAM_STATE_RESERVED_REMOTE: 4, + NGHTTP2_STREAM_STATE_HALF_CLOSED_LOCAL: 5, + NGHTTP2_STREAM_STATE_HALF_CLOSED_REMOTE: 6, + NGHTTP2_STREAM_STATE_CLOSED: 7, + NGHTTP2_HCAT_REQUEST: 0, + NGHTTP2_HCAT_RESPONSE: 1, + NGHTTP2_HCAT_PUSH_RESPONSE: 2, + NGHTTP2_HCAT_HEADERS: 3, + NGHTTP2_NO_ERROR: 0, + NGHTTP2_PROTOCOL_ERROR: 1, + NGHTTP2_INTERNAL_ERROR: 2, + NGHTTP2_FLOW_CONTROL_ERROR: 3, + NGHTTP2_SETTINGS_TIMEOUT: 4, + NGHTTP2_STREAM_CLOSED: 5, + NGHTTP2_FRAME_SIZE_ERROR: 6, + NGHTTP2_REFUSED_STREAM: 7, + NGHTTP2_CANCEL: 8, + NGHTTP2_COMPRESSION_ERROR: 9, + NGHTTP2_CONNECT_ERROR: 10, + NGHTTP2_ENHANCE_YOUR_CALM: 11, + NGHTTP2_INADEQUATE_SECURITY: 12, + NGHTTP2_HTTP_1_1_REQUIRED: 13, + NGHTTP2_NV_FLAG_NONE: 0, + NGHTTP2_NV_FLAG_NO_INDEX: 1, + NGHTTP2_ERR_DEFERRED: -508, + NGHTTP2_ERR_NOMEM: -901, + NGHTTP2_ERR_STREAM_ID_NOT_AVAILABLE: -509, + NGHTTP2_ERR_INVALID_ARGUMENT: -501, + NGHTTP2_ERR_STREAM_CLOSED: -510, + NGHTTP2_ERR_FRAME_SIZE_ERROR: -522, + NGHTTP2_FLAG_NONE: 0, + NGHTTP2_FLAG_END_STREAM: 1, + NGHTTP2_FLAG_END_HEADERS: 4, + NGHTTP2_FLAG_ACK: 1, + NGHTTP2_FLAG_PADDED: 8, + NGHTTP2_FLAG_PRIORITY: 32, + NGHTTP2_DEFAULT_WEIGHT: 16, + NGHTTP2_SETTINGS_HEADER_TABLE_SIZE: 1, + NGHTTP2_SETTINGS_ENABLE_PUSH: 2, + NGHTTP2_SETTINGS_MAX_CONCURRENT_STREAMS: 3, + NGHTTP2_SETTINGS_INITIAL_WINDOW_SIZE: 4, + NGHTTP2_SETTINGS_MAX_FRAME_SIZE: 5, + NGHTTP2_SETTINGS_MAX_HEADER_LIST_SIZE: 6, + NGHTTP2_SETTINGS_ENABLE_CONNECT_PROTOCOL: 8 +}; + +const defaultSettings = { + DEFAULT_SETTINGS_HEADER_TABLE_SIZE: 4096, + DEFAULT_SETTINGS_ENABLE_PUSH: 1, + DEFAULT_SETTINGS_MAX_CONCURRENT_STREAMS: 4294967295, + DEFAULT_SETTINGS_INITIAL_WINDOW_SIZE: 65535, + DEFAULT_SETTINGS_MAX_FRAME_SIZE: 16384, + DEFAULT_SETTINGS_MAX_HEADER_LIST_SIZE: 65535, + DEFAULT_SETTINGS_ENABLE_CONNECT_PROTOCOL: 0 +}; + +for (const name of Object.keys(constants)) { + if (name.startsWith('HTTP_STATUS_')) { + assert.strictEqual(constants[name], expectedStatusCodes[name], + `Expected status code match for ${name}`); + } else if (name.startsWith('HTTP2_HEADER_')) { + assert.strictEqual(constants[name], expectedHeaderNames[name], + `Expected header name match for ${name}`); + } else if (name.startsWith('NGHTTP2_')) { + assert.strictEqual(constants[name], expectedNGConstants[name], + `Expected ng constant match for ${name}`); + } else if (name.startsWith('DEFAULT_SETTINGS_')) { + assert.strictEqual(constants[name], defaultSettings[name], + `Expected default setting match for ${name}`); + } +} diff --git a/test/js/node/test/parallel/test-http2-respond-errors.js b/test/js/node/test/parallel/test-http2-respond-errors.js new file mode 100644 index 000000000000..cc733b6994a3 --- /dev/null +++ b/test/js/node/test/parallel/test-http2-respond-errors.js @@ -0,0 +1,52 @@ +'use strict'; +// Flags: --expose-internals + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); +const assert = require('assert'); +const http2 = require('http2'); +const { internalBinding } = require('internal/test/binding'); +const { Http2Stream } = internalBinding('http2'); + +const server = http2.createServer(); + +Http2Stream.prototype.respond = () => 1; +server.on('stream', common.mustCall((stream) => { + + // Send headers + stream.respond({ 'content-type': 'text/plain' }); + + // Should throw if headers already sent + assert.throws( + () => stream.respond(), + { + name: 'Error', + code: 'ERR_HTTP2_HEADERS_SENT', + message: 'Response has already been initiated.' + } + ); + + // Should throw if stream already destroyed + stream.destroy(); + assert.throws( + () => stream.respond(), + { + name: 'Error', + code: 'ERR_HTTP2_INVALID_STREAM', + message: 'The stream has been destroyed' + } + ); +})); + +server.listen(0, common.mustCall(() => { + const client = http2.connect(`http://localhost:${server.address().port}`); + const req = client.request(); + + req.on('end', common.mustCall(() => { + client.close(); + server.close(); + })); + req.resume(); + req.end(); +}));