mirror of
https://github.com/apache/superset.git
synced 2026-09-01 21:11:28 +00:00
273 lines
10 KiB
JavaScript
273 lines
10 KiB
JavaScript
/**
|
|
* 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 http = require('http');
|
|
const zlib = require('zlib');
|
|
const { ZSTDCompress } = require('simple-zstd');
|
|
const { createProxyMiddleware } = require('http-proxy-middleware');
|
|
|
|
// yargs ships ESM-only and jest's default transform doesn't cover
|
|
// node_modules; webpack.proxy-config.js only uses it to parse a `--env`
|
|
// CLI flag we don't exercise here (the target port is set via
|
|
// process.env.supersetPort below), so stub it out rather than teaching
|
|
// the whole suite's transformIgnorePatterns about it.
|
|
jest.mock('yargs', () => jest.fn(() => ({ parse: () => ({}) })));
|
|
jest.mock('yargs/helpers', () => ({ hideBin: argv => argv }));
|
|
|
|
const HANG_GUARD_MS = 2000;
|
|
|
|
/**
|
|
* Wires the real dev proxy config to a real HTTP server, exactly the way
|
|
* webpack-dev-server does (`devServer.proxy: [() => proxyConfig]`), and
|
|
* points it at a caller-supplied backend. Both servers are ephemeral
|
|
* (port 0) so tests can run in parallel.
|
|
*/
|
|
async function startProxy(backendPort) {
|
|
const previousPort = process.env.supersetPort;
|
|
// webpack.proxy-config.js resolves its target port from process.env at
|
|
// require()-time, so the module must be (re-)required after this is set.
|
|
process.env.supersetPort = String(backendPort);
|
|
jest.resetModules();
|
|
// eslint-disable-next-line global-require
|
|
const getProxyConfig = require('../webpack.proxy-config');
|
|
process.env.supersetPort = previousPort;
|
|
|
|
const proxyMiddleware = createProxyMiddleware(getProxyConfig(undefined));
|
|
const server = http.createServer((req, res) => proxyMiddleware(req, res));
|
|
await new Promise(resolve => server.listen(0, resolve));
|
|
return server;
|
|
}
|
|
|
|
async function startBackend(handler) {
|
|
const server = http.createServer(handler);
|
|
await new Promise(resolve => server.listen(0, resolve));
|
|
return server;
|
|
}
|
|
|
|
function get(port, path = '/dashboard/list/') {
|
|
return new Promise((resolve, reject) => {
|
|
const req = http.get({ hostname: 'localhost', port, path }, res => {
|
|
const chunks = [];
|
|
res.on('data', chunk => chunks.push(chunk));
|
|
res.on('end', () =>
|
|
resolve({
|
|
statusCode: res.statusCode,
|
|
body: Buffer.concat(chunks).toString(),
|
|
}),
|
|
);
|
|
res.on('error', reject);
|
|
});
|
|
req.on('error', reject);
|
|
});
|
|
}
|
|
|
|
async function closeAll(...servers) {
|
|
await Promise.all(
|
|
servers.map(server => new Promise(resolve => server.close(resolve))),
|
|
);
|
|
}
|
|
|
|
// simple-zstd only exposes streaming (de)compressors backed by the system
|
|
// zstd binary, not a buffer-in/buffer-out helper -- wrap ZSTDCompress so the
|
|
// tests below can compress a fixture in one call.
|
|
function compressBuffer(buffer, level = 3) {
|
|
return new Promise((resolve, reject) => {
|
|
const chunks = [];
|
|
const compressor = ZSTDCompress(level);
|
|
compressor.on('data', chunk => chunks.push(chunk));
|
|
compressor.on('end', () => resolve(Buffer.concat(chunks)));
|
|
compressor.on('error', reject);
|
|
compressor.end(buffer);
|
|
});
|
|
}
|
|
|
|
describe('webpack.proxy-config zstd/gzip HTML decompression', () => {
|
|
test('decompresses a complete zstd-encoded HTML response and injects the [DEV] title', async () => {
|
|
const html =
|
|
'<html><head><title>Superset</title></head><body>hi</body></html>';
|
|
const backend = await startBackend(async (req, res) => {
|
|
const compressed = await compressBuffer(Buffer.from(html), 3);
|
|
res.writeHead(200, {
|
|
'content-type': 'text/html; charset=utf-8',
|
|
'content-encoding': 'zstd',
|
|
});
|
|
res.end(compressed);
|
|
});
|
|
const proxy = await startProxy(backend.address().port);
|
|
|
|
try {
|
|
const { statusCode, body } = await get(proxy.address().port);
|
|
expect(statusCode).toBe(200);
|
|
expect(body).toContain('[DEV] Superset');
|
|
expect(body).toContain('<body>hi</body>');
|
|
} finally {
|
|
await closeAll(proxy, backend);
|
|
}
|
|
});
|
|
|
|
test(
|
|
'fails fast instead of hanging when the backend connection drops mid-response (zstd)',
|
|
async () => {
|
|
const html = `<html><head><title>Superset</title></head><body>${'x'.repeat(20000)}</body></html>`;
|
|
const backend = await startBackend(async (req, res) => {
|
|
const compressed = await compressBuffer(Buffer.from(html), 3);
|
|
res.writeHead(200, {
|
|
'content-type': 'text/html; charset=utf-8',
|
|
'content-encoding': 'zstd',
|
|
});
|
|
// Simulate the backend dying mid-response -- e.g. the Flask dev
|
|
// server's reloader restarting on a file save -- by writing only
|
|
// half the compressed body and then hard-destroying the socket.
|
|
// The short delay lets the proxy fully receive the response headers
|
|
// first, so this exercises the body-stream-level failure inside
|
|
// processHTML rather than a connection-level error that
|
|
// http-proxy-middleware's own error handler would intercept first.
|
|
res.write(compressed.subarray(0, Math.floor(compressed.length / 2)));
|
|
setTimeout(() => res.socket.destroy(), 20);
|
|
});
|
|
const proxy = await startProxy(backend.address().port);
|
|
|
|
let hangGuardTimer;
|
|
try {
|
|
const hangGuard = new Promise((_resolve, reject) => {
|
|
hangGuardTimer = setTimeout(
|
|
() =>
|
|
reject(
|
|
new Error(
|
|
'request never resolved -- the client-facing response hung ' +
|
|
'instead of the proxy propagating the backend disconnect',
|
|
),
|
|
),
|
|
HANG_GUARD_MS,
|
|
);
|
|
});
|
|
|
|
// Headers (including the 200 the backend sent before dying) are
|
|
// already flushed before the drop is detected, so the response
|
|
// completes with the original status; what matters is that it
|
|
// completes at all, promptly, with the error surfaced in the body
|
|
// instead of the connection hanging indefinitely.
|
|
const { body } = await Promise.race([
|
|
get(proxy.address().port),
|
|
hangGuard,
|
|
]);
|
|
expect(body).toContain('Error requesting');
|
|
} finally {
|
|
clearTimeout(hangGuardTimer);
|
|
await closeAll(proxy, backend);
|
|
}
|
|
},
|
|
HANG_GUARD_MS + 1000,
|
|
);
|
|
|
|
test(
|
|
'fails fast instead of hanging when the backend connection drops mid-response (gzip)',
|
|
async () => {
|
|
// The hang is a generic pipe()-doesn't-propagate-errors bug in
|
|
// processHTML, not specific to any one decoder -- pin it for gzip too.
|
|
const html = `<html><head><title>Superset</title></head><body>${'x'.repeat(20000)}</body></html>`;
|
|
const compressed = zlib.gzipSync(Buffer.from(html));
|
|
const backend = await startBackend((req, res) => {
|
|
res.writeHead(200, {
|
|
'content-type': 'text/html; charset=utf-8',
|
|
'content-encoding': 'gzip',
|
|
});
|
|
res.write(compressed.subarray(0, Math.floor(compressed.length / 2)));
|
|
setTimeout(() => res.socket.destroy(), 20);
|
|
});
|
|
const proxy = await startProxy(backend.address().port);
|
|
|
|
let hangGuardTimer;
|
|
try {
|
|
const hangGuard = new Promise((_resolve, reject) => {
|
|
hangGuardTimer = setTimeout(
|
|
() =>
|
|
reject(
|
|
new Error(
|
|
'request never resolved -- the client-facing response hung ' +
|
|
'instead of the proxy propagating the backend disconnect',
|
|
),
|
|
),
|
|
HANG_GUARD_MS,
|
|
);
|
|
});
|
|
|
|
const { body } = await Promise.race([
|
|
get(proxy.address().port),
|
|
hangGuard,
|
|
]);
|
|
expect(body).toContain('Error requesting');
|
|
} finally {
|
|
clearTimeout(hangGuardTimer);
|
|
await closeAll(proxy, backend);
|
|
}
|
|
},
|
|
HANG_GUARD_MS + 1000,
|
|
);
|
|
});
|
|
|
|
describe('webpack.proxy-config generic (non-HTML) passthrough', () => {
|
|
test(
|
|
'fails fast instead of hanging when the backend connection drops mid-response (JSON)',
|
|
async () => {
|
|
// Dashboard/chart data requests (e.g. /api/v1/chart/data) go through
|
|
// this generic passthrough branch, not processHTML -- pin the same
|
|
// mid-stream-disconnect hang for it too.
|
|
const json = `{"result": "${'x'.repeat(20000)}"}`;
|
|
const backend = await startBackend((req, res) => {
|
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
res.write(json.slice(0, Math.floor(json.length / 2)));
|
|
setTimeout(() => res.socket.destroy(), 20);
|
|
});
|
|
const proxy = await startProxy(backend.address().port);
|
|
|
|
let hangGuardTimer;
|
|
try {
|
|
const hangGuard = new Promise((_resolve, reject) => {
|
|
hangGuardTimer = setTimeout(
|
|
() =>
|
|
reject(
|
|
new Error(
|
|
'request never resolved -- the client-facing response hung ' +
|
|
'instead of the proxy propagating the backend disconnect',
|
|
),
|
|
),
|
|
HANG_GUARD_MS,
|
|
);
|
|
});
|
|
|
|
// Whether the truncated body surfaces as a client-side error or as
|
|
// a short-but-well-framed response depends on transfer encoding --
|
|
// what matters here is that the request settles promptly one way
|
|
// or the other instead of hanging indefinitely.
|
|
const settled = await Promise.race([
|
|
get(proxy.address().port, '/api/v1/chart/data').catch(e => ({
|
|
error: e.message,
|
|
})),
|
|
hangGuard,
|
|
]);
|
|
expect(settled).toBeTruthy();
|
|
} finally {
|
|
clearTimeout(hangGuardTimer);
|
|
await closeAll(proxy, backend);
|
|
}
|
|
},
|
|
HANG_GUARD_MS + 1000,
|
|
);
|
|
});
|