mirror of
https://github.com/apache/superset.git
synced 2026-07-10 08:45:41 +00:00
1076 lines
34 KiB
TypeScript
1076 lines
34 KiB
TypeScript
/**
|
|
* Licensed to the Apache Software Foundation (ASF) under one
|
|
* or more contributor license agreements. See the NOTICE file
|
|
* distributed with this work for additional information
|
|
* regarding copyright ownership. The ASF licenses this file
|
|
* to you under the Apache License, Version 2.0 (the
|
|
* "License"); you may not use this file except in compliance
|
|
* with the License. You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing,
|
|
* software distributed under the License is distributed on an
|
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
* KIND, either express or implied. See the License for the
|
|
* specific language governing permissions and limitations
|
|
* under the License.
|
|
*/
|
|
const jwt = require('jsonwebtoken');
|
|
const config = require('../config.test.json');
|
|
|
|
import {
|
|
describe,
|
|
expect,
|
|
test,
|
|
beforeEach,
|
|
afterEach,
|
|
jest,
|
|
} from '@jest/globals';
|
|
import * as http from 'http';
|
|
import * as net from 'net';
|
|
import { WebSocket } from 'ws';
|
|
|
|
interface MockedRedisXrange {
|
|
(): Promise<server.StreamResult[]>;
|
|
}
|
|
|
|
// NOTE: these mock variables needs to start with "mock" due to
|
|
// calls to `jest.mock` being hoisted to the top of the file.
|
|
// https://jestjs.io/docs/es6-class-mocks#calling-jestmock-with-the-module-factory-parameter
|
|
const mockRedisXrange = jest.fn() as jest.MockedFunction<MockedRedisXrange>;
|
|
|
|
jest.mock('ws');
|
|
jest.mock('ioredis', () => {
|
|
return jest.fn().mockImplementation(() => {
|
|
return { xrange: mockRedisXrange, on: jest.fn() };
|
|
});
|
|
});
|
|
|
|
const wsMock = WebSocket as jest.Mocked<typeof WebSocket>;
|
|
const channelId = 'bc9e040c-7b4a-4817-99b9-292832d97ec7';
|
|
const streamReturnValue: server.StreamResult[] = [
|
|
[
|
|
'1615426152415-0',
|
|
[
|
|
'data',
|
|
`{"channel_id": "${channelId}", "job_id": "c9b99965-8f1e-4ce5-aa43-d6fc94d6a510", "user_id": "1", "status": "done", "errors": [], "result_url": "/superset/explore_json/data/ejr-37281682b1282cdb8f25e0de0339b386"}`,
|
|
],
|
|
],
|
|
[
|
|
'1615426152516-0',
|
|
[
|
|
'data',
|
|
`{"channel_id": "${channelId}", "job_id": "f1e5bb1f-f2f1-4f21-9b2f-c9b91dcc9b59", "user_id": "1", "status": "done", "errors": [], "result_url": "/api/v1/chart/data/qc-64e8452dc9907dd77746cb75a19202de"}`,
|
|
],
|
|
],
|
|
];
|
|
|
|
import * as server from '../src/index';
|
|
import { statsd } from '../src/index';
|
|
|
|
describe('server', () => {
|
|
let statsdIncrementMock: jest.SpiedFunction<typeof statsd.increment>;
|
|
|
|
beforeEach(() => {
|
|
mockRedisXrange.mockClear();
|
|
server.resetState();
|
|
statsdIncrementMock = jest.spyOn(statsd, 'increment').mockReturnValue();
|
|
});
|
|
|
|
afterEach(() => {
|
|
statsdIncrementMock.mockRestore();
|
|
});
|
|
|
|
describe('HTTP requests', () => {
|
|
test('services health checks', () => {
|
|
const endMock = jest.fn();
|
|
const writeHeadMock = jest.fn();
|
|
|
|
const request = {
|
|
url: '/health',
|
|
method: 'GET',
|
|
headers: {
|
|
host: 'example.com',
|
|
},
|
|
};
|
|
|
|
const response = {
|
|
writeHead: writeHeadMock,
|
|
end: endMock,
|
|
};
|
|
|
|
server.httpRequest(
|
|
request as unknown as http.IncomingMessage,
|
|
response as unknown as http.ServerResponse<http.IncomingMessage>,
|
|
);
|
|
|
|
expect(writeHeadMock).toHaveBeenCalledTimes(1);
|
|
expect(writeHeadMock).toHaveBeenLastCalledWith(200);
|
|
|
|
expect(endMock).toHaveBeenCalledTimes(1);
|
|
expect(endMock).toHaveBeenLastCalledWith('OK');
|
|
});
|
|
|
|
test('responds with a 404 when not found', () => {
|
|
const endMock = jest.fn();
|
|
const writeHeadMock = jest.fn();
|
|
|
|
const request = {
|
|
url: '/unsupported',
|
|
method: 'GET',
|
|
headers: {
|
|
host: 'example.com',
|
|
},
|
|
};
|
|
|
|
const response = {
|
|
writeHead: writeHeadMock,
|
|
end: endMock,
|
|
};
|
|
|
|
server.httpRequest(
|
|
request as unknown as http.IncomingMessage,
|
|
response as unknown as http.ServerResponse<http.IncomingMessage>,
|
|
);
|
|
|
|
expect(writeHeadMock).toHaveBeenCalledTimes(1);
|
|
expect(writeHeadMock).toHaveBeenLastCalledWith(404);
|
|
|
|
expect(endMock).toHaveBeenCalledTimes(1);
|
|
expect(endMock).toHaveBeenLastCalledWith('Not Found');
|
|
});
|
|
});
|
|
|
|
describe('incrementId', () => {
|
|
test('it increments a valid Redis stream ID', () => {
|
|
expect(server.incrementId('1607477697866-0')).toEqual('1607477697866-1');
|
|
});
|
|
|
|
test('it handles an invalid Redis stream ID', () => {
|
|
expect(server.incrementId('foo')).toEqual('foo');
|
|
});
|
|
});
|
|
|
|
describe('getLastId', () => {
|
|
const requestWithUrl = (url: string): http.IncomingMessage => {
|
|
const request = new http.IncomingMessage(new net.Socket());
|
|
request.url = url;
|
|
return request;
|
|
};
|
|
|
|
test('returns null when last_id is absent', () => {
|
|
expect(server.getLastId(requestWithUrl('http://localhost'))).toBeNull();
|
|
});
|
|
|
|
test('returns a well-formed Redis stream ID', () => {
|
|
expect(
|
|
server.getLastId(
|
|
requestWithUrl('http://localhost?last_id=1607477697866-0'),
|
|
),
|
|
).toEqual('1607477697866-0');
|
|
});
|
|
|
|
test.each([
|
|
'abc-xyz',
|
|
'1607477697866',
|
|
'1607477697866-',
|
|
'-0',
|
|
'1607477697866-0; DROP TABLE',
|
|
'1607477697866-0-0',
|
|
])('returns null for malformed last_id %p', value => {
|
|
expect(
|
|
server.getLastId(
|
|
requestWithUrl(
|
|
`http://localhost?last_id=${encodeURIComponent(value)}`,
|
|
),
|
|
),
|
|
).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('redisUrlFromConfig', () => {
|
|
test('it builds a valid Redis URL from defaults', () => {
|
|
expect(
|
|
server.buildRedisOpts({
|
|
port: 6379,
|
|
host: '127.0.0.1',
|
|
username: 'test-user',
|
|
password: '',
|
|
db: 0,
|
|
ssl: false,
|
|
validateHostname: false,
|
|
}),
|
|
).toEqual({ db: 0, host: '127.0.0.1', port: 6379 });
|
|
});
|
|
test('it builds a valid Redis URL with a password', () => {
|
|
expect(
|
|
server.buildRedisOpts({
|
|
port: 6380,
|
|
host: 'redis.local',
|
|
username: 'cool-user',
|
|
password: 'foo',
|
|
db: 1,
|
|
ssl: false,
|
|
validateHostname: false,
|
|
}),
|
|
).toEqual({
|
|
db: 1,
|
|
host: 'redis.local',
|
|
password: 'foo',
|
|
port: 6380,
|
|
username: 'cool-user',
|
|
});
|
|
});
|
|
test('it builds a valid Redis URL with SSL', () => {
|
|
expect(
|
|
server.buildRedisOpts({
|
|
port: 6379,
|
|
host: '127.0.0.1',
|
|
password: '',
|
|
username: 'cool-user',
|
|
db: 0,
|
|
ssl: true,
|
|
validateHostname: false,
|
|
}),
|
|
).toEqual({
|
|
db: 0,
|
|
host: '127.0.0.1',
|
|
port: 6379,
|
|
tls: { checkServerIdentity: expect.anything() },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('processStreamResults', () => {
|
|
test('sends data to channel', async () => {
|
|
const ws = new wsMock('localhost');
|
|
const sendMock = jest.spyOn(ws, 'send');
|
|
const socketInstance = { ws: ws, channel: channelId, pongTs: Date.now() };
|
|
|
|
expect(statsdIncrementMock).toHaveBeenCalledTimes(0);
|
|
server.trackClient(channelId, socketInstance);
|
|
expect(statsdIncrementMock).toHaveBeenCalledTimes(1);
|
|
expect(statsdIncrementMock).toHaveBeenNthCalledWith(
|
|
1,
|
|
'ws_connected_client',
|
|
);
|
|
|
|
server.processStreamResults(streamReturnValue);
|
|
expect(statsdIncrementMock).toHaveBeenCalledTimes(1);
|
|
|
|
const message1 = `{"id":"1615426152415-0","channel_id":"${channelId}","job_id":"c9b99965-8f1e-4ce5-aa43-d6fc94d6a510","user_id":"1","status":"done","errors":[],"result_url":"/superset/explore_json/data/ejr-37281682b1282cdb8f25e0de0339b386"}`;
|
|
const message2 = `{"id":"1615426152516-0","channel_id":"${channelId}","job_id":"f1e5bb1f-f2f1-4f21-9b2f-c9b91dcc9b59","user_id":"1","status":"done","errors":[],"result_url":"/api/v1/chart/data/qc-64e8452dc9907dd77746cb75a19202de"}`;
|
|
expect(sendMock).toHaveBeenCalledWith(message1);
|
|
expect(sendMock).toHaveBeenCalledWith(message2);
|
|
});
|
|
|
|
test('channel not present', async () => {
|
|
const ws = new wsMock('localhost');
|
|
const sendMock = jest.spyOn(ws, 'send');
|
|
|
|
expect(statsdIncrementMock).toHaveBeenCalledTimes(0);
|
|
server.processStreamResults(streamReturnValue);
|
|
expect(statsdIncrementMock).toHaveBeenCalledTimes(0);
|
|
|
|
expect(sendMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('error sending data to client', async () => {
|
|
const ws = new wsMock('localhost');
|
|
const sendMock = jest.spyOn(ws, 'send').mockImplementation(() => {
|
|
throw new Error();
|
|
});
|
|
const cleanChannelMock = jest.spyOn(server, 'cleanChannel');
|
|
const socketInstance = { ws: ws, channel: channelId, pongTs: Date.now() };
|
|
|
|
expect(statsdIncrementMock).toHaveBeenCalledTimes(0);
|
|
server.trackClient(channelId, socketInstance);
|
|
expect(statsdIncrementMock).toHaveBeenCalledTimes(1);
|
|
expect(statsdIncrementMock).toHaveBeenNthCalledWith(
|
|
1,
|
|
'ws_connected_client',
|
|
);
|
|
|
|
server.processStreamResults(streamReturnValue);
|
|
expect(statsdIncrementMock).toHaveBeenCalledTimes(2);
|
|
expect(statsdIncrementMock).toHaveBeenNthCalledWith(
|
|
2,
|
|
'ws_client_send_error',
|
|
);
|
|
|
|
expect(sendMock).toHaveBeenCalled();
|
|
expect(cleanChannelMock).toHaveBeenCalledWith(channelId);
|
|
|
|
cleanChannelMock.mockRestore();
|
|
});
|
|
|
|
const makeItem = (i: number): server.StreamResult =>
|
|
[
|
|
`161542615${i}-0`,
|
|
[
|
|
'data',
|
|
JSON.stringify({
|
|
channel_id: channelId,
|
|
job_id: `job-${i}`,
|
|
status: 'done',
|
|
}),
|
|
],
|
|
] as server.StreamResult;
|
|
|
|
afterEach(() => {
|
|
server.opts.eventYieldBatchSize = 100;
|
|
});
|
|
|
|
test('yields to the event loop for large batches', async () => {
|
|
server.opts.eventYieldBatchSize = 2;
|
|
const ws = new wsMock('localhost');
|
|
server.trackClient(channelId, {
|
|
ws,
|
|
channel: channelId,
|
|
pongTs: Date.now(),
|
|
});
|
|
const sendMock = jest.spyOn(ws, 'send');
|
|
const setImmediateSpy = jest.spyOn(global, 'setImmediate');
|
|
|
|
const results = [0, 1, 2, 3, 4].map(makeItem);
|
|
await server.processStreamResults(results);
|
|
|
|
// every event is still delivered
|
|
expect(sendMock).toHaveBeenCalledTimes(5);
|
|
// and the loop yielded at least at indexes 2 and 4
|
|
expect(setImmediateSpy.mock.calls.length).toBeGreaterThanOrEqual(2);
|
|
setImmediateSpy.mockRestore();
|
|
});
|
|
|
|
test('processes the whole batch when yielding is disabled', async () => {
|
|
server.opts.eventYieldBatchSize = 0;
|
|
const ws = new wsMock('localhost');
|
|
server.trackClient(channelId, {
|
|
ws,
|
|
channel: channelId,
|
|
pongTs: Date.now(),
|
|
});
|
|
const sendMock = jest.spyOn(ws, 'send');
|
|
|
|
const results = [0, 1, 2, 3, 4].map(makeItem);
|
|
await server.processStreamResults(results);
|
|
|
|
expect(sendMock).toHaveBeenCalledTimes(5);
|
|
});
|
|
});
|
|
|
|
describe('backpressure', () => {
|
|
const fakeEvent = {
|
|
id: '1615426152415-0',
|
|
channel_id: channelId,
|
|
job_id: 'c9b99965-8f1e-4ce5-aa43-d6fc94d6a510',
|
|
status: 'done',
|
|
};
|
|
|
|
afterEach(() => {
|
|
server.opts.maxSocketBufferBytes = 0;
|
|
// Restore any spies (e.g. on server.cleanChannel) so they don't leak
|
|
// across tests and cause order-dependent failures.
|
|
jest.restoreAllMocks();
|
|
});
|
|
|
|
test('does not terminate when cap disabled (0)', () => {
|
|
server.opts.maxSocketBufferBytes = 0;
|
|
const ws = new wsMock('localhost');
|
|
// simulate a large outbound buffer
|
|
(ws as unknown as { bufferedAmount: number }).bufferedAmount = 10_000_000;
|
|
const terminateMock = jest.spyOn(ws, 'terminate');
|
|
const sendMock = jest.spyOn(ws, 'send');
|
|
server.trackClient(channelId, {
|
|
ws,
|
|
channel: channelId,
|
|
pongTs: Date.now(),
|
|
});
|
|
|
|
server.sendToChannel(channelId, fakeEvent);
|
|
|
|
expect(terminateMock).not.toHaveBeenCalled();
|
|
expect(sendMock).toHaveBeenCalled();
|
|
});
|
|
|
|
test('terminates a slow client whose buffer exceeds the cap', () => {
|
|
server.opts.maxSocketBufferBytes = 1024;
|
|
const ws = new wsMock('localhost');
|
|
(ws as unknown as { bufferedAmount: number }).bufferedAmount = 2048;
|
|
const terminateMock = jest.spyOn(ws, 'terminate');
|
|
const sendMock = jest.spyOn(ws, 'send');
|
|
const cleanChannelMock = jest.spyOn(server, 'cleanChannel');
|
|
server.trackClient(channelId, {
|
|
ws,
|
|
channel: channelId,
|
|
pongTs: Date.now(),
|
|
});
|
|
|
|
server.sendToChannel(channelId, fakeEvent);
|
|
|
|
expect(terminateMock).toHaveBeenCalled();
|
|
expect(sendMock).not.toHaveBeenCalled();
|
|
expect(statsdIncrementMock).toHaveBeenCalledWith(
|
|
'ws_client_backpressure_disconnect',
|
|
);
|
|
expect(cleanChannelMock).toHaveBeenCalledWith(channelId);
|
|
});
|
|
|
|
test('keeps sending to a client within the cap', () => {
|
|
server.opts.maxSocketBufferBytes = 1024;
|
|
const ws = new wsMock('localhost');
|
|
(ws as unknown as { bufferedAmount: number }).bufferedAmount = 16;
|
|
const terminateMock = jest.spyOn(ws, 'terminate');
|
|
const sendMock = jest.spyOn(ws, 'send');
|
|
server.trackClient(channelId, {
|
|
ws,
|
|
channel: channelId,
|
|
pongTs: Date.now(),
|
|
});
|
|
|
|
server.sendToChannel(channelId, fakeEvent);
|
|
|
|
expect(terminateMock).not.toHaveBeenCalled();
|
|
expect(sendMock).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('fetchRangeFromStream', () => {
|
|
beforeEach(() => {
|
|
mockRedisXrange.mockClear();
|
|
});
|
|
|
|
test('success with results', async () => {
|
|
mockRedisXrange.mockResolvedValueOnce(streamReturnValue);
|
|
const cb = jest.fn() as jest.MockedFunction<
|
|
(results: server.StreamResult[]) => void | Promise<void>
|
|
>;
|
|
await server.fetchRangeFromStream({
|
|
sessionId: '123',
|
|
startId: '-',
|
|
endId: '+',
|
|
listener: cb,
|
|
});
|
|
|
|
expect(mockRedisXrange).toHaveBeenCalledWith(
|
|
'test-async-events-123',
|
|
'-',
|
|
'+',
|
|
);
|
|
expect(cb).toHaveBeenCalledWith(streamReturnValue);
|
|
});
|
|
|
|
test('success no results', async () => {
|
|
const cb = jest.fn() as jest.MockedFunction<
|
|
(results: server.StreamResult[]) => void | Promise<void>
|
|
>;
|
|
await server.fetchRangeFromStream({
|
|
sessionId: '123',
|
|
startId: '-',
|
|
endId: '+',
|
|
listener: cb,
|
|
});
|
|
|
|
expect(mockRedisXrange).toHaveBeenCalledWith(
|
|
'test-async-events-123',
|
|
'-',
|
|
'+',
|
|
);
|
|
expect(cb).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('error', async () => {
|
|
const cb = jest.fn() as jest.MockedFunction<
|
|
(results: server.StreamResult[]) => void | Promise<void>
|
|
>;
|
|
mockRedisXrange.mockRejectedValueOnce(new Error());
|
|
await server.fetchRangeFromStream({
|
|
sessionId: '123',
|
|
startId: '-',
|
|
endId: '+',
|
|
listener: cb,
|
|
});
|
|
|
|
expect(mockRedisXrange).toHaveBeenCalledWith(
|
|
'test-async-events-123',
|
|
'-',
|
|
'+',
|
|
);
|
|
expect(cb).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('wsConnection', () => {
|
|
let ws: WebSocket;
|
|
let wsEventMock: jest.SpiedFunction<typeof ws.on>;
|
|
let trackClientSpy: jest.SpiedFunction<typeof server.trackClient>;
|
|
let fetchRangeFromStreamSpy: jest.SpiedFunction<
|
|
typeof server.fetchRangeFromStream
|
|
>;
|
|
let dateNowSpy: jest.SpiedFunction<typeof Date.now>;
|
|
let socketInstanceExpected: server.SocketInstance;
|
|
|
|
const getRequest = (token: string, url: string): http.IncomingMessage => {
|
|
const request = new http.IncomingMessage(new net.Socket());
|
|
request.method = 'GET';
|
|
request.headers = { cookie: `${config.jwtCookieName}=${token}` };
|
|
request.url = url;
|
|
return request;
|
|
};
|
|
|
|
beforeEach(() => {
|
|
ws = new wsMock('localhost');
|
|
wsEventMock = jest.spyOn(ws, 'on');
|
|
trackClientSpy = jest.spyOn(server, 'trackClient');
|
|
fetchRangeFromStreamSpy = jest.spyOn(server, 'fetchRangeFromStream');
|
|
dateNowSpy = jest
|
|
.spyOn(global.Date, 'now')
|
|
.mockImplementation(() =>
|
|
new Date('2021-03-10T11:01:58.135Z').valueOf(),
|
|
);
|
|
socketInstanceExpected = {
|
|
ws,
|
|
channel: channelId,
|
|
pongTs: 1615374118135,
|
|
};
|
|
});
|
|
|
|
afterEach(() => {
|
|
wsEventMock.mockRestore();
|
|
trackClientSpy.mockRestore();
|
|
fetchRangeFromStreamSpy.mockRestore();
|
|
dateNowSpy.mockRestore();
|
|
});
|
|
|
|
test('invalid JWT', async () => {
|
|
const invalidToken = jwt.sign({ channel: channelId }, 'invalid secret');
|
|
const request = getRequest(invalidToken, 'http://localhost');
|
|
|
|
expect(() => {
|
|
server.wsConnection(ws, request);
|
|
}).toThrow();
|
|
});
|
|
|
|
test('valid JWT, no lastId', async () => {
|
|
const validToken = jwt.sign({ channel: channelId }, config.jwtSecret);
|
|
const request = getRequest(validToken, 'http://localhost');
|
|
|
|
server.wsConnection(ws, request);
|
|
|
|
expect(trackClientSpy).toHaveBeenCalledWith(
|
|
channelId,
|
|
socketInstanceExpected,
|
|
);
|
|
expect(fetchRangeFromStreamSpy).not.toHaveBeenCalled();
|
|
expect(wsEventMock).toHaveBeenCalledWith('pong', expect.any(Function));
|
|
});
|
|
|
|
test('valid JWT, malformed lastId is ignored', async () => {
|
|
const validToken = jwt.sign({ channel: channelId }, config.jwtSecret);
|
|
const request = getRequest(
|
|
validToken,
|
|
'http://localhost?last_id=abc-xyz',
|
|
);
|
|
|
|
server.wsConnection(ws, request);
|
|
|
|
expect(trackClientSpy).toHaveBeenCalledWith(
|
|
channelId,
|
|
socketInstanceExpected,
|
|
);
|
|
// Malformed last_id must not trigger a stream range fetch.
|
|
expect(fetchRangeFromStreamSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('valid JWT, with lastId', async () => {
|
|
const validToken = jwt.sign({ channel: channelId }, config.jwtSecret);
|
|
const lastId = '1615426152415-0';
|
|
const request = getRequest(
|
|
validToken,
|
|
`http://localhost?last_id=${lastId}`,
|
|
);
|
|
|
|
server.wsConnection(ws, request);
|
|
|
|
expect(trackClientSpy).toHaveBeenCalledWith(
|
|
channelId,
|
|
socketInstanceExpected,
|
|
);
|
|
expect(fetchRangeFromStreamSpy).toHaveBeenCalledWith({
|
|
sessionId: channelId,
|
|
startId: '1615426152415-1',
|
|
endId: '+',
|
|
listener: server.processStreamResults,
|
|
});
|
|
expect(wsEventMock).toHaveBeenCalledWith('pong', expect.any(Function));
|
|
});
|
|
|
|
test('valid JWT, with lastId and lastFirehoseId', async () => {
|
|
const validToken = jwt.sign({ channel: channelId }, config.jwtSecret);
|
|
const lastId = '1615426152415-0';
|
|
const lastFirehoseId = '1715426152415-0';
|
|
const request = getRequest(
|
|
validToken,
|
|
`http://localhost?last_id=${lastId}`,
|
|
);
|
|
|
|
server.setLastFirehoseId(lastFirehoseId);
|
|
server.wsConnection(ws, request);
|
|
|
|
expect(trackClientSpy).toHaveBeenCalledWith(
|
|
channelId,
|
|
socketInstanceExpected,
|
|
);
|
|
expect(fetchRangeFromStreamSpy).toHaveBeenCalledWith({
|
|
sessionId: channelId,
|
|
startId: '1615426152415-1',
|
|
endId: lastFirehoseId,
|
|
listener: server.processStreamResults,
|
|
});
|
|
expect(wsEventMock).toHaveBeenCalledWith('pong', expect.any(Function));
|
|
});
|
|
});
|
|
|
|
describe('connection limits', () => {
|
|
const getRequest = (token: string, url: string): http.IncomingMessage => {
|
|
const request = new http.IncomingMessage(new net.Socket());
|
|
request.method = 'GET';
|
|
request.headers = { cookie: `${config.jwtCookieName}=${token}` };
|
|
request.url = url;
|
|
return request;
|
|
};
|
|
|
|
afterEach(() => {
|
|
// restore opt-in limits to their disabled default
|
|
server.opts.maxTotalConnections = 0;
|
|
server.opts.maxConnectionsPerChannel = 0;
|
|
});
|
|
|
|
test('no limit when disabled (0)', () => {
|
|
server.opts.maxTotalConnections = 0;
|
|
server.opts.maxConnectionsPerChannel = 0;
|
|
const socketInstance = {
|
|
ws: new wsMock('localhost'),
|
|
channel: channelId,
|
|
pongTs: Date.now(),
|
|
};
|
|
server.trackClient(channelId, socketInstance);
|
|
expect(server.connectionLimitReason(channelId)).toBeNull();
|
|
});
|
|
|
|
test('total connection limit reached', () => {
|
|
server.opts.maxTotalConnections = 1;
|
|
const ws = new wsMock('localhost');
|
|
setReadyState(ws, WebSocket.OPEN);
|
|
const socketInstance = {
|
|
ws,
|
|
channel: channelId,
|
|
pongTs: Date.now(),
|
|
};
|
|
server.trackClient(channelId, socketInstance);
|
|
expect(server.connectionLimitReason('some-other-channel')).toMatch(
|
|
/total connection limit/,
|
|
);
|
|
});
|
|
|
|
test('per-channel connection limit reached', () => {
|
|
server.opts.maxConnectionsPerChannel = 1;
|
|
const ws = new wsMock('localhost');
|
|
setReadyState(ws, WebSocket.OPEN);
|
|
const socketInstance = {
|
|
ws,
|
|
channel: channelId,
|
|
pongTs: Date.now(),
|
|
};
|
|
server.trackClient(channelId, socketInstance);
|
|
expect(server.connectionLimitReason(channelId)).toMatch(
|
|
/per-channel connection limit/,
|
|
);
|
|
});
|
|
|
|
test('stale closed socket does not count toward total limit', () => {
|
|
server.opts.maxTotalConnections = 1;
|
|
const ws = new wsMock('localhost');
|
|
const socketInstance = {
|
|
ws,
|
|
channel: channelId,
|
|
pongTs: Date.now(),
|
|
};
|
|
server.trackClient(channelId, socketInstance);
|
|
// simulate the socket having closed but not yet been GC'd
|
|
setReadyState(ws, WebSocket.CLOSED);
|
|
expect(server.connectionLimitReason('some-other-channel')).toBeNull();
|
|
});
|
|
|
|
test('stale closed socket does not count toward per-channel limit', () => {
|
|
server.opts.maxConnectionsPerChannel = 1;
|
|
const ws = new wsMock('localhost');
|
|
const socketInstance = {
|
|
ws,
|
|
channel: channelId,
|
|
pongTs: Date.now(),
|
|
};
|
|
server.trackClient(channelId, socketInstance);
|
|
// simulate the socket having closed but not yet been GC'd
|
|
setReadyState(ws, WebSocket.CLOSED);
|
|
expect(server.connectionLimitReason(channelId)).toBeNull();
|
|
});
|
|
|
|
test('isSocketActive reflects the socket readyState', () => {
|
|
const ws = new wsMock('localhost');
|
|
setReadyState(ws, WebSocket.OPEN);
|
|
const socketId = server.trackClient(channelId, {
|
|
ws,
|
|
channel: channelId,
|
|
pongTs: Date.now(),
|
|
});
|
|
expect(server.isSocketActive(socketId)).toBe(true);
|
|
// CONNECTING is also considered active (see SOCKET_ACTIVE_STATES)
|
|
setReadyState(ws, WebSocket.CONNECTING);
|
|
expect(server.isSocketActive(socketId)).toBe(true);
|
|
setReadyState(ws, WebSocket.CLOSED);
|
|
expect(server.isSocketActive(socketId)).toBe(false);
|
|
// unknown socket ids are never active
|
|
expect(server.isSocketActive('does-not-exist')).toBe(false);
|
|
});
|
|
|
|
test('activeSocketCount counts only active sockets', () => {
|
|
const openWs = new wsMock('localhost');
|
|
setReadyState(openWs, WebSocket.OPEN);
|
|
server.trackClient(channelId, {
|
|
ws: openWs,
|
|
channel: channelId,
|
|
pongTs: Date.now(),
|
|
});
|
|
const closedWs = new wsMock('localhost');
|
|
setReadyState(closedWs, WebSocket.CLOSED);
|
|
server.trackClient(channelId, {
|
|
ws: closedWs,
|
|
channel: channelId,
|
|
pongTs: Date.now(),
|
|
});
|
|
expect(server.activeSocketCount()).toBe(1);
|
|
});
|
|
|
|
test('activeChannelSocketCount counts only active sockets on the channel', () => {
|
|
const openWs = new wsMock('localhost');
|
|
setReadyState(openWs, WebSocket.OPEN);
|
|
server.trackClient(channelId, {
|
|
ws: openWs,
|
|
channel: channelId,
|
|
pongTs: Date.now(),
|
|
});
|
|
const closedWs = new wsMock('localhost');
|
|
setReadyState(closedWs, WebSocket.CLOSED);
|
|
server.trackClient(channelId, {
|
|
ws: closedWs,
|
|
channel: channelId,
|
|
pongTs: Date.now(),
|
|
});
|
|
expect(server.activeChannelSocketCount(channelId)).toBe(1);
|
|
// unknown channels report zero active sockets
|
|
expect(server.activeChannelSocketCount('no-such-channel')).toBe(0);
|
|
});
|
|
|
|
test('wsConnection refuses over-limit connection without tracking', () => {
|
|
server.opts.maxConnectionsPerChannel = 1;
|
|
const existingWs = new wsMock('localhost');
|
|
setReadyState(existingWs, WebSocket.OPEN);
|
|
const existing = {
|
|
ws: existingWs,
|
|
channel: channelId,
|
|
pongTs: Date.now(),
|
|
};
|
|
server.trackClient(channelId, existing);
|
|
|
|
const trackClientSpy = jest.spyOn(server, 'trackClient');
|
|
const ws = new wsMock('localhost');
|
|
const validToken = jwt.sign({ channel: channelId }, config.jwtSecret);
|
|
server.wsConnection(ws, getRequest(validToken, 'http://localhost'));
|
|
|
|
expect(ws.close).toHaveBeenCalledWith(
|
|
1013,
|
|
expect.stringMatching(/limit/),
|
|
);
|
|
expect(trackClientSpy).not.toHaveBeenCalled();
|
|
trackClientSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe('httpUpgrade', () => {
|
|
let socket: net.Socket;
|
|
let socketDestroySpy: jest.SpiedFunction<typeof socket.destroy>;
|
|
let wssUpgradeSpy: jest.SpiedFunction<typeof server.wss.handleUpgrade>;
|
|
|
|
const getRequest = (token: string, url: string): http.IncomingMessage => {
|
|
const request = new http.IncomingMessage(new net.Socket());
|
|
request.method = 'GET';
|
|
request.headers = { cookie: `${config.jwtCookieName}=${token}` };
|
|
request.url = url;
|
|
return request;
|
|
};
|
|
|
|
beforeEach(() => {
|
|
socket = new net.Socket();
|
|
socketDestroySpy = jest.spyOn(socket, 'destroy');
|
|
wssUpgradeSpy = jest.spyOn(server.wss, 'handleUpgrade');
|
|
});
|
|
|
|
afterEach(() => {
|
|
wssUpgradeSpy.mockRestore();
|
|
});
|
|
|
|
test('invalid JWT', async () => {
|
|
const invalidToken = jwt.sign({ channel: channelId }, 'invalid secret');
|
|
const request = getRequest(invalidToken, 'http://localhost');
|
|
|
|
server.httpUpgrade(request, socket, Buffer.alloc(5));
|
|
expect(socketDestroySpy).toHaveBeenCalled();
|
|
expect(wssUpgradeSpy).not.toHaveBeenCalled();
|
|
// rejected upgrades are counted for auditability
|
|
expect(statsdIncrementMock).toHaveBeenCalledWith('ws_upgrade_rejected');
|
|
});
|
|
|
|
test('valid JWT, no channel', async () => {
|
|
const validToken = jwt.sign({ foo: 'bar' }, config.jwtSecret);
|
|
const request = getRequest(validToken, 'http://localhost');
|
|
|
|
server.httpUpgrade(request, socket, Buffer.alloc(5));
|
|
|
|
expect(socketDestroySpy).toHaveBeenCalled();
|
|
expect(wssUpgradeSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('valid upgrade', async () => {
|
|
const validToken = jwt.sign({ channel: channelId }, config.jwtSecret);
|
|
const request = getRequest(validToken, 'http://localhost');
|
|
|
|
server.httpUpgrade(request, socket, Buffer.alloc(5));
|
|
|
|
expect(socketDestroySpy).not.toHaveBeenCalled();
|
|
expect(wssUpgradeSpy).toHaveBeenCalled();
|
|
});
|
|
|
|
describe('origin validation', () => {
|
|
afterEach(() => {
|
|
server.opts.allowedOrigins = [];
|
|
});
|
|
|
|
const getRequestWithOrigin = (
|
|
token: string,
|
|
origin?: string,
|
|
): http.IncomingMessage => {
|
|
const request = new http.IncomingMessage(new net.Socket());
|
|
request.method = 'GET';
|
|
request.headers = { cookie: `${config.jwtCookieName}=${token}` };
|
|
if (origin) request.headers.origin = origin;
|
|
request.url = 'http://localhost';
|
|
return request;
|
|
};
|
|
|
|
test('rejects upgrade from a disallowed origin', () => {
|
|
server.opts.allowedOrigins = ['https://superset.example.com'];
|
|
const validToken = jwt.sign({ channel: channelId }, config.jwtSecret);
|
|
const request = getRequestWithOrigin(
|
|
validToken,
|
|
'https://evil.example',
|
|
);
|
|
|
|
server.httpUpgrade(request, socket, Buffer.alloc(5));
|
|
|
|
expect(socketDestroySpy).toHaveBeenCalled();
|
|
expect(wssUpgradeSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('rejects upgrade with no origin when an allowlist is set', () => {
|
|
server.opts.allowedOrigins = ['https://superset.example.com'];
|
|
const validToken = jwt.sign({ channel: channelId }, config.jwtSecret);
|
|
const request = getRequestWithOrigin(validToken);
|
|
|
|
server.httpUpgrade(request, socket, Buffer.alloc(5));
|
|
|
|
expect(socketDestroySpy).toHaveBeenCalled();
|
|
expect(wssUpgradeSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('allows upgrade from an allowed origin', () => {
|
|
server.opts.allowedOrigins = ['https://superset.example.com'];
|
|
const validToken = jwt.sign({ channel: channelId }, config.jwtSecret);
|
|
const request = getRequestWithOrigin(
|
|
validToken,
|
|
'https://superset.example.com',
|
|
);
|
|
|
|
server.httpUpgrade(request, socket, Buffer.alloc(5));
|
|
|
|
expect(socketDestroySpy).not.toHaveBeenCalled();
|
|
expect(wssUpgradeSpy).toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('isOriginAllowed', () => {
|
|
const makeRequest = (origin?: string): http.IncomingMessage => {
|
|
const request = new http.IncomingMessage(new net.Socket());
|
|
if (origin) request.headers.origin = origin;
|
|
return request;
|
|
};
|
|
|
|
afterEach(() => {
|
|
server.opts.allowedOrigins = [];
|
|
});
|
|
|
|
test('allows any origin when allowlist is empty', () => {
|
|
server.opts.allowedOrigins = [];
|
|
expect(server.isOriginAllowed(makeRequest('https://anything'))).toBe(
|
|
true,
|
|
);
|
|
expect(server.isOriginAllowed(makeRequest())).toBe(true);
|
|
});
|
|
|
|
test('allows any origin when allowlist contains a wildcard', () => {
|
|
server.opts.allowedOrigins = ['*'];
|
|
expect(server.isOriginAllowed(makeRequest('https://anything'))).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
test('allows an exact-match origin', () => {
|
|
server.opts.allowedOrigins = ['https://a.example', 'https://b.example'];
|
|
expect(server.isOriginAllowed(makeRequest('https://b.example'))).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
test('rejects a non-matching or missing origin', () => {
|
|
server.opts.allowedOrigins = ['https://a.example'];
|
|
expect(server.isOriginAllowed(makeRequest('https://evil.example'))).toBe(
|
|
false,
|
|
);
|
|
expect(server.isOriginAllowed(makeRequest())).toBe(false);
|
|
});
|
|
});
|
|
|
|
const setReadyState = (ws: WebSocket, value: typeof ws.readyState) => {
|
|
// workaround for not being able to do
|
|
// spyOn(instance,'readyState','get').and.returnValue(value);
|
|
// See for details: https://github.com/facebook/jest/issues/9675
|
|
Object.defineProperty(ws, 'readyState', {
|
|
configurable: true,
|
|
get() {
|
|
return value;
|
|
},
|
|
});
|
|
};
|
|
|
|
describe('checkSockets', () => {
|
|
let ws: WebSocket;
|
|
let pingSpy: jest.SpiedFunction<typeof ws.ping>;
|
|
let terminateSpy: jest.SpiedFunction<typeof ws.terminate>;
|
|
let socketInstance: server.SocketInstance;
|
|
|
|
beforeEach(() => {
|
|
ws = new wsMock('localhost');
|
|
pingSpy = jest.spyOn(ws, 'ping');
|
|
terminateSpy = jest.spyOn(ws, 'terminate');
|
|
socketInstance = { ws: ws, channel: channelId, pongTs: Date.now() };
|
|
});
|
|
|
|
test('active sockets', () => {
|
|
setReadyState(ws, WebSocket.OPEN);
|
|
server.trackClient(channelId, socketInstance);
|
|
|
|
server.checkSockets();
|
|
|
|
expect(pingSpy).toHaveBeenCalled();
|
|
expect(terminateSpy).not.toHaveBeenCalled();
|
|
expect(Object.keys(server.sockets).length).toBe(1);
|
|
});
|
|
|
|
test('stale sockets', () => {
|
|
setReadyState(ws, WebSocket.OPEN);
|
|
socketInstance.pongTs = Date.now() - 60000;
|
|
server.trackClient(channelId, socketInstance);
|
|
|
|
server.checkSockets();
|
|
|
|
expect(pingSpy).not.toHaveBeenCalled();
|
|
expect(terminateSpy).toHaveBeenCalled();
|
|
expect(Object.keys(server.sockets).length).toBe(0);
|
|
});
|
|
|
|
test('closed sockets', () => {
|
|
setReadyState(ws, WebSocket.CLOSED);
|
|
server.trackClient(channelId, socketInstance);
|
|
|
|
server.checkSockets();
|
|
|
|
expect(pingSpy).not.toHaveBeenCalled();
|
|
expect(terminateSpy).not.toHaveBeenCalled();
|
|
expect(Object.keys(server.sockets).length).toBe(0);
|
|
});
|
|
|
|
test('no sockets', () => {
|
|
// don't error
|
|
server.checkSockets();
|
|
});
|
|
});
|
|
|
|
describe('cleanChannel', () => {
|
|
let ws: WebSocket;
|
|
let socketInstance: server.SocketInstance;
|
|
|
|
beforeEach(() => {
|
|
ws = new wsMock('localhost');
|
|
socketInstance = { ws: ws, channel: channelId, pongTs: Date.now() };
|
|
});
|
|
|
|
test('active sockets', () => {
|
|
setReadyState(ws, WebSocket.OPEN);
|
|
server.trackClient(channelId, socketInstance);
|
|
|
|
server.cleanChannel(channelId);
|
|
|
|
expect(server.channels[channelId].sockets.length).toBe(1);
|
|
});
|
|
|
|
test('closing sockets', () => {
|
|
setReadyState(ws, WebSocket.CLOSING);
|
|
server.trackClient(channelId, socketInstance);
|
|
|
|
server.cleanChannel(channelId);
|
|
|
|
expect(server.channels[channelId]).toBeUndefined();
|
|
});
|
|
|
|
test('multiple sockets', () => {
|
|
setReadyState(ws, WebSocket.OPEN);
|
|
server.trackClient(channelId, socketInstance);
|
|
|
|
const ws2 = new wsMock('localhost');
|
|
setReadyState(ws2, WebSocket.OPEN);
|
|
const socketInstance2 = {
|
|
ws: ws2,
|
|
channel: channelId,
|
|
pongTs: Date.now(),
|
|
};
|
|
server.trackClient(channelId, socketInstance2);
|
|
|
|
server.cleanChannel(channelId);
|
|
|
|
expect(server.channels[channelId].sockets.length).toBe(2);
|
|
|
|
setReadyState(ws2, WebSocket.CLOSED);
|
|
server.cleanChannel(channelId);
|
|
|
|
expect(server.channels[channelId].sockets.length).toBe(1);
|
|
});
|
|
|
|
test('invalid channel', () => {
|
|
// don't error
|
|
server.cleanChannel(channelId);
|
|
});
|
|
});
|
|
});
|