signalk-questdb 2.1.0 → 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/migration.js +40 -45
- package/dist/migration.js.map +1 -1
- package/package.json +1 -1
- package/dist/test/config.test.d.ts +0 -1
- package/dist/test/config.test.js +0 -161
- package/dist/test/config.test.js.map +0 -1
- package/dist/test/configpanel-responses.test.d.ts +0 -1
- package/dist/test/configpanel-responses.test.js +0 -197
- package/dist/test/configpanel-responses.test.js.map +0 -1
- package/dist/test/console-proxy.test.d.ts +0 -1
- package/dist/test/console-proxy.test.js +0 -664
- package/dist/test/console-proxy.test.js.map +0 -1
- package/dist/test/container-env.test.d.ts +0 -1
- package/dist/test/container-env.test.js +0 -70
- package/dist/test/container-env.test.js.map +0 -1
- package/dist/test/delta-routing.test.d.ts +0 -1
- package/dist/test/delta-routing.test.js +0 -258
- package/dist/test/delta-routing.test.js.map +0 -1
- package/dist/test/full-export-range.test.d.ts +0 -1
- package/dist/test/full-export-range.test.js +0 -78
- package/dist/test/full-export-range.test.js.map +0 -1
- package/dist/test/history-v1.test.d.ts +0 -1
- package/dist/test/history-v1.test.js +0 -676
- package/dist/test/history-v1.test.js.map +0 -1
- package/dist/test/history-v2.test.d.ts +0 -1
- package/dist/test/history-v2.test.js +0 -1001
- package/dist/test/history-v2.test.js.map +0 -1
- package/dist/test/host-limits.test.d.ts +0 -1
- package/dist/test/host-limits.test.js +0 -81
- package/dist/test/host-limits.test.js.map +0 -1
- package/dist/test/ilp-writer.test.d.ts +0 -1
- package/dist/test/ilp-writer.test.js +0 -419
- package/dist/test/ilp-writer.test.js.map +0 -1
- package/dist/test/influx-detect.test.d.ts +0 -1
- package/dist/test/influx-detect.test.js +0 -287
- package/dist/test/influx-detect.test.js.map +0 -1
- package/dist/test/migration.test.d.ts +0 -1
- package/dist/test/migration.test.js +0 -851
- package/dist/test/migration.test.js.map +0 -1
- package/dist/test/package-e2e.test.d.ts +0 -1
- package/dist/test/package-e2e.test.js +0 -306
- package/dist/test/package-e2e.test.js.map +0 -1
- package/dist/test/path-matcher.test.d.ts +0 -1
- package/dist/test/path-matcher.test.js +0 -224
- package/dist/test/path-matcher.test.js.map +0 -1
- package/dist/test/query-client.test.d.ts +0 -1
- package/dist/test/query-client.test.js +0 -299
- package/dist/test/query-client.test.js.map +0 -1
- package/dist/test/questdb-endpoint.test.d.ts +0 -1
- package/dist/test/questdb-endpoint.test.js +0 -297
- package/dist/test/questdb-endpoint.test.js.map +0 -1
- package/dist/test/restore.test.d.ts +0 -1
- package/dist/test/restore.test.js +0 -543
- package/dist/test/restore.test.js.map +0 -1
- package/dist/test/time-range.test.d.ts +0 -1
- package/dist/test/time-range.test.js +0 -53
- package/dist/test/time-range.test.js.map +0 -1
- package/dist/test/wal-monitor.test.d.ts +0 -1
- package/dist/test/wal-monitor.test.js +0 -405
- package/dist/test/wal-monitor.test.js.map +0 -1
|
@@ -1,664 +0,0 @@
|
|
|
1
|
-
import { describe, it, before, after } from "node:test";
|
|
2
|
-
import assert from "node:assert/strict";
|
|
3
|
-
import http from "node:http";
|
|
4
|
-
import { createConsoleProxy } from "../console-proxy.js";
|
|
5
|
-
let upstream;
|
|
6
|
-
let upstreamUrl;
|
|
7
|
-
let seen = [];
|
|
8
|
-
const MOUNT = "/plugins/signalk-questdb/console";
|
|
9
|
-
before(async () => {
|
|
10
|
-
upstream = http.createServer((req, res) => {
|
|
11
|
-
let body = "";
|
|
12
|
-
req.on("data", (c) => (body += c));
|
|
13
|
-
req.on("end", () => {
|
|
14
|
-
seen.push({
|
|
15
|
-
method: req.method,
|
|
16
|
-
url: req.url,
|
|
17
|
-
headers: req.headers,
|
|
18
|
-
body,
|
|
19
|
-
});
|
|
20
|
-
if (req.url?.startsWith("/teapot")) {
|
|
21
|
-
res.writeHead(418, { "content-type": "text/plain" });
|
|
22
|
-
res.end("no coffee");
|
|
23
|
-
return;
|
|
24
|
-
}
|
|
25
|
-
res.writeHead(200, { "content-type": "application/json" });
|
|
26
|
-
res.end(JSON.stringify({ ok: true }));
|
|
27
|
-
});
|
|
28
|
-
});
|
|
29
|
-
await new Promise((r) => upstream.listen(0, "127.0.0.1", r));
|
|
30
|
-
const { port } = upstream.address();
|
|
31
|
-
upstreamUrl = `http://127.0.0.1:${port}`;
|
|
32
|
-
});
|
|
33
|
-
after(async () => {
|
|
34
|
-
await new Promise((r) => upstream.close(() => r()));
|
|
35
|
-
});
|
|
36
|
-
// Drive the proxy through a real server so req/res are genuine streams —
|
|
37
|
-
// piping is the part most likely to break, and a hand-rolled mock would not
|
|
38
|
-
// exercise it.
|
|
39
|
-
async function throughProxy(path, init = {}) {
|
|
40
|
-
seen = [];
|
|
41
|
-
const proxy = createConsoleProxy({
|
|
42
|
-
baseUrl: init.baseUrl ?? (() => upstreamUrl),
|
|
43
|
-
debug: () => { },
|
|
44
|
-
mountPath: MOUNT,
|
|
45
|
-
});
|
|
46
|
-
const front = http.createServer((req, res) => proxy(req, res));
|
|
47
|
-
await new Promise((r) => front.listen(0, "127.0.0.1", r));
|
|
48
|
-
const { port } = front.address();
|
|
49
|
-
try {
|
|
50
|
-
const res = await fetch(`http://127.0.0.1:${port}${path}`, {
|
|
51
|
-
method: init.method ?? "GET",
|
|
52
|
-
body: init.body,
|
|
53
|
-
});
|
|
54
|
-
return {
|
|
55
|
-
status: res.status,
|
|
56
|
-
text: await res.text(),
|
|
57
|
-
csp: res.headers.get("content-security-policy"),
|
|
58
|
-
nosniff: res.headers.get("x-content-type-options"),
|
|
59
|
-
seen,
|
|
60
|
-
};
|
|
61
|
-
}
|
|
62
|
-
finally {
|
|
63
|
-
await new Promise((r) => front.close(() => r()));
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
describe("console proxy forwarding", () => {
|
|
67
|
-
it("forwards the path to the upstream", async () => {
|
|
68
|
-
const r = await throughProxy("/index.html");
|
|
69
|
-
assert.equal(r.status, 200);
|
|
70
|
-
assert.equal(r.seen[0].url, "/index.html");
|
|
71
|
-
});
|
|
72
|
-
it("preserves the query string", async () => {
|
|
73
|
-
// The console's whole API is query-driven (`exec?query=...`); dropping the
|
|
74
|
-
// query would leave every request syntactically valid and useless.
|
|
75
|
-
const r = await throughProxy("/exec?query=SELECT%201&limit=0%2C1000");
|
|
76
|
-
assert.equal(r.seen[0].url, "/exec?query=SELECT%201&limit=0%2C1000");
|
|
77
|
-
});
|
|
78
|
-
it("forwards the method and body", async () => {
|
|
79
|
-
const r = await throughProxy("/imp", { method: "POST", body: "csvdata" });
|
|
80
|
-
assert.equal(r.seen[0].method, "POST");
|
|
81
|
-
assert.equal(r.seen[0].body, "csvdata");
|
|
82
|
-
});
|
|
83
|
-
it("passes the upstream status and content-type through", async () => {
|
|
84
|
-
const r = await throughProxy("/teapot");
|
|
85
|
-
assert.equal(r.status, 418);
|
|
86
|
-
assert.equal(r.text, "no coffee");
|
|
87
|
-
});
|
|
88
|
-
it("rewrites Host so it names the upstream, not Signal K", async () => {
|
|
89
|
-
const r = await throughProxy("/");
|
|
90
|
-
const host = r.seen[0].headers.host;
|
|
91
|
-
assert.ok(host?.startsWith("127.0.0.1:"), `expected the upstream host, got ${host}`);
|
|
92
|
-
});
|
|
93
|
-
it("does not leak Signal K credentials to QuestDB", async () => {
|
|
94
|
-
// The admin session cookie authenticates the caller to Signal K. QuestDB
|
|
95
|
-
// has no use for it and should never see it.
|
|
96
|
-
const proxy = createConsoleProxy({
|
|
97
|
-
baseUrl: () => upstreamUrl,
|
|
98
|
-
debug: () => { },
|
|
99
|
-
mountPath: MOUNT,
|
|
100
|
-
});
|
|
101
|
-
seen = [];
|
|
102
|
-
const front = http.createServer((req, res) => proxy(req, res));
|
|
103
|
-
await new Promise((r) => front.listen(0, "127.0.0.1", r));
|
|
104
|
-
const { port } = front.address();
|
|
105
|
-
try {
|
|
106
|
-
await fetch(`http://127.0.0.1:${port}/`, {
|
|
107
|
-
headers: {
|
|
108
|
-
cookie: "JAUTHENTICATION=secret",
|
|
109
|
-
authorization: "Bearer x",
|
|
110
|
-
},
|
|
111
|
-
});
|
|
112
|
-
}
|
|
113
|
-
finally {
|
|
114
|
-
await new Promise((r) => front.close(() => r()));
|
|
115
|
-
}
|
|
116
|
-
assert.equal(seen[0].headers.cookie, undefined);
|
|
117
|
-
assert.equal(seen[0].headers.authorization, undefined);
|
|
118
|
-
});
|
|
119
|
-
});
|
|
120
|
-
describe("console proxy header hygiene", () => {
|
|
121
|
-
it("drops headers nominated by Connection, not just the fixed set", async () => {
|
|
122
|
-
// RFC 7230 §6.1: Connection may list ADDITIONAL single-hop headers. A
|
|
123
|
-
// fixed blocklist forwards those, leaking connection-scoped state across
|
|
124
|
-
// the hop.
|
|
125
|
-
const proxy = createConsoleProxy({
|
|
126
|
-
baseUrl: () => upstreamUrl,
|
|
127
|
-
debug: () => { },
|
|
128
|
-
mountPath: MOUNT,
|
|
129
|
-
});
|
|
130
|
-
seen = [];
|
|
131
|
-
const front = http.createServer((req, res) => proxy(req, res));
|
|
132
|
-
await new Promise((r) => front.listen(0, "127.0.0.1", r));
|
|
133
|
-
const { port } = front.address();
|
|
134
|
-
try {
|
|
135
|
-
// Raw socket: fetch() refuses to set Connection.
|
|
136
|
-
await new Promise((resolve) => {
|
|
137
|
-
const r = http.request({
|
|
138
|
-
// Explicit host. `http.request` defaults to `localhost`, which
|
|
139
|
-
// resolves differently inside the plugin registry's firejail
|
|
140
|
-
// sandbox and failed with ECONNREFUSED — while `fetch`, used by
|
|
141
|
-
// every other test here, was unaffected. The server listens on
|
|
142
|
-
// 127.0.0.1, so name it.
|
|
143
|
-
host: "127.0.0.1",
|
|
144
|
-
port,
|
|
145
|
-
path: "/",
|
|
146
|
-
// `close`, not `keep-alive`: the header still nominates
|
|
147
|
-
// x-hop-secret (which is what this test is about), but the socket
|
|
148
|
-
// is not held open afterwards. With keep-alive the agent keeps it
|
|
149
|
-
// alive, server.close() waits for it, and the test hangs — which
|
|
150
|
-
// it did, but only under the registry's sandbox where the timing
|
|
151
|
-
// differed enough to expose it.
|
|
152
|
-
headers: {
|
|
153
|
-
connection: "close, x-hop-secret",
|
|
154
|
-
"x-hop-secret": "must-not-travel",
|
|
155
|
-
"x-normal": "must-travel",
|
|
156
|
-
},
|
|
157
|
-
}, (res) => {
|
|
158
|
-
res.resume();
|
|
159
|
-
res.on("end", () => resolve());
|
|
160
|
-
});
|
|
161
|
-
r.end();
|
|
162
|
-
});
|
|
163
|
-
}
|
|
164
|
-
finally {
|
|
165
|
-
// closeAllConnections() so a lingering socket cannot stall teardown.
|
|
166
|
-
front.closeAllConnections?.();
|
|
167
|
-
await new Promise((r) => front.close(() => r()));
|
|
168
|
-
}
|
|
169
|
-
assert.equal(seen[0].headers["x-hop-secret"], undefined);
|
|
170
|
-
assert.equal(seen[0].headers["x-normal"], "must-travel");
|
|
171
|
-
});
|
|
172
|
-
it("sends a CSP narrowing what the same-origin console may do", async () => {
|
|
173
|
-
const r = await throughProxy("/");
|
|
174
|
-
assert.match(r.csp ?? "", /form-action 'self'/);
|
|
175
|
-
assert.match(r.csp ?? "", /frame-ancestors 'self'/);
|
|
176
|
-
assert.match(r.csp ?? "", /base-uri 'self'/);
|
|
177
|
-
});
|
|
178
|
-
it("does not send connect-src, which would break the SQL editor", async () => {
|
|
179
|
-
// Monaco is loaded from a CDN path baked into QuestDB's bundle. A
|
|
180
|
-
// `connect-src 'self'` here reads as safer but blanks out the query
|
|
181
|
-
// editor — the console's entire purpose. Pinned so a future tightening is
|
|
182
|
-
// a deliberate decision, taken with the Monaco question re-checked.
|
|
183
|
-
const r = await throughProxy("/");
|
|
184
|
-
assert.doesNotMatch(r.csp ?? "", /connect-src/);
|
|
185
|
-
});
|
|
186
|
-
it("does not let QuestDB set cookies on the Signal K origin", async () => {
|
|
187
|
-
// The console shares Signal K's origin, so a proxied Set-Cookie would be
|
|
188
|
-
// applied to it — a `Path=/` cookie from QuestDB could collide with Signal
|
|
189
|
-
// K's own session handling. The request side already strips `cookie`, so
|
|
190
|
-
// no QuestDB cookie could complete a round trip regardless.
|
|
191
|
-
const cookieUpstream = http.createServer((_req, res) => {
|
|
192
|
-
res.writeHead(200, {
|
|
193
|
-
"set-cookie": "questdb_session=abc; Path=/",
|
|
194
|
-
"content-type": "text/plain",
|
|
195
|
-
});
|
|
196
|
-
res.end("ok");
|
|
197
|
-
});
|
|
198
|
-
await new Promise((r) => cookieUpstream.listen(0, "127.0.0.1", r));
|
|
199
|
-
const upPort = cookieUpstream.address().port;
|
|
200
|
-
const proxy = createConsoleProxy({
|
|
201
|
-
baseUrl: () => `http://127.0.0.1:${upPort}`,
|
|
202
|
-
debug: () => { },
|
|
203
|
-
mountPath: MOUNT,
|
|
204
|
-
});
|
|
205
|
-
const front = http.createServer((req, res) => proxy(req, res));
|
|
206
|
-
await new Promise((r) => front.listen(0, "127.0.0.1", r));
|
|
207
|
-
const { port } = front.address();
|
|
208
|
-
try {
|
|
209
|
-
const res = await fetch(`http://127.0.0.1:${port}/`, {
|
|
210
|
-
headers: { connection: "close" },
|
|
211
|
-
});
|
|
212
|
-
assert.equal(res.headers.get("set-cookie"), null);
|
|
213
|
-
}
|
|
214
|
-
finally {
|
|
215
|
-
await new Promise((r) => front.close(() => r()));
|
|
216
|
-
await new Promise((r) => cookieUpstream.close(() => r()));
|
|
217
|
-
}
|
|
218
|
-
});
|
|
219
|
-
it("marks responses nosniff", async () => {
|
|
220
|
-
const r = await throughProxy("/");
|
|
221
|
-
assert.equal(r.nosniff, "nosniff");
|
|
222
|
-
});
|
|
223
|
-
});
|
|
224
|
-
describe("console proxy redirect rewriting", () => {
|
|
225
|
-
// QuestDB answers `/` with `301 Location: /index.html` — an ABSOLUTE path.
|
|
226
|
-
// Relayed unchanged it escapes the mount and lands on the Signal K root,
|
|
227
|
-
// which 404s. That is what made the webapp report "console unavailable"
|
|
228
|
-
// while the console itself was serving fine. My own testing missed it
|
|
229
|
-
// because `curl -L` follows the redirect and papers over the escape.
|
|
230
|
-
function redirectingUpstream(location) {
|
|
231
|
-
return http.createServer((_req, res) => {
|
|
232
|
-
res.writeHead(301, { location });
|
|
233
|
-
res.end();
|
|
234
|
-
});
|
|
235
|
-
}
|
|
236
|
-
async function locationThrough(upstreamLocation) {
|
|
237
|
-
const up = redirectingUpstream(upstreamLocation);
|
|
238
|
-
await new Promise((r) => up.listen(0, "127.0.0.1", r));
|
|
239
|
-
const upPort = up.address().port;
|
|
240
|
-
const proxy = createConsoleProxy({
|
|
241
|
-
baseUrl: () => `http://127.0.0.1:${upPort}`,
|
|
242
|
-
debug: () => { },
|
|
243
|
-
mountPath: MOUNT,
|
|
244
|
-
});
|
|
245
|
-
const front = http.createServer((req, res) => proxy(req, res));
|
|
246
|
-
await new Promise((r) => front.listen(0, "127.0.0.1", r));
|
|
247
|
-
const { port } = front.address();
|
|
248
|
-
try {
|
|
249
|
-
// `Connection: close` so no keep-alive socket survives the request —
|
|
250
|
-
// server.close() waits for open connections and would otherwise hang.
|
|
251
|
-
const res = await fetch(`http://127.0.0.1:${port}/`, {
|
|
252
|
-
redirect: "manual",
|
|
253
|
-
headers: { connection: "close" },
|
|
254
|
-
});
|
|
255
|
-
return res.headers.get("location");
|
|
256
|
-
}
|
|
257
|
-
finally {
|
|
258
|
-
await new Promise((r) => front.close(() => r()));
|
|
259
|
-
await new Promise((r) => up.close(() => r()));
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
it("does not announce a chunked body on a bodyless redirect", async () => {
|
|
263
|
-
// QuestDB's 301 states NEITHER content-length nor transfer-encoding, so
|
|
264
|
-
// Node falls back to chunked framing — announcing a body that never
|
|
265
|
-
// arrives. The browser waits on a redirect that never completes, which is
|
|
266
|
-
// how a working console still reported "console unavailable" even after
|
|
267
|
-
// the Location rewrite was correct. Rewriting Location was necessary but
|
|
268
|
-
// not sufficient; this is the other half.
|
|
269
|
-
// Exactly QuestDB's shape: no content-length, no transfer-encoding, no
|
|
270
|
-
// body. `res.writeHead(...); res.end()` will NOT do — Node adds
|
|
271
|
-
// `chunked` for an empty body, which the real origin does not send, and a
|
|
272
|
-
// stand-in that declares framing is not the case under test. Write the
|
|
273
|
-
// raw response so the bytes match what QuestDB actually emits.
|
|
274
|
-
const up = http.createServer((_req, res) => {
|
|
275
|
-
res.socket?.end("HTTP/1.1 301 Moved Permanently\r\n" +
|
|
276
|
-
"Location: /index.html\r\n" +
|
|
277
|
-
"Connection: close\r\n" +
|
|
278
|
-
"\r\n");
|
|
279
|
-
});
|
|
280
|
-
await new Promise((r) => up.listen(0, "127.0.0.1", r));
|
|
281
|
-
const upPort = up.address().port;
|
|
282
|
-
const proxy = createConsoleProxy({
|
|
283
|
-
baseUrl: () => `http://127.0.0.1:${upPort}`,
|
|
284
|
-
debug: () => { },
|
|
285
|
-
mountPath: MOUNT,
|
|
286
|
-
});
|
|
287
|
-
const front = http.createServer((req, res) => proxy(req, res));
|
|
288
|
-
await new Promise((r) => front.listen(0, "127.0.0.1", r));
|
|
289
|
-
const { port } = front.address();
|
|
290
|
-
try {
|
|
291
|
-
// Raw client: fetch() normalises framing away, hiding the very header
|
|
292
|
-
// this test exists to check.
|
|
293
|
-
const headers = await new Promise((resolve, reject) => {
|
|
294
|
-
const r = http.request({
|
|
295
|
-
host: "127.0.0.1",
|
|
296
|
-
port,
|
|
297
|
-
path: "/",
|
|
298
|
-
headers: { connection: "close" },
|
|
299
|
-
}, (res) => {
|
|
300
|
-
res.resume();
|
|
301
|
-
res.on("end", () => resolve(res.headers));
|
|
302
|
-
});
|
|
303
|
-
r.on("error", reject);
|
|
304
|
-
r.end();
|
|
305
|
-
});
|
|
306
|
-
assert.equal(headers["transfer-encoding"], undefined, "a bodyless redirect must not be chunked");
|
|
307
|
-
assert.equal(headers["content-length"], "0");
|
|
308
|
-
}
|
|
309
|
-
finally {
|
|
310
|
-
front.closeAllConnections?.();
|
|
311
|
-
await new Promise((r) => front.close(() => r()));
|
|
312
|
-
await new Promise((r) => up.close(() => r()));
|
|
313
|
-
}
|
|
314
|
-
});
|
|
315
|
-
it("passes a 304 through without inventing a content-length", async () => {
|
|
316
|
-
// QuestDB really does serve 304 for the console's assets, so this is the
|
|
317
|
-
// browser-caching path, not a hypothetical. RFC 9110 §6.4.1: a 304 must
|
|
318
|
-
// not carry content-length — adding one here would be a protocol error on
|
|
319
|
-
// every cached asset load.
|
|
320
|
-
const up = http.createServer((_req, res) => {
|
|
321
|
-
res.writeHead(304, { etag: '"abc"' });
|
|
322
|
-
res.end();
|
|
323
|
-
});
|
|
324
|
-
await new Promise((r) => up.listen(0, "127.0.0.1", r));
|
|
325
|
-
const upPort = up.address().port;
|
|
326
|
-
const proxy = createConsoleProxy({
|
|
327
|
-
baseUrl: () => `http://127.0.0.1:${upPort}`,
|
|
328
|
-
debug: () => { },
|
|
329
|
-
mountPath: MOUNT,
|
|
330
|
-
});
|
|
331
|
-
const front = http.createServer((req, res) => proxy(req, res));
|
|
332
|
-
await new Promise((r) => front.listen(0, "127.0.0.1", r));
|
|
333
|
-
const { port } = front.address();
|
|
334
|
-
try {
|
|
335
|
-
const headers = await new Promise((resolve, reject) => {
|
|
336
|
-
const r = http.request({
|
|
337
|
-
host: "127.0.0.1",
|
|
338
|
-
port,
|
|
339
|
-
path: "/",
|
|
340
|
-
headers: { connection: "close" },
|
|
341
|
-
}, (res) => {
|
|
342
|
-
res.resume();
|
|
343
|
-
res.on("end", () => resolve(res.headers));
|
|
344
|
-
});
|
|
345
|
-
r.on("error", reject);
|
|
346
|
-
r.end();
|
|
347
|
-
});
|
|
348
|
-
assert.equal(headers["content-length"], undefined);
|
|
349
|
-
assert.equal(headers["transfer-encoding"], undefined);
|
|
350
|
-
assert.equal(headers.etag, '"abc"');
|
|
351
|
-
}
|
|
352
|
-
finally {
|
|
353
|
-
front.closeAllConnections?.();
|
|
354
|
-
await new Promise((r) => front.close(() => r()));
|
|
355
|
-
await new Promise((r) => up.close(() => r()));
|
|
356
|
-
}
|
|
357
|
-
});
|
|
358
|
-
it("preserves a redirect that legitimately carries a body", async () => {
|
|
359
|
-
// Self-audit catch. A 3xx MAY carry a body (RFC 9110 §15.4) and browsers
|
|
360
|
-
// render it. The first version of the bodyless fix keyed purely on status,
|
|
361
|
-
// so it dropped that body AND overwrote a correct content-length with 0 —
|
|
362
|
-
// trading one framing bug for a worse, silent one.
|
|
363
|
-
const body = "<html>Moved. <a href='/x'>here</a></html>";
|
|
364
|
-
const up = http.createServer((_req, res) => {
|
|
365
|
-
res.writeHead(302, {
|
|
366
|
-
location: "/x",
|
|
367
|
-
"content-type": "text/html",
|
|
368
|
-
"content-length": String(Buffer.byteLength(body)),
|
|
369
|
-
});
|
|
370
|
-
res.end(body);
|
|
371
|
-
});
|
|
372
|
-
await new Promise((r) => up.listen(0, "127.0.0.1", r));
|
|
373
|
-
const upPort = up.address().port;
|
|
374
|
-
const proxy = createConsoleProxy({
|
|
375
|
-
baseUrl: () => `http://127.0.0.1:${upPort}`,
|
|
376
|
-
debug: () => { },
|
|
377
|
-
mountPath: MOUNT,
|
|
378
|
-
});
|
|
379
|
-
const front = http.createServer((req, res) => proxy(req, res));
|
|
380
|
-
await new Promise((r) => front.listen(0, "127.0.0.1", r));
|
|
381
|
-
const { port } = front.address();
|
|
382
|
-
try {
|
|
383
|
-
const res = await fetch(`http://127.0.0.1:${port}/`, {
|
|
384
|
-
redirect: "manual",
|
|
385
|
-
headers: { connection: "close" },
|
|
386
|
-
});
|
|
387
|
-
assert.equal(res.status, 302);
|
|
388
|
-
assert.equal(await res.text(), body, "a redirect body must not be dropped");
|
|
389
|
-
assert.equal(res.headers.get("content-length"), String(body.length));
|
|
390
|
-
}
|
|
391
|
-
finally {
|
|
392
|
-
front.closeAllConnections?.();
|
|
393
|
-
await new Promise((r) => front.close(() => r()));
|
|
394
|
-
await new Promise((r) => up.close(() => r()));
|
|
395
|
-
}
|
|
396
|
-
});
|
|
397
|
-
it("preserves a redirect body sent with chunked encoding", async () => {
|
|
398
|
-
// Second self-audit catch. Keying "bodyless" on the absence of
|
|
399
|
-
// content-length alone still dropped a redirect body streamed with
|
|
400
|
-
// chunked encoding, which carries no length. Both framing headers have to
|
|
401
|
-
// be absent before a 3xx can be treated as empty.
|
|
402
|
-
const body = "<html>Moved chunked</html>";
|
|
403
|
-
const up = http.createServer((_req, res) => {
|
|
404
|
-
// No content-length: Node frames this as chunked.
|
|
405
|
-
res.writeHead(302, { location: "/x", "content-type": "text/html" });
|
|
406
|
-
res.end(body);
|
|
407
|
-
});
|
|
408
|
-
await new Promise((r) => up.listen(0, "127.0.0.1", r));
|
|
409
|
-
const upPort = up.address().port;
|
|
410
|
-
const proxy = createConsoleProxy({
|
|
411
|
-
baseUrl: () => `http://127.0.0.1:${upPort}`,
|
|
412
|
-
debug: () => { },
|
|
413
|
-
mountPath: MOUNT,
|
|
414
|
-
});
|
|
415
|
-
const front = http.createServer((req, res) => proxy(req, res));
|
|
416
|
-
await new Promise((r) => front.listen(0, "127.0.0.1", r));
|
|
417
|
-
const { port } = front.address();
|
|
418
|
-
try {
|
|
419
|
-
const res = await fetch(`http://127.0.0.1:${port}/`, {
|
|
420
|
-
redirect: "manual",
|
|
421
|
-
headers: { connection: "close" },
|
|
422
|
-
});
|
|
423
|
-
assert.equal(await res.text(), body, "a chunked redirect body must survive");
|
|
424
|
-
}
|
|
425
|
-
finally {
|
|
426
|
-
front.closeAllConnections?.();
|
|
427
|
-
await new Promise((r) => front.close(() => r()));
|
|
428
|
-
await new Promise((r) => up.close(() => r()));
|
|
429
|
-
}
|
|
430
|
-
});
|
|
431
|
-
it("keeps a root-relative redirect inside the mount", async () => {
|
|
432
|
-
assert.equal(await locationThrough("/index.html"), `${MOUNT}/index.html`);
|
|
433
|
-
});
|
|
434
|
-
it("preserves the query string on a redirect", async () => {
|
|
435
|
-
assert.equal(await locationThrough("/index.html?a=1"), `${MOUNT}/index.html?a=1`);
|
|
436
|
-
});
|
|
437
|
-
it("leaves a relative redirect alone", async () => {
|
|
438
|
-
// Already resolves against the proxied URL; rewriting would break it.
|
|
439
|
-
assert.equal(await locationThrough("other.html"), "other.html");
|
|
440
|
-
});
|
|
441
|
-
it("does not retarget an absolute redirect to another host", async () => {
|
|
442
|
-
const target = "https://questdb.io/docs";
|
|
443
|
-
assert.equal(await locationThrough(target), target);
|
|
444
|
-
});
|
|
445
|
-
it("does not treat a protocol-relative target as root-relative", async () => {
|
|
446
|
-
// `//evil.example.com/` starts with "/" but names another host; prefixing
|
|
447
|
-
// the mount would silently rewrite it into a path on this origin.
|
|
448
|
-
assert.equal(await locationThrough("//evil.example.com/"), "//evil.example.com/");
|
|
449
|
-
});
|
|
450
|
-
});
|
|
451
|
-
describe("console proxy upstream failure mid-response", () => {
|
|
452
|
-
it("does not leave the client hanging when the upstream dies mid-body", async () => {
|
|
453
|
-
// Self-audit catch. `proxied.pipe(res)` had no error/abort handler, so an
|
|
454
|
-
// upstream that dies after headers were sent never ended our response —
|
|
455
|
-
// the browser tab sat on a request that would never complete. Node signals
|
|
456
|
-
// truncation to a direct client by aborting; the proxy must do the same.
|
|
457
|
-
const up = http.createServer((_req, res) => {
|
|
458
|
-
res.writeHead(200, {
|
|
459
|
-
"content-type": "text/plain",
|
|
460
|
-
"content-length": "100",
|
|
461
|
-
});
|
|
462
|
-
res.write("partial");
|
|
463
|
-
setTimeout(() => res.socket?.destroy(), 30);
|
|
464
|
-
});
|
|
465
|
-
await new Promise((r) => up.listen(0, "127.0.0.1", r));
|
|
466
|
-
const upPort = up.address().port;
|
|
467
|
-
const proxy = createConsoleProxy({
|
|
468
|
-
baseUrl: () => `http://127.0.0.1:${upPort}`,
|
|
469
|
-
debug: () => { },
|
|
470
|
-
mountPath: MOUNT,
|
|
471
|
-
});
|
|
472
|
-
const front = http.createServer((req, res) => proxy(req, res));
|
|
473
|
-
await new Promise((r) => front.listen(0, "127.0.0.1", r));
|
|
474
|
-
const { port } = front.address();
|
|
475
|
-
try {
|
|
476
|
-
const outcome = await new Promise((resolve) => {
|
|
477
|
-
const timer = setTimeout(() => resolve("HUNG"), 4000);
|
|
478
|
-
const settle = (v) => {
|
|
479
|
-
clearTimeout(timer);
|
|
480
|
-
resolve(v);
|
|
481
|
-
};
|
|
482
|
-
const r = http.request({
|
|
483
|
-
host: "127.0.0.1",
|
|
484
|
-
port,
|
|
485
|
-
path: "/",
|
|
486
|
-
headers: { connection: "close" },
|
|
487
|
-
}, (res) => {
|
|
488
|
-
res.resume();
|
|
489
|
-
res.on("aborted", () => settle("aborted"));
|
|
490
|
-
res.on("end", () => settle("ended"));
|
|
491
|
-
});
|
|
492
|
-
r.on("error", () => settle("error"));
|
|
493
|
-
r.end();
|
|
494
|
-
});
|
|
495
|
-
assert.notEqual(outcome, "HUNG", "a truncated response must not hang");
|
|
496
|
-
assert.notEqual(outcome, "ended", "a truncated response must not look like a clean end");
|
|
497
|
-
}
|
|
498
|
-
finally {
|
|
499
|
-
front.closeAllConnections?.();
|
|
500
|
-
await new Promise((r) => front.close(() => r()));
|
|
501
|
-
await new Promise((r) => up.close(() => r()));
|
|
502
|
-
}
|
|
503
|
-
});
|
|
504
|
-
});
|
|
505
|
-
describe("console proxy HEAD content-length", () => {
|
|
506
|
-
it("reports the resource's real length, not zero", async () => {
|
|
507
|
-
// Third self-audit catch. A HEAD response must carry the headers a GET
|
|
508
|
-
// would return (RFC 9110 §9.3.2), so overwriting a stated
|
|
509
|
-
// `content-length: 12345` with 0 misreports the resource's size — a
|
|
510
|
-
// client sizing a download from HEAD would read it as empty.
|
|
511
|
-
const up = http.createServer((_req, res) => {
|
|
512
|
-
res.writeHead(200, {
|
|
513
|
-
"content-type": "text/plain",
|
|
514
|
-
"content-length": "12345",
|
|
515
|
-
});
|
|
516
|
-
res.end();
|
|
517
|
-
});
|
|
518
|
-
await new Promise((r) => up.listen(0, "127.0.0.1", r));
|
|
519
|
-
const upPort = up.address().port;
|
|
520
|
-
const proxy = createConsoleProxy({
|
|
521
|
-
baseUrl: () => `http://127.0.0.1:${upPort}`,
|
|
522
|
-
debug: () => { },
|
|
523
|
-
mountPath: MOUNT,
|
|
524
|
-
});
|
|
525
|
-
const front = http.createServer((req, res) => proxy(req, res));
|
|
526
|
-
await new Promise((r) => front.listen(0, "127.0.0.1", r));
|
|
527
|
-
const { port } = front.address();
|
|
528
|
-
try {
|
|
529
|
-
const res = await fetch(`http://127.0.0.1:${port}/`, {
|
|
530
|
-
method: "HEAD",
|
|
531
|
-
headers: { connection: "close" },
|
|
532
|
-
});
|
|
533
|
-
assert.equal(res.headers.get("content-length"), "12345");
|
|
534
|
-
}
|
|
535
|
-
finally {
|
|
536
|
-
front.closeAllConnections?.();
|
|
537
|
-
await new Promise((r) => front.close(() => r()));
|
|
538
|
-
await new Promise((r) => up.close(() => r()));
|
|
539
|
-
}
|
|
540
|
-
});
|
|
541
|
-
});
|
|
542
|
-
describe("console proxy HEAD handling", () => {
|
|
543
|
-
it("answers HEAD without hanging", async () => {
|
|
544
|
-
// QuestDB replies to HEAD with `405 Transfer-Encoding: chunked` AND a
|
|
545
|
-
// body, which is protocol-illegal. Piping that through left the client
|
|
546
|
-
// waiting for chunks that never legally arrive, so the connection died
|
|
547
|
-
// with an empty reply — every HEAD probe of the console reported it broken
|
|
548
|
-
// while the console itself worked perfectly.
|
|
549
|
-
const badHead = http.createServer((req, res) => {
|
|
550
|
-
res.writeHead(405, {
|
|
551
|
-
"transfer-encoding": "chunked",
|
|
552
|
-
"content-type": "text/plain",
|
|
553
|
-
});
|
|
554
|
-
res.end("Method HEAD not supported");
|
|
555
|
-
});
|
|
556
|
-
await new Promise((r) => badHead.listen(0, "127.0.0.1", r));
|
|
557
|
-
const badPort = badHead.address().port;
|
|
558
|
-
const proxy = createConsoleProxy({
|
|
559
|
-
baseUrl: () => `http://127.0.0.1:${badPort}`,
|
|
560
|
-
debug: () => { },
|
|
561
|
-
mountPath: MOUNT,
|
|
562
|
-
});
|
|
563
|
-
const front = http.createServer((req, res) => proxy(req, res));
|
|
564
|
-
await new Promise((r) => front.listen(0, "127.0.0.1", r));
|
|
565
|
-
const { port } = front.address();
|
|
566
|
-
try {
|
|
567
|
-
const res = await fetch(`http://127.0.0.1:${port}/`, {
|
|
568
|
-
method: "HEAD",
|
|
569
|
-
signal: AbortSignal.timeout(4000),
|
|
570
|
-
});
|
|
571
|
-
assert.equal(res.status, 405);
|
|
572
|
-
assert.equal(await res.text(), "", "a HEAD response carries no body");
|
|
573
|
-
}
|
|
574
|
-
finally {
|
|
575
|
-
await new Promise((r) => front.close(() => r()));
|
|
576
|
-
await new Promise((r) => badHead.close(() => r()));
|
|
577
|
-
}
|
|
578
|
-
});
|
|
579
|
-
});
|
|
580
|
-
describe("console proxy failure handling", () => {
|
|
581
|
-
it("returns 503 when QuestDB is not running", async () => {
|
|
582
|
-
const r = await throughProxy("/", { baseUrl: () => null });
|
|
583
|
-
assert.equal(r.status, 503);
|
|
584
|
-
assert.match(r.text, /not running/i);
|
|
585
|
-
});
|
|
586
|
-
it("returns a readable 502 when the upstream refuses", async () => {
|
|
587
|
-
// Port 1 is reserved and never listening: a stopped container in practice.
|
|
588
|
-
// Bind an ephemeral port and immediately release it: guarantees a closed
|
|
589
|
-
// port on this host, rather than assuming a well-known one (port 1) is
|
|
590
|
-
// free — on a machine where something holds it, the test would silently
|
|
591
|
-
// stop testing refusal.
|
|
592
|
-
const probe = http.createServer();
|
|
593
|
-
await new Promise((r) => probe.listen(0, "127.0.0.1", r));
|
|
594
|
-
const deadPort = probe.address().port;
|
|
595
|
-
await new Promise((r) => probe.close(() => r()));
|
|
596
|
-
const r = await throughProxy("/", {
|
|
597
|
-
baseUrl: () => `http://127.0.0.1:${deadPort}`,
|
|
598
|
-
});
|
|
599
|
-
assert.equal(r.status, 502);
|
|
600
|
-
assert.match(r.text, /not reachable/i);
|
|
601
|
-
assert.doesNotMatch(r.text, /ECONNREFUSED/, "must not leak a raw error");
|
|
602
|
-
});
|
|
603
|
-
});
|
|
604
|
-
describe("console mount gating", () => {
|
|
605
|
-
// Regression: the enableConsole check first sat AROUND the router.use(...)
|
|
606
|
-
// call. registerWithRouter runs at plugin registration, before start() has
|
|
607
|
-
// loaded any config, so currentConfig was still null and a saved
|
|
608
|
-
// `enableConsole: false` was ignored — the console stayed reachable. Caught
|
|
609
|
-
// only by end-to-end testing against a real server; every unit test passed.
|
|
610
|
-
//
|
|
611
|
-
// This models the mount as index.ts now does it: always registered, decided
|
|
612
|
-
// per request against live config.
|
|
613
|
-
function mountedHandler(getConfig) {
|
|
614
|
-
const proxy = createConsoleProxy({
|
|
615
|
-
baseUrl: () => upstreamUrl,
|
|
616
|
-
debug: () => { },
|
|
617
|
-
mountPath: MOUNT,
|
|
618
|
-
});
|
|
619
|
-
return (req, res) => {
|
|
620
|
-
if (getConfig()?.enableConsole === false) {
|
|
621
|
-
// Express would fall through to the 404 handler here.
|
|
622
|
-
res.writeHead(404);
|
|
623
|
-
res.end();
|
|
624
|
-
return;
|
|
625
|
-
}
|
|
626
|
-
proxy(req, res);
|
|
627
|
-
};
|
|
628
|
-
}
|
|
629
|
-
async function callMount(getConfig) {
|
|
630
|
-
const front = http.createServer(mountedHandler(getConfig));
|
|
631
|
-
await new Promise((r) => front.listen(0, "127.0.0.1", r));
|
|
632
|
-
const { port } = front.address();
|
|
633
|
-
try {
|
|
634
|
-
return (await fetch(`http://127.0.0.1:${port}/`)).status;
|
|
635
|
-
}
|
|
636
|
-
finally {
|
|
637
|
-
await new Promise((r) => front.close(() => r()));
|
|
638
|
-
}
|
|
639
|
-
}
|
|
640
|
-
it("serves the console when config has not loaded yet", async () => {
|
|
641
|
-
// null config == registration time. Must not 404: the default is on, and
|
|
642
|
-
// the request will be re-evaluated once config arrives.
|
|
643
|
-
assert.equal(await callMount(() => null), 200);
|
|
644
|
-
});
|
|
645
|
-
it("serves the console when the key is absent", async () => {
|
|
646
|
-
assert.equal(await callMount(() => ({})), 200);
|
|
647
|
-
});
|
|
648
|
-
it("does NOT serve the console when explicitly disabled", async () => {
|
|
649
|
-
assert.equal(await callMount(() => ({ enableConsole: false })), 404);
|
|
650
|
-
});
|
|
651
|
-
it("serves the console when explicitly enabled", async () => {
|
|
652
|
-
assert.equal(await callMount(() => ({ enableConsole: true })), 200);
|
|
653
|
-
});
|
|
654
|
-
});
|
|
655
|
-
describe("console proxy target confinement", () => {
|
|
656
|
-
it("refuses a path that would re-target another origin", async () => {
|
|
657
|
-
// `new URL("//evil.example.com/", base)` resolves to that host — without
|
|
658
|
-
// an origin check the admin-only route becomes an open relay.
|
|
659
|
-
const r = await throughProxy("//example.com/exec");
|
|
660
|
-
assert.equal(r.status, 400);
|
|
661
|
-
assert.equal(r.seen.length, 0, "nothing may reach the upstream");
|
|
662
|
-
});
|
|
663
|
-
});
|
|
664
|
-
//# sourceMappingURL=console-proxy.test.js.map
|