diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5f132aaefa1f..f09d804d3bed 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -35,6 +35,7 @@ export { NewTransport, TransportMakeRequestResponse, TransportRequest, + TransportRequestExecutor, } from './transports/base'; export { SDK_VERSION } from './version'; diff --git a/packages/core/src/transports/base.ts b/packages/core/src/transports/base.ts index 6290fbfec36e..663a4c3c686f 100644 --- a/packages/core/src/transports/base.ts +++ b/packages/core/src/transports/base.ts @@ -67,17 +67,6 @@ export interface BrowserTransportOptions extends BaseTransportOptions { sendClientReports?: boolean; } -// TODO: Move into Node transport -export interface NodeTransportOptions extends BaseTransportOptions { - headers?: Record; - // Set a HTTP proxy that should be used for outbound requests. - httpProxy?: string; - // Set a HTTPS proxy that should be used for outbound requests. - httpsProxy?: string; - // HTTPS proxy certificates path - caCerts?: string; -} - export interface NewTransport { send(request: Envelope): PromiseLike; flush(timeout?: number): PromiseLike; diff --git a/packages/node/src/backend.ts b/packages/node/src/backend.ts index 61cbfad9c819..f319673ebc18 100644 --- a/packages/node/src/backend.ts +++ b/packages/node/src/backend.ts @@ -1,9 +1,9 @@ -import { BaseBackend } from '@sentry/core'; +import { BaseBackend, getEnvelopeEndpointWithUrlEncodedAuth, initAPIDetails } from '@sentry/core'; import { Event, EventHint, Severity, Transport, TransportOptions } from '@sentry/types'; import { makeDsn, resolvedSyncPromise } from '@sentry/utils'; import { eventFromMessage, eventFromUnknownInput } from './eventbuilder'; -import { HTTPSTransport, HTTPTransport } from './transports'; +import { HTTPSTransport, HTTPTransport, makeNodeTransport } from './transports'; import { NodeOptions } from './types'; /** @@ -50,6 +50,17 @@ export class NodeBackend extends BaseBackend { if (this._options.transport) { return new this._options.transport(transportOptions); } + + const api = initAPIDetails(transportOptions.dsn, transportOptions._metadata, transportOptions.tunnel); + const url = getEnvelopeEndpointWithUrlEncodedAuth(api.dsn, api.tunnel); + + this._newTransport = makeNodeTransport({ + url, + headers: transportOptions.headers, + proxy: transportOptions.httpProxy, + caCerts: transportOptions.caCerts, + }); + if (dsn.protocol === 'http') { return new HTTPTransport(transportOptions); } diff --git a/packages/node/src/transports/index.ts b/packages/node/src/transports/index.ts index f48517061c2f..2cabeee08d5f 100644 --- a/packages/node/src/transports/index.ts +++ b/packages/node/src/transports/index.ts @@ -1,3 +1,4 @@ export { BaseTransport } from './base'; export { HTTPTransport } from './http'; export { HTTPSTransport } from './https'; +export { makeNodeTransport, NodeTransportOptions } from './new'; diff --git a/packages/node/src/transports/new.ts b/packages/node/src/transports/new.ts new file mode 100644 index 000000000000..b30dc3ffe36f --- /dev/null +++ b/packages/node/src/transports/new.ts @@ -0,0 +1,142 @@ +import { + BaseTransportOptions, + createTransport, + NewTransport, + TransportMakeRequestResponse, + TransportRequest, + TransportRequestExecutor, +} from '@sentry/core'; +import { eventStatusFromHttpCode } from '@sentry/utils'; +import * as http from 'http'; +import * as https from 'https'; +import { URL } from 'url'; + +import { HTTPModule } from './base/http-module'; + +// TODO(v7): +// - Rename this file "transport.ts" +// - Move this file one folder upwards +// - Delete "transports" folder +// OR +// - Split this file up and leave it in the transports folder + +export interface NodeTransportOptions extends BaseTransportOptions { + /** Define custom headers */ + headers?: Record; + /** Set a proxy that should be used for outbound requests. */ + proxy?: string; + /** HTTPS proxy CA certificates */ + caCerts?: string | Buffer | Array; + /** Custom HTTP module. Defaults to the native 'http' and 'https' modules. */ + httpModule?: HTTPModule; +} + +/** + * Creates a Transport that uses native the native 'http' and 'https' modules to send events to Sentry. + */ +export function makeNodeTransport(options: NodeTransportOptions): NewTransport { + const urlSegments = new URL(options.url); + const isHttps = urlSegments.protocol === 'https:'; + + // Proxy prioritization: http => `options.proxy` | `process.env.http_proxy` + // Proxy prioritization: https => `options.proxy` | `process.env.https_proxy` | `process.env.http_proxy` + const proxy = applyNoProxyOption( + urlSegments, + options.proxy || (isHttps ? process.env.https_proxy : undefined) || process.env.http_proxy, + ); + + const nativeHttpModule = isHttps ? https : http; + + // TODO(v7): Evaluate if we can set keepAlive to true. This would involve testing for memory leaks in older node + // versions(>= 8) as they had memory leaks when using it: #2555 + const agent = proxy + ? (new (require('https-proxy-agent'))(proxy) as http.Agent) + : new nativeHttpModule.Agent({ keepAlive: false, maxSockets: 30, timeout: 2000 }); + + const requestExecutor = createRequestExecutor(options, options.httpModule ?? nativeHttpModule, agent); + return createTransport({ bufferSize: options.bufferSize }, requestExecutor); +} + +/** + * Honors the `no_proxy` env variable with the highest priority to allow for hosts exclusion. + * + * @param transportUrl The URL the transport intends to send events to. + * @param proxy The client configured proxy. + * @returns A proxy the transport should use. + */ +function applyNoProxyOption(transportUrlSegments: URL, proxy: string | undefined): string | undefined { + const { no_proxy } = process.env; + + const urlIsExemptFromProxy = + no_proxy && + no_proxy + .split(',') + .some( + exemption => transportUrlSegments.host.endsWith(exemption) || transportUrlSegments.hostname.endsWith(exemption), + ); + + if (urlIsExemptFromProxy) { + return undefined; + } else { + return proxy; + } +} + +/** + * Creates a RequestExecutor to be used with `createTransport`. + */ +function createRequestExecutor( + options: NodeTransportOptions, + httpModule: HTTPModule, + agent: http.Agent, +): TransportRequestExecutor { + const { hostname, pathname, port, protocol, search } = new URL(options.url); + + return function makeRequest(request: TransportRequest): Promise { + return new Promise((resolve, reject) => { + const req = httpModule.request( + { + method: 'POST', + agent, + headers: options.headers, + hostname, + path: `${pathname}${search}`, + port, + protocol, + ca: options.caCerts, + }, + res => { + res.on('data', () => { + // Drain socket + }); + + res.on('end', () => { + // Drain socket + }); + + const statusCode = res.statusCode ?? 500; + const status = eventStatusFromHttpCode(statusCode); + + res.setEncoding('utf8'); + + // "Key-value pairs of header names and values. Header names are lower-cased." + // https://nodejs.org/api/http.html#http_message_headers + const retryAfterHeader = res.headers['retry-after'] ?? null; + const rateLimitsHeader = res.headers['x-sentry-rate-limits'] ?? null; + + resolve({ + headers: { + 'retry-after': retryAfterHeader, + 'x-sentry-rate-limits': Array.isArray(rateLimitsHeader) ? rateLimitsHeader[0] : rateLimitsHeader, + }, + reason: status, + statusCode: statusCode, + }); + }, + ); + + req.on('error', reject); + req.end(request.body); + }); + }; +} diff --git a/packages/node/test/transports/new/http.test.ts b/packages/node/test/transports/new/http.test.ts new file mode 100644 index 000000000000..b3ce46d5a542 --- /dev/null +++ b/packages/node/test/transports/new/http.test.ts @@ -0,0 +1,347 @@ +import { createTransport } from '@sentry/core'; +import { EventEnvelope, EventItem } from '@sentry/types'; +import { createEnvelope, serializeEnvelope } from '@sentry/utils'; +import * as http from 'http'; + +// TODO(v7): We're renaming the imported file so this needs to be changed as well +import { makeNodeTransport } from '../../../src/transports/new'; + +jest.mock('@sentry/core', () => { + const actualCore = jest.requireActual('@sentry/core'); + return { + ...actualCore, + createTransport: jest.fn().mockImplementation(actualCore.createTransport), + }; +}); + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const httpProxyAgent = require('https-proxy-agent'); +jest.mock('https-proxy-agent', () => { + return jest.fn().mockImplementation(() => new http.Agent({ keepAlive: false, maxSockets: 30, timeout: 2000 })); +}); + +const SUCCESS = 200; +const RATE_LIMIT = 429; +const INVALID = 400; +const FAILED = 500; + +interface TestServerOptions { + statusCode: number; + responseHeaders?: Record; +} + +let testServer: http.Server | undefined; + +function setupTestServer( + options: TestServerOptions, + requestInspector?: (req: http.IncomingMessage, body: string) => void, +) { + testServer = http.createServer((req, res) => { + let body = ''; + + req.on('data', data => { + body += data; + }); + + req.on('end', () => { + requestInspector?.(req, body); + }); + + res.writeHead(options.statusCode, options.responseHeaders); + res.end(); + + // also terminate socket because keepalive hangs connection a bit + res.connection.end(); + }); + + testServer.listen(18099); + + return new Promise(resolve => { + testServer?.on('listening', resolve); + }); +} + +const TEST_SERVER_URL = 'http://localhost:18099'; + +const EVENT_ENVELOPE = createEnvelope({ event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', sent_at: '123' }, [ + [{ type: 'event' }, { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2' }] as EventItem, +]); + +const SERIALIZED_EVENT_ENVELOPE = serializeEnvelope(EVENT_ENVELOPE); + +describe('makeNewHttpTransport()', () => { + afterEach(() => { + jest.clearAllMocks(); + + if (testServer) { + testServer.close(); + } + }); + + describe('.send()', () => { + it('should correctly return successful server response', async () => { + await setupTestServer({ statusCode: SUCCESS }); + + const transport = makeNodeTransport({ url: TEST_SERVER_URL }); + const transportResponse = await transport.send(EVENT_ENVELOPE); + + expect(transportResponse).toEqual(expect.objectContaining({ status: 'success' })); + }); + + it('should correctly send envelope to server', async () => { + await setupTestServer({ statusCode: SUCCESS }, (req, body) => { + expect(req.method).toBe('POST'); + expect(body).toBe(SERIALIZED_EVENT_ENVELOPE); + }); + + const transport = makeNodeTransport({ url: TEST_SERVER_URL }); + await transport.send(EVENT_ENVELOPE); + }); + + it('should correctly send user-provided headers to server', async () => { + await setupTestServer({ statusCode: SUCCESS }, req => { + expect(req.headers).toEqual( + expect.objectContaining({ + // node http module lower-cases incoming headers + 'x-some-custom-header-1': 'value1', + 'x-some-custom-header-2': 'value2', + }), + ); + }); + + const transport = makeNodeTransport({ + url: TEST_SERVER_URL, + headers: { + 'X-Some-Custom-Header-1': 'value1', + 'X-Some-Custom-Header-2': 'value2', + }, + }); + + await transport.send(EVENT_ENVELOPE); + }); + + it.each([ + [RATE_LIMIT, 'rate_limit'], + [INVALID, 'invalid'], + [FAILED, 'failed'], + ])('should correctly reject bad server response (status %i)', async (serverStatusCode, expectedStatus) => { + await setupTestServer({ statusCode: serverStatusCode }); + + const transport = makeNodeTransport({ url: TEST_SERVER_URL }); + await expect(transport.send(EVENT_ENVELOPE)).rejects.toEqual(expect.objectContaining({ status: expectedStatus })); + }); + + it('should resolve when server responds with rate limit header and status code 200', async () => { + await setupTestServer({ + statusCode: SUCCESS, + responseHeaders: { + 'Retry-After': '2700', + 'X-Sentry-Rate-Limits': '60::organization, 2700::organization', + }, + }); + + const transport = makeNodeTransport({ url: TEST_SERVER_URL }); + const transportResponse = await transport.send(EVENT_ENVELOPE); + + expect(transportResponse).toEqual(expect.objectContaining({ status: 'success' })); + }); + + it('should resolve when server responds with rate limit header and status code 200', async () => { + await setupTestServer({ + statusCode: SUCCESS, + responseHeaders: { + 'Retry-After': '2700', + 'X-Sentry-Rate-Limits': '60::organization, 2700::organization', + }, + }); + + const transport = makeNodeTransport({ url: TEST_SERVER_URL }); + const transportResponse = await transport.send(EVENT_ENVELOPE); + + expect(transportResponse).toEqual(expect.objectContaining({ status: 'success' })); + }); + }); + + describe('proxy', () => { + it('can be configured through option', () => { + makeNodeTransport({ + url: 'http://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622', + proxy: 'http://example.com', + }); + + expect(httpProxyAgent).toHaveBeenCalledTimes(1); + expect(httpProxyAgent).toHaveBeenCalledWith('http://example.com'); + }); + + it('can be configured through env variables option', () => { + process.env.http_proxy = 'http://example.com'; + makeNodeTransport({ + url: 'http://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622', + }); + + expect(httpProxyAgent).toHaveBeenCalledTimes(1); + expect(httpProxyAgent).toHaveBeenCalledWith('http://example.com'); + delete process.env.http_proxy; + }); + + it('client options have priority over env variables', () => { + process.env.http_proxy = 'http://foo.com'; + makeNodeTransport({ + url: 'http://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622', + proxy: 'http://bar.com', + }); + + expect(httpProxyAgent).toHaveBeenCalledTimes(1); + expect(httpProxyAgent).toHaveBeenCalledWith('http://bar.com'); + delete process.env.http_proxy; + }); + + it('no_proxy allows for skipping specific hosts', () => { + process.env.no_proxy = 'sentry.io'; + makeNodeTransport({ + url: 'http://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622', + proxy: 'http://example.com', + }); + + expect(httpProxyAgent).not.toHaveBeenCalled(); + + delete process.env.no_proxy; + }); + + it('no_proxy works with a port', () => { + process.env.http_proxy = 'http://example.com:8080'; + process.env.no_proxy = 'sentry.io:8989'; + + makeNodeTransport({ + url: 'http://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622', + }); + + expect(httpProxyAgent).not.toHaveBeenCalled(); + + delete process.env.no_proxy; + delete process.env.http_proxy; + }); + + it('no_proxy works with multiple comma-separated hosts', () => { + process.env.http_proxy = 'http://example.com:8080'; + process.env.no_proxy = 'example.com,sentry.io,wat.com:1337'; + + makeNodeTransport({ + url: 'http://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622', + }); + + expect(httpProxyAgent).not.toHaveBeenCalled(); + + delete process.env.no_proxy; + delete process.env.http_proxy; + }); + }); + + it('should register TransportRequestExecutor that returns the correct object from server response (rate limit)', async () => { + await setupTestServer({ + statusCode: RATE_LIMIT, + responseHeaders: { + 'Retry-After': '2700', + 'X-Sentry-Rate-Limits': '60::organization, 2700::organization', + }, + }); + + makeNodeTransport({ url: TEST_SERVER_URL }); + const registeredRequestExecutor = (createTransport as jest.Mock).mock.calls[0][1]; + + const executorResult = registeredRequestExecutor({ + body: serializeEnvelope(EVENT_ENVELOPE), + category: 'error', + }); + + await expect(executorResult).resolves.toEqual( + expect.objectContaining({ + headers: { + 'retry-after': '2700', + 'x-sentry-rate-limits': '60::organization, 2700::organization', + }, + statusCode: RATE_LIMIT, + }), + ); + }); + + it('should register TransportRequestExecutor that returns the correct object from server response (OK)', async () => { + await setupTestServer({ + statusCode: SUCCESS, + }); + + makeNodeTransport({ url: TEST_SERVER_URL }); + const registeredRequestExecutor = (createTransport as jest.Mock).mock.calls[0][1]; + + const executorResult = registeredRequestExecutor({ + body: serializeEnvelope(EVENT_ENVELOPE), + category: 'error', + }); + + await expect(executorResult).resolves.toEqual( + expect.objectContaining({ + headers: { + 'retry-after': null, + 'x-sentry-rate-limits': null, + }, + statusCode: SUCCESS, + }), + ); + }); + + it('should register TransportRequestExecutor that returns the correct object from server response (OK with rate-limit headers)', async () => { + await setupTestServer({ + statusCode: SUCCESS, + responseHeaders: { + 'Retry-After': '2700', + 'X-Sentry-Rate-Limits': '60::organization, 2700::organization', + }, + }); + + makeNodeTransport({ url: TEST_SERVER_URL }); + const registeredRequestExecutor = (createTransport as jest.Mock).mock.calls[0][1]; + + const executorResult = registeredRequestExecutor({ + body: serializeEnvelope(EVENT_ENVELOPE), + category: 'error', + }); + + await expect(executorResult).resolves.toEqual( + expect.objectContaining({ + headers: { + 'retry-after': '2700', + 'x-sentry-rate-limits': '60::organization, 2700::organization', + }, + statusCode: SUCCESS, + }), + ); + }); + + it('should register TransportRequestExecutor that returns the correct object from server response (NOK with rate-limit headers)', async () => { + await setupTestServer({ + statusCode: RATE_LIMIT, + responseHeaders: { + 'Retry-After': '2700', + 'X-Sentry-Rate-Limits': '60::organization, 2700::organization', + }, + }); + + makeNodeTransport({ url: TEST_SERVER_URL }); + const registeredRequestExecutor = (createTransport as jest.Mock).mock.calls[0][1]; + + const executorResult = registeredRequestExecutor({ + body: serializeEnvelope(EVENT_ENVELOPE), + category: 'error', + }); + + await expect(executorResult).resolves.toEqual( + expect.objectContaining({ + headers: { + 'retry-after': '2700', + 'x-sentry-rate-limits': '60::organization, 2700::organization', + }, + statusCode: RATE_LIMIT, + }), + ); + }); +}); diff --git a/packages/node/test/transports/new/https.test.ts b/packages/node/test/transports/new/https.test.ts new file mode 100644 index 000000000000..7784e16c65df --- /dev/null +++ b/packages/node/test/transports/new/https.test.ts @@ -0,0 +1,397 @@ +import { createTransport } from '@sentry/core'; +import { EventEnvelope, EventItem } from '@sentry/types'; +import { createEnvelope, serializeEnvelope } from '@sentry/utils'; +import * as http from 'http'; +import * as https from 'https'; + +import { HTTPModule, HTTPModuleRequestIncomingMessage } from '../../../src/transports/base/http-module'; +// TODO(v7): We're renaming the imported file so this needs to be changed as well +import { makeNodeTransport } from '../../../src/transports/new'; +import testServerCerts from './test-server-certs'; + +jest.mock('@sentry/core', () => { + const actualCore = jest.requireActual('@sentry/core'); + return { + ...actualCore, + createTransport: jest.fn().mockImplementation(actualCore.createTransport), + }; +}); + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const httpProxyAgent = require('https-proxy-agent'); +jest.mock('https-proxy-agent', () => { + return jest.fn().mockImplementation(() => new http.Agent({ keepAlive: false, maxSockets: 30, timeout: 2000 })); +}); + +const SUCCESS = 200; +const RATE_LIMIT = 429; +const INVALID = 400; +const FAILED = 500; + +interface TestServerOptions { + statusCode: number; + responseHeaders?: Record; +} + +let testServer: http.Server | undefined; + +function setupTestServer( + options: TestServerOptions, + requestInspector?: (req: http.IncomingMessage, body: string) => void, +) { + testServer = https.createServer(testServerCerts, (req, res) => { + let body = ''; + + req.on('data', data => { + body += data; + }); + + req.on('end', () => { + requestInspector?.(req, body); + }); + + res.writeHead(options.statusCode, options.responseHeaders); + res.end(); + + // also terminate socket because keepalive hangs connection a bit + res.connection.end(); + }); + + testServer.listen(8099); + + return new Promise(resolve => { + testServer?.on('listening', resolve); + }); +} + +const TEST_SERVER_URL = 'https://localhost:8099'; + +const EVENT_ENVELOPE = createEnvelope({ event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', sent_at: '123' }, [ + [{ type: 'event' }, { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2' }] as EventItem, +]); + +const SERIALIZED_EVENT_ENVELOPE = serializeEnvelope(EVENT_ENVELOPE); + +const unsafeHttpsModule: HTTPModule = { + request: jest + .fn() + .mockImplementation((options: https.RequestOptions, callback?: (res: HTTPModuleRequestIncomingMessage) => void) => { + return https.request({ ...options, rejectUnauthorized: false }, callback); + }), +}; + +describe('makeNewHttpsTransport()', () => { + afterEach(() => { + jest.clearAllMocks(); + + if (testServer) { + testServer.close(); + } + }); + + describe('.send()', () => { + it('should correctly return successful server response', async () => { + await setupTestServer({ statusCode: SUCCESS }); + + const transport = makeNodeTransport({ httpModule: unsafeHttpsModule, url: TEST_SERVER_URL }); + const transportResponse = await transport.send(EVENT_ENVELOPE); + + expect(transportResponse).toEqual(expect.objectContaining({ status: 'success' })); + }); + + it('should correctly send envelope to server', async () => { + await setupTestServer({ statusCode: SUCCESS }, (req, body) => { + expect(req.method).toBe('POST'); + expect(body).toBe(SERIALIZED_EVENT_ENVELOPE); + }); + + const transport = makeNodeTransport({ httpModule: unsafeHttpsModule, url: TEST_SERVER_URL }); + await transport.send(EVENT_ENVELOPE); + }); + + it('should correctly send user-provided headers to server', async () => { + await setupTestServer({ statusCode: SUCCESS }, req => { + expect(req.headers).toEqual( + expect.objectContaining({ + // node http module lower-cases incoming headers + 'x-some-custom-header-1': 'value1', + 'x-some-custom-header-2': 'value2', + }), + ); + }); + + const transport = makeNodeTransport({ + httpModule: unsafeHttpsModule, + url: TEST_SERVER_URL, + headers: { + 'X-Some-Custom-Header-1': 'value1', + 'X-Some-Custom-Header-2': 'value2', + }, + }); + + await transport.send(EVENT_ENVELOPE); + }); + + it.each([ + [RATE_LIMIT, 'rate_limit'], + [INVALID, 'invalid'], + [FAILED, 'failed'], + ])('should correctly reject bad server response (status %i)', async (serverStatusCode, expectedStatus) => { + await setupTestServer({ statusCode: serverStatusCode }); + + const transport = makeNodeTransport({ httpModule: unsafeHttpsModule, url: TEST_SERVER_URL }); + await expect(transport.send(EVENT_ENVELOPE)).rejects.toEqual(expect.objectContaining({ status: expectedStatus })); + }); + + it('should resolve when server responds with rate limit header and status code 200', async () => { + await setupTestServer({ + statusCode: SUCCESS, + responseHeaders: { + 'Retry-After': '2700', + 'X-Sentry-Rate-Limits': '60::organization, 2700::organization', + }, + }); + + const transport = makeNodeTransport({ httpModule: unsafeHttpsModule, url: TEST_SERVER_URL }); + const transportResponse = await transport.send(EVENT_ENVELOPE); + + expect(transportResponse).toEqual(expect.objectContaining({ status: 'success' })); + }); + + it('should resolve when server responds with rate limit header and status code 200', async () => { + await setupTestServer({ + statusCode: SUCCESS, + responseHeaders: { + 'Retry-After': '2700', + 'X-Sentry-Rate-Limits': '60::organization, 2700::organization', + }, + }); + + const transport = makeNodeTransport({ httpModule: unsafeHttpsModule, url: TEST_SERVER_URL }); + const transportResponse = await transport.send(EVENT_ENVELOPE); + + expect(transportResponse).toEqual(expect.objectContaining({ status: 'success' })); + }); + + it('should use `caCerts` option', async () => { + await setupTestServer({ statusCode: SUCCESS }); + + const transport = makeNodeTransport({ + httpModule: unsafeHttpsModule, + url: TEST_SERVER_URL, + caCerts: 'some cert', + }); + + await transport.send(EVENT_ENVELOPE); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(unsafeHttpsModule.request).toHaveBeenCalledWith( + expect.objectContaining({ + ca: 'some cert', + }), + expect.anything(), + ); + }); + }); + + describe('proxy', () => { + it('can be configured through option', () => { + makeNodeTransport({ + httpModule: unsafeHttpsModule, + url: 'https://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622', + proxy: 'https://example.com', + }); + + expect(httpProxyAgent).toHaveBeenCalledTimes(1); + expect(httpProxyAgent).toHaveBeenCalledWith('https://example.com'); + }); + + it('can be configured through env variables option (http)', () => { + process.env.http_proxy = 'https://example.com'; + makeNodeTransport({ + httpModule: unsafeHttpsModule, + url: 'https://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622', + }); + + expect(httpProxyAgent).toHaveBeenCalledTimes(1); + expect(httpProxyAgent).toHaveBeenCalledWith('https://example.com'); + delete process.env.http_proxy; + }); + + it('can be configured through env variables option (https)', () => { + process.env.https_proxy = 'https://example.com'; + makeNodeTransport({ + httpModule: unsafeHttpsModule, + url: 'https://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622', + }); + + expect(httpProxyAgent).toHaveBeenCalledTimes(1); + expect(httpProxyAgent).toHaveBeenCalledWith('https://example.com'); + delete process.env.https_proxy; + }); + + it('client options have priority over env variables', () => { + process.env.https_proxy = 'https://foo.com'; + makeNodeTransport({ + httpModule: unsafeHttpsModule, + url: 'https://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622', + proxy: 'https://bar.com', + }); + + expect(httpProxyAgent).toHaveBeenCalledTimes(1); + expect(httpProxyAgent).toHaveBeenCalledWith('https://bar.com'); + delete process.env.https_proxy; + }); + + it('no_proxy allows for skipping specific hosts', () => { + process.env.no_proxy = 'sentry.io'; + makeNodeTransport({ + httpModule: unsafeHttpsModule, + url: 'https://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622', + proxy: 'https://example.com', + }); + + expect(httpProxyAgent).not.toHaveBeenCalled(); + + delete process.env.no_proxy; + }); + + it('no_proxy works with a port', () => { + process.env.http_proxy = 'https://example.com:8080'; + process.env.no_proxy = 'sentry.io:8989'; + + makeNodeTransport({ + httpModule: unsafeHttpsModule, + url: 'https://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622', + }); + + expect(httpProxyAgent).not.toHaveBeenCalled(); + + delete process.env.no_proxy; + delete process.env.http_proxy; + }); + + it('no_proxy works with multiple comma-separated hosts', () => { + process.env.http_proxy = 'https://example.com:8080'; + process.env.no_proxy = 'example.com,sentry.io,wat.com:1337'; + + makeNodeTransport({ + httpModule: unsafeHttpsModule, + url: 'https://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622', + }); + + expect(httpProxyAgent).not.toHaveBeenCalled(); + + delete process.env.no_proxy; + delete process.env.http_proxy; + }); + }); + + it('should register TransportRequestExecutor that returns the correct object from server response (rate limit)', async () => { + await setupTestServer({ + statusCode: RATE_LIMIT, + responseHeaders: { + 'Retry-After': '2700', + 'X-Sentry-Rate-Limits': '60::organization, 2700::organization', + }, + }); + + makeNodeTransport({ httpModule: unsafeHttpsModule, url: TEST_SERVER_URL }); + const registeredRequestExecutor = (createTransport as jest.Mock).mock.calls[0][1]; + + const executorResult = registeredRequestExecutor({ + body: serializeEnvelope(EVENT_ENVELOPE), + category: 'error', + }); + + await expect(executorResult).resolves.toEqual( + expect.objectContaining({ + headers: { + 'retry-after': '2700', + 'x-sentry-rate-limits': '60::organization, 2700::organization', + }, + statusCode: RATE_LIMIT, + }), + ); + }); + + it('should register TransportRequestExecutor that returns the correct object from server response (OK)', async () => { + await setupTestServer({ + statusCode: SUCCESS, + }); + + makeNodeTransport({ httpModule: unsafeHttpsModule, url: TEST_SERVER_URL }); + const registeredRequestExecutor = (createTransport as jest.Mock).mock.calls[0][1]; + + const executorResult = registeredRequestExecutor({ + body: serializeEnvelope(EVENT_ENVELOPE), + category: 'error', + }); + + await expect(executorResult).resolves.toEqual( + expect.objectContaining({ + headers: { + 'retry-after': null, + 'x-sentry-rate-limits': null, + }, + statusCode: SUCCESS, + }), + ); + }); + + it('should register TransportRequestExecutor that returns the correct object from server response (OK with rate-limit headers)', async () => { + await setupTestServer({ + statusCode: SUCCESS, + responseHeaders: { + 'Retry-After': '2700', + 'X-Sentry-Rate-Limits': '60::organization, 2700::organization', + }, + }); + + makeNodeTransport({ httpModule: unsafeHttpsModule, url: TEST_SERVER_URL }); + const registeredRequestExecutor = (createTransport as jest.Mock).mock.calls[0][1]; + + const executorResult = registeredRequestExecutor({ + body: serializeEnvelope(EVENT_ENVELOPE), + category: 'error', + }); + + await expect(executorResult).resolves.toEqual( + expect.objectContaining({ + headers: { + 'retry-after': '2700', + 'x-sentry-rate-limits': '60::organization, 2700::organization', + }, + statusCode: SUCCESS, + }), + ); + }); + + it('should register TransportRequestExecutor that returns the correct object from server response (NOK with rate-limit headers)', async () => { + await setupTestServer({ + statusCode: RATE_LIMIT, + responseHeaders: { + 'Retry-After': '2700', + 'X-Sentry-Rate-Limits': '60::organization, 2700::organization', + }, + }); + + makeNodeTransport({ httpModule: unsafeHttpsModule, url: TEST_SERVER_URL }); + const registeredRequestExecutor = (createTransport as jest.Mock).mock.calls[0][1]; + + const executorResult = registeredRequestExecutor({ + body: serializeEnvelope(EVENT_ENVELOPE), + category: 'error', + }); + + await expect(executorResult).resolves.toEqual( + expect.objectContaining({ + headers: { + 'retry-after': '2700', + 'x-sentry-rate-limits': '60::organization, 2700::organization', + }, + statusCode: RATE_LIMIT, + }), + ); + }); +}); diff --git a/packages/node/test/transports/new/test-server-certs.ts b/packages/node/test/transports/new/test-server-certs.ts new file mode 100644 index 000000000000..a5ce436c4234 --- /dev/null +++ b/packages/node/test/transports/new/test-server-certs.ts @@ -0,0 +1,48 @@ +export default { + key: `-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAuMunjXC2tu2d4x8vKuPQbHwPjYG6pVvAUs7wzpDnMEGo3o2A +bZpL7vUAkQWZ86M84rX9b65cVvT35uqM9uxnJKQhSdGARxEcrz9yxjc9RaIO9xM4 +6WdFd6pcVHW9MF6njnc19jyIoSGXRADJjreNZHyMobAHyL2ZbFiptknUWFW3YT4t +q9bQD5yfhZ94fRt1IbdBAn5Bmz6x61BYudWU2KA3G1akPUmzj0OwZwaIrnGbfLUH +M5F50dNUYfCdmxtE8YRBPyWwcg+KOWa/P8C84p1UQ+/0GHNqUTa4wXBgKeUXNjth +AhV/4JgDDdec+/W0Z1UdEqxZvKfAYnjveFpxEwIDAQABAoIBADLsjEPB59gJKxVH +pqvfE7SRi4enVFP1MM6hEGMcM1ls/qg1vkp11q8G/Rz5ui8VsNWY6To5hmDAKQCN +akMxaksCn9nDzeHHqWvxxCMzXcMuoYkc1vYa613KqJ7twzDtJKdx2oD8tXoR06l9 +vg2CL4idefOkmsCK3xioZjxBpC6jF6ybvlY241MGhaAGRHmP6ik1uFJ+6Y8smh6R +AQKO0u0oQPy6bka9F6DTP6BMUeZ+OA/oOrrb5FxTHu8AHcyCSk2wHnCkB9EF/Ou2 +xSWrnu0O0/0Px6OO9oEsNSq2/fKNV9iuEU8LeAoDVm4ysyMrPce2c4ZsB4U244bj +yQpQZ6ECgYEA9KwA7Lmyf+eeZHxEM4MNSqyeXBtSKu4Zyk0RRY1j69ConjHKet3Q +ylVedXQ0/FJAHHKEm4zFGZtnaaxrzCIcQSKJBCoaA+cN44MM3D1nKmHjgPy8R/yE +BNgIVwJB1MmVSGa+NYnQgUomcCIEr/guNMIxV7p2iybqoxaEHKLfGFUCgYEAwVn1 +8LARsZihLUdxxbAc9+v/pBeMTrkTw1eN1ki9VWYoRam2MLozehEzabt677cU4h7+ +bjdKCKo1x2liY9zmbIiVHssv9Jf3E9XhcajsXB42m1+kjUYVPh8o9lDXcatV9EKt +DZK8wfRY9boyDKB2zRyo6bvIEK3qWbas31W3a8cCgYA6w0TFliPkzEAiaiYHKSZ8 +FNFD1dv6K41OJQxM5BRngom81MCImdWXgsFY/DvtjeOP8YEfysNbzxMbMioBsP+Q +NTcrJOFypn+TcNoZ2zV33GLDi++8ak1azHfUTdp5vKB57xMn0J2fL6vjqoftq3GN +gkZPh50I9qPL35CDQCrMsQKBgC6tFfc1uf/Cld5FagzMOCINodguKxvyB/hXUZFS +XAqar8wpbScUPEsSjfPPY50s+GiiDM/0nvW6iWMLaMos0J+Q1VbqvDfy2525O0Ri +ADU4wfv+Oc41BfnKMexMlcYGE6j006v8KX81Cqi/e0ebETLw4UITp/eG1JU1yUPd +AHuPAoGBAL25v4/onoH0FBLdEwb2BAENxc+0g4In1T+83jfHbfD0gOF3XTbgH4FF +MduIG8qBoZC5whiZ3qH7YJK7sydaM1bDwiesqIik+gEUE65T7S2ZF84y5GC5JjTf +z6v6i+DMCIJXDY5/gjzOED6UllV2Jrn2pDoV++zVyR6KAwXpCmK6 +-----END RSA PRIVATE KEY-----`, + cert: `-----BEGIN CERTIFICATE----- +MIIDETCCAfkCFCMI53aBdS2kWTrw39Kkv93ErG3iMA0GCSqGSIb3DQEBCwUAMEUx +CzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRl +cm5ldCBXaWRnaXRzIFB0eSBMdGQwHhcNMjIwMzI4MDgzODQwWhcNNDkwODEyMDgz +ODQwWjBFMQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UE +CgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOC +AQ8AMIIBCgKCAQEAuMunjXC2tu2d4x8vKuPQbHwPjYG6pVvAUs7wzpDnMEGo3o2A +bZpL7vUAkQWZ86M84rX9b65cVvT35uqM9uxnJKQhSdGARxEcrz9yxjc9RaIO9xM4 +6WdFd6pcVHW9MF6njnc19jyIoSGXRADJjreNZHyMobAHyL2ZbFiptknUWFW3YT4t +q9bQD5yfhZ94fRt1IbdBAn5Bmz6x61BYudWU2KA3G1akPUmzj0OwZwaIrnGbfLUH +M5F50dNUYfCdmxtE8YRBPyWwcg+KOWa/P8C84p1UQ+/0GHNqUTa4wXBgKeUXNjth +AhV/4JgDDdec+/W0Z1UdEqxZvKfAYnjveFpxEwIDAQABMA0GCSqGSIb3DQEBCwUA +A4IBAQBh4BKiByhyvAc5uHj5bkSqspY2xZWW8xiEGaCaQWDMlyjP9mVVWFHfE3XL +lzsJdZVnHDZUliuA5L+qTEpLJ5GmgDWqnKp3HdhtkL16mPbPyJLPY0X+m7wvoZRt +RwLfFCx1E13m0ktYWWgmSCnBl+rI7pyagDhZ2feyxsMrecCazyG/llFBuyWSOnIi +OHxjdHV7be5c8uOOp1iNB9j++LW1pRVrSCWOKRLcsUBal73FW+UvhM5+1If/F9pF +GNQrMhVRA8aHD0JAu3tpjYRKRuOpAbbqtiAUSbDPsJBQy/K9no2K83G7+AV+aGai +HXfQqFFJS6xGKU79azH51wLVEGXq +-----END CERTIFICATE-----`, +};