Skip to content

test: Add test utility to intercept requests to Sentry #7271

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Feb 24, 2023
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"packages/ember",
"packages/eslint-config-sdk",
"packages/eslint-plugin-sdk",
"packages/event-proxy-server",
"packages/gatsby",
"packages/hub",
"packages/integration-tests",
Expand Down
220 changes: 220 additions & 0 deletions packages/e2e-tests/test-utils/event-proxy-server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
import type { Envelope, EnvelopeItem, Event } from '@sentry/types';
import { parseEnvelope } from '@sentry/utils';
import * as fs from 'fs';
import * as http from 'http';
import * as https from 'https';
import type { AddressInfo } from 'net';
import * as os from 'os';
import * as path from 'path';

interface EventProxyServerOptions {
/** Port to start the event proxy server at. */
port: number;
/** The name for the proxy server used for referencing it with listener functions */
proxyServerName: string;
}

interface SentryRequestCallbackData {
envelope: Envelope;
rawProxyRequestBody: string;
rawSentryResponseBody: string;
sentryResponseStatusCode?: number;
}

/**
* Starts an event proxy server that will proxy events to sentry when the `tunnel` option is used. Point the `tunnel`
* option to this server (like this `tunnel: http://localhost:${port option}/`).
*/
export async function startEventProxyServer(options: EventProxyServerOptions): Promise<void> {
const eventCallbackListeners: Set<(data: string) => void> = new Set();

const proxyServer = http.createServer((proxyRequest, proxyResponse) => {
const proxyRequestChunks: Uint8Array[] = [];

proxyRequest.addListener('data', (chunk: Buffer) => {
proxyRequestChunks.push(chunk);
});

proxyRequest.addListener('error', err => {
throw err;
});

proxyRequest.addListener('end', () => {
const proxyRequestBody = Buffer.concat(proxyRequestChunks).toString();
const envelopeHeader: { dsn?: string } = JSON.parse(proxyRequestBody.split('\n')[0]);

if (!envelopeHeader.dsn) {
throw new Error('[event-proxy-server] No dsn on envelope header. Please set tunnel option.');
}

const { origin, pathname, host } = new URL(envelopeHeader.dsn);

const projectId = pathname.substring(1);
const sentryIngestUrl = `${origin}/api/${projectId}/envelope/`;

proxyRequest.headers.host = host;

const sentryResponseChunks: Uint8Array[] = [];

const sentryRequest = https.request(
sentryIngestUrl,
{ headers: proxyRequest.headers, method: proxyRequest.method },
sentryResponse => {
sentryResponse.addListener('data', (chunk: Buffer) => {
proxyResponse.write(chunk, 'binary');
sentryResponseChunks.push(chunk);
});

sentryResponse.addListener('end', () => {
eventCallbackListeners.forEach(listener => {
const rawProxyRequestBody = Buffer.concat(proxyRequestChunks).toString();
const rawSentryResponseBody = Buffer.concat(sentryResponseChunks).toString();

const data: SentryRequestCallbackData = {
envelope: parseEnvelope(rawProxyRequestBody, new TextEncoder(), new TextDecoder()),
rawProxyRequestBody,
rawSentryResponseBody,
sentryResponseStatusCode: sentryResponse.statusCode,
};

listener(Buffer.from(JSON.stringify(data)).toString('base64'));
});
proxyResponse.end();
});

sentryResponse.addListener('error', err => {
throw err;
});

proxyResponse.writeHead(sentryResponse.statusCode || 500, sentryResponse.headers);
},
);
Comment on lines +63 to +95

Check failure

Code scanning / CodeQL

Server-side request forgery

The [URL](1) of this request depends on a [user-provided value](2).

sentryRequest.write(Buffer.concat(proxyRequestChunks), 'binary');
sentryRequest.end();
});
});

const proxyServerStartupPromise = new Promise<void>(resolve => {
proxyServer.listen(options.port, () => {
resolve();
});
});

const eventCallbackServer = http.createServer((eventCallbackRequest, eventCallbackResponse) => {
eventCallbackResponse.statusCode = 200;
eventCallbackResponse.setHeader('connection', 'keep-alive');

const callbackListener = (data: string): void => {
eventCallbackResponse.write(data.concat('\n'), 'utf8');
};

eventCallbackListeners.add(callbackListener);

eventCallbackRequest.on('close', () => {
eventCallbackListeners.delete(callbackListener);
});

eventCallbackRequest.on('error', () => {
eventCallbackListeners.delete(callbackListener);
});
});

const eventCallbackServerStartupPromise = new Promise<void>(resolve => {
const listener = eventCallbackServer.listen(0, () => {
const port = String((listener.address() as AddressInfo).port);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const port = String((listener.address() as AddressInfo).port);
const port = String(eventCallbackServer.address().port);

l: We can then forgo the listener return as well.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 f2bc116

const tmpFileWithPort = path.join(os.tmpdir(), `event-proxy-server-${options.proxyServerName}`);
fs.writeFileSync(tmpFileWithPort, port, { encoding: 'utf8' });
resolve();
});
});

await eventCallbackServerStartupPromise;
await proxyServerStartupPromise;
return;
}

export function waitForRequest(
proxyServerName: string,
callback: (eventData: SentryRequestCallbackData) => boolean,
): Promise<SentryRequestCallbackData> {
const tmpFileWithPort = path.join(os.tmpdir(), `event-proxy-server-${proxyServerName}`);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

l: If this is expected to keep in sync with tmpFileWithPort generated from eventCallbackServerStartupPromise, I would like it to be a helper func. This way there's no way that it'll ever differ unless done intentionally.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 9090f79

const eventCallbackServerPort = fs.readFileSync(tmpFileWithPort, 'utf8');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

l: Can this be async?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 9090f79


return new Promise<SentryRequestCallbackData>((resolve, reject) => {
const request = http.request(`http://localhost:${eventCallbackServerPort}/`, {}, response => {
let eventContents = '';

response.on('error', err => {
reject(err);
});

response.on('data', (chunk: Buffer) => {
const chunkString = chunk.toString('utf8');
chunkString.split('').forEach(char => {
if (char === '\n') {
const eventCallbackData: SentryRequestCallbackData = JSON.parse(
Buffer.from(eventContents, 'base64').toString('utf8'),
);
if (callback(eventCallbackData)) {
response.destroy();
resolve(eventCallbackData);
}
eventContents = '';
} else {
eventContents = eventContents.concat(char);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

l: why concat over push? push is faster, and we already break mutability by using let eventContents

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sadly string doesn't have a push method.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

omg im a dummy

}
});
});
});

request.end();
});
}

export function waitForEnvelopeItem(
proxyServerName: string,
callback: (envelopeItem: EnvelopeItem) => boolean,
): Promise<EnvelopeItem> {
return new Promise((resolve, reject) => {
waitForRequest(proxyServerName, eventData => {
const envelopeItems = eventData.envelope[1];
for (const envelopeItem of envelopeItems) {
if (callback(envelopeItem)) {
resolve(envelopeItem);
return true;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

m: Why are we returning from the promise? Ditto for the other waitForX methods.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're not returning from the promise, we're returning from the callback of waitForRequest. This is to stop listening for events. Do you know a better way to do this?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yup you're right, the nesting just confused me.

I think it's fine, just takes a bit to get - this is the best way to share state this functional way.

The alternative would be to have this all be in a class mutating some internal state, but that is prob less clean.

}
}
return false;
}).catch(reject);
});
}

export function waitForError(proxyServerName: string, callback: (transactionEvent: Event) => boolean): Promise<Event> {
return new Promise((resolve, reject) => {
waitForEnvelopeItem(proxyServerName, envelopeItem => {
const [envelopeItemHeader, envelopeItemBody] = envelopeItem;
if (envelopeItemHeader.type === 'event' && callback(envelopeItemBody as Event)) {
resolve(envelopeItemBody as Event);
return true;
}
return false;
}).catch(reject);
});
}

export function waitForTransaction(
proxyServerName: string,
callback: (transactionEvent: Event) => boolean,
): Promise<Event> {
return new Promise((resolve, reject) => {
waitForEnvelopeItem(proxyServerName, envelopeItem => {
const [envelopeItemHeader, envelopeItemBody] = envelopeItem;
if (envelopeItemHeader.type === 'transaction' && callback(envelopeItemBody as Event)) {
resolve(envelopeItemBody as Event);
return true;
}
return false;
}).catch(reject);
});
}