mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
ef4180c8da
Summary: This includes two fixes: one to ensure that any exception from websocket upgrade handlers are handled (by destroying the socket). A test case is added for this. The other is to ensure verifyClient returns false instead of failing; this should lead to a better error to the client (Forbidden, rather than just socket close). This is only tested manually with a curl request. Test Plan: Added a test case for the more sensitive half of the fix. Reviewers: georgegevoian Reviewed By: georgegevoian Subscribers: georgegevoian Differential Revision: https://phab.getgrist.com/D4323
195 lines
7.0 KiB
TypeScript
195 lines
7.0 KiB
TypeScript
import { assert } from 'chai';
|
|
import * as http from 'http';
|
|
import { GristClientSocket } from 'app/client/components/GristClientSocket';
|
|
import { GristSocketServer, GristSocketServerOptions } from 'app/server/lib/GristSocketServer';
|
|
import { fromCallback, listenPromise } from 'app/server/lib/serverUtils';
|
|
import { AddressInfo } from 'net';
|
|
import httpProxy from 'http-proxy';
|
|
|
|
describe(`GristSockets`, function () {
|
|
|
|
for (const webSocketsSupported of [true, false]) {
|
|
describe(`when the networks ${webSocketsSupported ? "supports" : "does not support"} WebSockets`, function () {
|
|
|
|
let server: http.Server | null;
|
|
let serverPort: number;
|
|
let socketServer: GristSocketServer | null;
|
|
let proxy: httpProxy | null;
|
|
let proxyServer: http.Server | null;
|
|
let proxyPort: number;
|
|
let wsAddress: string;
|
|
|
|
beforeEach(async function () {
|
|
await startSocketServer();
|
|
await startProxyServer();
|
|
});
|
|
|
|
afterEach(async function () {
|
|
await stopProxyServer();
|
|
await stopSocketServer();
|
|
});
|
|
|
|
async function startSocketServer(options?: GristSocketServerOptions) {
|
|
server = http.createServer((req, res) => res.writeHead(404).end());
|
|
socketServer = new GristSocketServer(server, options);
|
|
await listenPromise(server.listen(0, 'localhost'));
|
|
serverPort = (server.address() as AddressInfo).port;
|
|
}
|
|
|
|
async function stopSocketServer() {
|
|
await fromCallback(cb => socketServer?.close(cb));
|
|
await fromCallback(cb => { server?.close(); server?.closeAllConnections(); server?.on("close", cb); });
|
|
socketServer = server = null;
|
|
}
|
|
|
|
// Start an HTTP proxy that supports WebSockets or not
|
|
async function startProxyServer() {
|
|
proxy = httpProxy.createProxy({
|
|
target: `http://localhost:${serverPort}`,
|
|
ws: webSocketsSupported,
|
|
timeout: 1000,
|
|
});
|
|
proxy.on('error', () => { });
|
|
proxyServer = http.createServer();
|
|
|
|
if (webSocketsSupported) {
|
|
// prevent non-WebSocket requests
|
|
proxyServer.on('request', (req, res) => res.writeHead(404).end());
|
|
// proxy WebSocket requests
|
|
proxyServer.on('upgrade', (req, socket, head) => proxy!.ws(req, socket, head));
|
|
} else {
|
|
// proxy non-WebSocket requests
|
|
proxyServer.on('request', (req, res) => proxy!.web(req, res));
|
|
// don't leave WebSocket connection attempts hanging
|
|
proxyServer.on('upgrade', (req, socket, head) => socket.destroy());
|
|
}
|
|
|
|
await listenPromise(proxyServer.listen(0, 'localhost'));
|
|
proxyPort = (proxyServer.address() as AddressInfo).port;
|
|
wsAddress = `ws://localhost:${proxyPort}`;
|
|
}
|
|
|
|
async function stopProxyServer() {
|
|
if (proxy) {
|
|
proxy.close();
|
|
proxy = null;
|
|
}
|
|
if (proxyServer) {
|
|
const server = proxyServer;
|
|
await fromCallback(cb => { server.close(cb); server.closeAllConnections(); });
|
|
}
|
|
proxyServer = null;
|
|
}
|
|
|
|
function getMessages(ws: GristClientSocket, count: number): Promise<string[]> {
|
|
return new Promise((resolve, reject) => {
|
|
const messages: string[] = [];
|
|
ws.onerror = (err) => {
|
|
ws.onerror = ws.onmessage = null;
|
|
reject(err);
|
|
};
|
|
ws.onmessage = (data: string) => {
|
|
messages.push(data);
|
|
if (messages.length >= count) {
|
|
ws.onerror = ws.onmessage = null;
|
|
resolve(messages);
|
|
}
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Returns a promise for the connected websocket.
|
|
*/
|
|
function connectClient(url: string): Promise<GristClientSocket> {
|
|
const socket = new GristClientSocket(url);
|
|
return new Promise<GristClientSocket>((resolve, reject) => {
|
|
socket.onopen = () => {
|
|
socket.onerror = null;
|
|
resolve(socket);
|
|
};
|
|
socket.onerror = (err) => {
|
|
socket.onopen = null;
|
|
reject(err);
|
|
};
|
|
});
|
|
}
|
|
|
|
it("should expose initial request", async function () {
|
|
const connectionPromise = new Promise<http.IncomingMessage>((resolve) => {
|
|
socketServer!.onconnection = (socket, req) => {
|
|
resolve(req);
|
|
};
|
|
});
|
|
const clientWs = new GristClientSocket(wsAddress + "/path?query=value", {
|
|
headers: { "cookie": "session=1234" }
|
|
});
|
|
const req = await connectionPromise;
|
|
clientWs.close();
|
|
|
|
// Engine.IO may append extra query parameters, so we check only the start of the URL
|
|
assert.match(req.url!, /^\/path\?query=value/);
|
|
|
|
assert.equal(req.headers.cookie, "session=1234");
|
|
});
|
|
|
|
it("should receive and send messages", async function () {
|
|
socketServer!.onconnection = (socket, req) => {
|
|
socket.onmessage = (data) => {
|
|
socket.send("hello, " + data);
|
|
};
|
|
};
|
|
const clientWs = await connectClient(wsAddress);
|
|
clientWs.send("world");
|
|
assert.deepEqual(await getMessages(clientWs, 1), ["hello, world"]);
|
|
clientWs.close();
|
|
});
|
|
|
|
it("should invoke send callbacks", async function () {
|
|
const connectionPromise = new Promise<void>((resolve) => {
|
|
socketServer!.onconnection = (socket, req) => {
|
|
socket.send("hello", () => resolve());
|
|
};
|
|
});
|
|
const clientWs = await connectClient(wsAddress);
|
|
await connectionPromise;
|
|
clientWs.close();
|
|
});
|
|
|
|
it("should emit close event for client", async function () {
|
|
const clientWs = await connectClient(wsAddress);
|
|
const closePromise = new Promise<void>(resolve => {
|
|
clientWs.onclose = resolve;
|
|
});
|
|
clientWs.close();
|
|
await closePromise;
|
|
});
|
|
|
|
it("should fail gracefully if verifyClient throws exception", async function () {
|
|
|
|
// Restart servers with a failing verifyClient method.
|
|
await stopProxyServer();
|
|
await stopSocketServer();
|
|
await startSocketServer({verifyClient: () => { throw new Error("Test error from verifyClient"); }});
|
|
await startProxyServer();
|
|
|
|
// Check whether we are getting an unhandledRejection.
|
|
let rejection: unknown = null;
|
|
const onUnhandledRejection = (err: unknown) => { rejection = err; };
|
|
process.on('unhandledRejection', onUnhandledRejection);
|
|
|
|
try {
|
|
// The "poll error" comes from the fallback to polling.
|
|
await assert.isRejected(connectClient(wsAddress), /poll error/);
|
|
} finally {
|
|
// Typings for process.removeListener are broken, possibly by electron's presence
|
|
// (https://github.com/electron/electron/issues/9626).
|
|
process.removeListener('unhandledRejection' as any, onUnhandledRejection);
|
|
}
|
|
// The important thing is that we don't get unhandledRejection.
|
|
assert.equal(rejection, null);
|
|
});
|
|
});
|
|
}
|
|
});
|