@specific.dev/spectest 0.39.0 → 0.43.0
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/browser.d.ts +21 -8
- package/dist/browser.js +78 -36
- package/dist/components/supabase.d.ts +87 -27
- package/dist/components/supabase.js +352 -69
- package/dist/daemon.d.ts +38 -0
- package/dist/daemon.js +464 -987
- package/dist/harness/build-context.d.ts +82 -0
- package/dist/harness/build-context.js +113 -0
- package/dist/harness/buildkit-progress.d.ts +37 -0
- package/dist/harness/buildkit-progress.js +66 -0
- package/dist/harness/container-run.d.ts +89 -0
- package/dist/harness/container-run.js +118 -0
- package/dist/harness/file-mounts.d.ts +91 -0
- package/dist/harness/file-mounts.js +119 -0
- package/dist/harness/hostmatch.d.ts +65 -0
- package/dist/harness/hostmatch.js +108 -0
- package/dist/harness/http-proxy.d.ts +62 -0
- package/dist/harness/http-proxy.js +104 -0
- package/dist/harness/ingress-table.d.ts +148 -0
- package/dist/harness/ingress-table.js +129 -0
- package/dist/harness/log-delta.d.ts +54 -0
- package/dist/harness/log-delta.js +83 -0
- package/dist/harness/main.d.ts +47 -0
- package/dist/harness/main.js +164 -0
- package/dist/harness/methods.d.ts +54 -0
- package/dist/harness/methods.js +65 -0
- package/dist/harness/names-registry.d.ts +63 -0
- package/dist/harness/names-registry.js +90 -0
- package/dist/harness/protocol.d.ts +88 -0
- package/dist/harness/protocol.js +96 -0
- package/dist/harness/ready-poll.d.ts +47 -0
- package/dist/harness/ready-poll.js +67 -0
- package/dist/harness/service-graph.d.ts +29 -0
- package/dist/harness/service-graph.js +92 -0
- package/dist/harness/volume-paths.d.ts +70 -0
- package/dist/harness/volume-paths.js +81 -0
- package/dist/index.d.ts +58 -16
- package/dist/ingress.d.ts +1 -1
- package/dist/mobile.d.ts +9 -5
- package/dist/mobile.js +7 -6
- package/dist/recorder.d.ts +10 -0
- package/dist/resolver.js +5 -8
- package/dist/vendor/rrweb-plugin-console-record.umd.js +521 -0
- package/dist/vendor/rrweb-record.min.js +5061 -0
- package/package.json +7 -1
- package/src/aws-sigv4.ts +218 -0
- package/src/browser.ts +2095 -0
- package/src/components/aws.ts +554 -0
- package/src/components/email.ts +398 -0
- package/src/components/expo.ts +167 -0
- package/src/components/index.ts +81 -0
- package/src/components/k3s.ts +2061 -0
- package/src/components/postgres.ts +132 -0
- package/src/components/replayFake.ts +1015 -0
- package/src/components/s3.ts +132 -0
- package/src/components/supabase.ts +1699 -0
- package/src/daemon.ts +5537 -0
- package/src/harness/build-context.test.ts +0 -0
- package/src/harness/build-context.ts +146 -0
- package/src/harness/buildkit-progress.test.ts +98 -0
- package/src/harness/buildkit-progress.ts +74 -0
- package/src/harness/container-run.test.ts +209 -0
- package/src/harness/container-run.ts +158 -0
- package/src/harness/file-mounts.test.ts +185 -0
- package/src/harness/file-mounts.ts +145 -0
- package/src/harness/hostmatch.test.ts +148 -0
- package/src/harness/hostmatch.ts +109 -0
- package/src/harness/http-proxy.test.ts +156 -0
- package/src/harness/http-proxy.ts +119 -0
- package/src/harness/ingress-rebind.test.ts +125 -0
- package/src/harness/ingress-table.test.ts +172 -0
- package/src/harness/ingress-table.ts +186 -0
- package/src/harness/log-delta.test.ts +125 -0
- package/src/harness/log-delta.ts +100 -0
- package/src/harness/main.test.ts +211 -0
- package/src/harness/main.ts +196 -0
- package/src/harness/methods.test.ts +63 -0
- package/src/harness/methods.ts +92 -0
- package/src/harness/names-registry.test.ts +137 -0
- package/src/harness/names-registry.ts +108 -0
- package/src/harness/protocol.test.ts +148 -0
- package/src/harness/protocol.ts +163 -0
- package/src/harness/ready-poll.test.ts +172 -0
- package/src/harness/ready-poll.ts +93 -0
- package/src/harness/service-graph.test.ts +97 -0
- package/src/harness/service-graph.ts +97 -0
- package/src/harness/volume-paths.test.ts +102 -0
- package/src/harness/volume-paths.ts +112 -0
- package/src/ids.ts +89 -0
- package/src/index.ts +2767 -0
- package/src/ingress.ts +305 -0
- package/src/inspect.ts +739 -0
- package/src/locator.ts +716 -0
- package/src/mobile.ts +138 -0
- package/src/record-secrets.ts +41 -0
- package/src/recorder.ts +856 -0
- package/src/redis.ts +202 -0
- package/src/replay-bundle.ts +108 -0
- package/src/resolver.ts +348 -0
- package/src/s3.ts +333 -0
- package/src/sql.ts +243 -0
- package/src/terminal.ts +740 -0
- package/src/url-match.ts +67 -0
- package/src/vendor/rrweb-plugin-console-record.umd.js +521 -0
- package/src/vendor/rrweb-record.min.js +5061 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
augmentCorsResponse,
|
|
5
|
+
corsPreflightResponse,
|
|
6
|
+
forwardableHeaders,
|
|
7
|
+
isCorsPreflight,
|
|
8
|
+
isHopByHop,
|
|
9
|
+
} from "./http-proxy";
|
|
10
|
+
|
|
11
|
+
describe("hop-by-hop headers", () => {
|
|
12
|
+
test("connection-scoped headers are recognised, case-insensitively", () => {
|
|
13
|
+
expect(isHopByHop("Connection")).toBe(true);
|
|
14
|
+
expect(isHopByHop("TRANSFER-ENCODING")).toBe(true);
|
|
15
|
+
expect(isHopByHop("upgrade")).toBe(true);
|
|
16
|
+
expect(isHopByHop("host")).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("ordinary headers are forwardable", () => {
|
|
20
|
+
expect(isHopByHop("authorization")).toBe(false);
|
|
21
|
+
expect(isHopByHop("content-type")).toBe(false);
|
|
22
|
+
expect(isHopByHop("cookie")).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
/** Forwarding transfer-encoding from a re-encoded body produces a
|
|
26
|
+
* response the client cannot frame; forwarding host would hide which
|
|
27
|
+
* service the upstream actually is. */
|
|
28
|
+
test("forwardableHeaders drops them and keeps the rest", () => {
|
|
29
|
+
const h = new Headers({
|
|
30
|
+
host: "app.test",
|
|
31
|
+
connection: "keep-alive",
|
|
32
|
+
"transfer-encoding": "chunked",
|
|
33
|
+
authorization: "Bearer x",
|
|
34
|
+
"content-type": "application/json",
|
|
35
|
+
});
|
|
36
|
+
const out = forwardableHeaders(h);
|
|
37
|
+
expect(out.get("host")).toBeNull();
|
|
38
|
+
expect(out.get("connection")).toBeNull();
|
|
39
|
+
expect(out.get("transfer-encoding")).toBeNull();
|
|
40
|
+
expect(out.get("authorization")).toBe("Bearer x");
|
|
41
|
+
expect(out.get("content-type")).toBe("application/json");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("repeated headers survive as repeats", () => {
|
|
45
|
+
const h = new Headers();
|
|
46
|
+
h.append("set-cookie", "a=1");
|
|
47
|
+
h.append("set-cookie", "b=2");
|
|
48
|
+
expect(forwardableHeaders(h).getAll("set-cookie")).toEqual(["a=1", "b=2"]);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("preflight detection", () => {
|
|
53
|
+
const req = (method: string, headers: Record<string, string>) =>
|
|
54
|
+
new Request("http://app.test/", { method, headers });
|
|
55
|
+
|
|
56
|
+
test("OPTIONS with Origin and Access-Control-Request-Method is a preflight", () => {
|
|
57
|
+
expect(
|
|
58
|
+
isCorsPreflight(
|
|
59
|
+
req("OPTIONS", { origin: "http://a.test", "access-control-request-method": "POST" }),
|
|
60
|
+
),
|
|
61
|
+
).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
/** A bare OPTIONS is a real request — an API being asked what it
|
|
65
|
+
* supports. Answering it here would hide the app's own response. */
|
|
66
|
+
test("OPTIONS without the CORS headers is NOT a preflight", () => {
|
|
67
|
+
expect(isCorsPreflight(req("OPTIONS", {}))).toBe(false);
|
|
68
|
+
expect(isCorsPreflight(req("OPTIONS", { origin: "http://a.test" }))).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("a non-OPTIONS request is never a preflight", () => {
|
|
72
|
+
expect(
|
|
73
|
+
isCorsPreflight(
|
|
74
|
+
req("POST", { origin: "http://a.test", "access-control-request-method": "POST" }),
|
|
75
|
+
),
|
|
76
|
+
).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("preflight response", () => {
|
|
81
|
+
const preflight = (headers: Record<string, string>) =>
|
|
82
|
+
corsPreflightResponse(new Request("http://app.test/", { method: "OPTIONS", headers }));
|
|
83
|
+
|
|
84
|
+
/** `Allow-Origin: *` with `Allow-Credentials: true` is a spec violation
|
|
85
|
+
* browsers reject — a wildcard would break exactly the credentialed
|
|
86
|
+
* requests this exists to permit. */
|
|
87
|
+
test("echoes the specific origin, never a wildcard, alongside credentials", () => {
|
|
88
|
+
const res = preflight({ origin: "http://a.test", "access-control-request-method": "POST" });
|
|
89
|
+
expect(res.headers.get("access-control-allow-origin")).toBe("http://a.test");
|
|
90
|
+
expect(res.headers.get("access-control-allow-credentials")).toBe("true");
|
|
91
|
+
expect(res.headers.get("access-control-allow-origin")).not.toBe("*");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("echoes the requested method and headers", () => {
|
|
95
|
+
const res = preflight({
|
|
96
|
+
origin: "http://a.test",
|
|
97
|
+
"access-control-request-method": "PATCH",
|
|
98
|
+
"access-control-request-headers": "x-custom, authorization",
|
|
99
|
+
});
|
|
100
|
+
expect(res.headers.get("access-control-allow-methods")).toBe("PATCH");
|
|
101
|
+
expect(res.headers.get("access-control-allow-headers")).toBe("x-custom, authorization");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("falls back to a permissive set when nothing specific was asked", () => {
|
|
105
|
+
const res = preflight({ origin: "http://a.test", "access-control-request-method": "" });
|
|
106
|
+
expect(res.headers.get("access-control-allow-methods")).toContain("POST");
|
|
107
|
+
expect(res.headers.get("access-control-allow-headers")).toBe("*");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("is a 204 with no body", async () => {
|
|
111
|
+
const res = preflight({ origin: "http://a.test", "access-control-request-method": "GET" });
|
|
112
|
+
expect(res.status).toBe(204);
|
|
113
|
+
expect(await res.text()).toBe("");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
/** Without Vary, a cache can serve one origin's preflight to another. */
|
|
117
|
+
test("varies on origin and requested headers", () => {
|
|
118
|
+
const res = preflight({ origin: "http://a.test", "access-control-request-method": "GET" });
|
|
119
|
+
const vary = res.headers.get("vary") ?? "";
|
|
120
|
+
expect(vary.toLowerCase()).toContain("origin");
|
|
121
|
+
expect(vary.toLowerCase()).toContain("access-control-request-headers");
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe("augmenting a proxied response", () => {
|
|
126
|
+
const withOrigin = new Request("http://app.test/", { headers: { origin: "http://a.test" } });
|
|
127
|
+
|
|
128
|
+
test("adds CORS headers when the response has none", () => {
|
|
129
|
+
const out = augmentCorsResponse(withOrigin, new Response("hi"));
|
|
130
|
+
expect(out.headers.get("access-control-allow-origin")).toBe("http://a.test");
|
|
131
|
+
expect(out.headers.get("access-control-allow-credentials")).toBe("true");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
/** An app that sets its own policy has an opinion — possibly a
|
|
135
|
+
* restrictive one a test is asserting. Overriding it would make the
|
|
136
|
+
* environment lie about the app. */
|
|
137
|
+
test("never overrides the upstream's own policy", () => {
|
|
138
|
+
const res = new Response("hi", {
|
|
139
|
+
headers: { "access-control-allow-origin": "https://only-this.test" },
|
|
140
|
+
});
|
|
141
|
+
const out = augmentCorsResponse(withOrigin, res);
|
|
142
|
+
expect(out.headers.get("access-control-allow-origin")).toBe("https://only-this.test");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("a request with no Origin is left alone", () => {
|
|
146
|
+
const out = augmentCorsResponse(new Request("http://app.test/"), new Response("hi"));
|
|
147
|
+
expect(out.headers.get("access-control-allow-origin")).toBeNull();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("an immutable-header response is returned unchanged rather than throwing", () => {
|
|
151
|
+
// A redirect response has guarded headers in some runtimes; the point
|
|
152
|
+
// is that a header failure must not fail the request.
|
|
153
|
+
const res = Response.redirect("http://elsewhere.test/", 302);
|
|
154
|
+
expect(() => augmentCorsResponse(withOrigin, res)).not.toThrow();
|
|
155
|
+
});
|
|
156
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP semantics the ingress proxy has to get right: which headers must
|
|
3
|
+
* not be forwarded, and how CORS is answered.
|
|
4
|
+
*
|
|
5
|
+
* Ported out of `daemon.ts`. Pure request→response logic, so the rules
|
|
6
|
+
* below are now assertable rather than inferred from a header list.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Headers that describe **this** connection rather than the message, and
|
|
11
|
+
* so must never be copied to the next hop (RFC 9110 §7.6.1).
|
|
12
|
+
*
|
|
13
|
+
* Forwarding `transfer-encoding` or `content-length` from a re-encoded
|
|
14
|
+
* body is the classic way to produce a response the client cannot frame;
|
|
15
|
+
* forwarding `connection` leaks our keep-alive intent to the upstream.
|
|
16
|
+
* `host` is here because the proxy sets its own — the upstream must see
|
|
17
|
+
* the service it actually is, not the public name the client asked for.
|
|
18
|
+
*/
|
|
19
|
+
export const HOP_BY_HOP_HEADERS: ReadonlySet<string> = new Set([
|
|
20
|
+
"connection",
|
|
21
|
+
"keep-alive",
|
|
22
|
+
"proxy-authenticate",
|
|
23
|
+
"proxy-authorization",
|
|
24
|
+
"te",
|
|
25
|
+
"trailers",
|
|
26
|
+
"transfer-encoding",
|
|
27
|
+
"upgrade",
|
|
28
|
+
"host",
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
export function isHopByHop(name: string): boolean {
|
|
32
|
+
return HOP_BY_HOP_HEADERS.has(name.toLowerCase());
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Copy headers, dropping the ones that belong to this connection. */
|
|
36
|
+
export function forwardableHeaders(headers: Headers): Headers {
|
|
37
|
+
const out = new Headers();
|
|
38
|
+
headers.forEach((value, key) => {
|
|
39
|
+
if (!isHopByHop(key)) out.append(key, value);
|
|
40
|
+
});
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A CORS preflight is an `OPTIONS` carrying both `Origin` and
|
|
46
|
+
* `Access-Control-Request-Method`. An `OPTIONS` without them is a normal
|
|
47
|
+
* request (an API asking what it supports) and must be proxied, not
|
|
48
|
+
* answered here.
|
|
49
|
+
*/
|
|
50
|
+
export function isCorsPreflight(req: {
|
|
51
|
+
method: string;
|
|
52
|
+
headers: { has(n: string): boolean };
|
|
53
|
+
}): boolean {
|
|
54
|
+
return (
|
|
55
|
+
req.method === "OPTIONS" &&
|
|
56
|
+
req.headers.has("origin") &&
|
|
57
|
+
req.headers.has("access-control-request-method")
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Answer a preflight permissively — the environment is hermetic, so
|
|
63
|
+
* there is nothing here to protect from itself, and a test failing on
|
|
64
|
+
* CORS teaches nothing about the app.
|
|
65
|
+
*
|
|
66
|
+
* The one rule that is not a matter of taste: the **specific** origin is
|
|
67
|
+
* echoed rather than `*`. `Allow-Origin: *` together with
|
|
68
|
+
* `Allow-Credentials: true` is a spec violation that browsers reject
|
|
69
|
+
* outright, so a wildcard would break exactly the credentialed requests
|
|
70
|
+
* this is meant to permit.
|
|
71
|
+
*/
|
|
72
|
+
export function corsPreflightResponse(req: {
|
|
73
|
+
headers: { get(n: string): string | null };
|
|
74
|
+
}): Response {
|
|
75
|
+
const origin = req.headers.get("origin") ?? "*";
|
|
76
|
+
const reqHeaders = req.headers.get("access-control-request-headers");
|
|
77
|
+
const reqMethod = req.headers.get("access-control-request-method");
|
|
78
|
+
const headers = new Headers();
|
|
79
|
+
headers.set("access-control-allow-origin", origin);
|
|
80
|
+
headers.set("access-control-allow-credentials", "true");
|
|
81
|
+
headers.set(
|
|
82
|
+
"access-control-allow-methods",
|
|
83
|
+
reqMethod && reqMethod.length > 0 ? reqMethod : "GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS",
|
|
84
|
+
);
|
|
85
|
+
headers.set("access-control-allow-headers", reqHeaders && reqHeaders.length > 0 ? reqHeaders : "*");
|
|
86
|
+
headers.set("access-control-max-age", "600");
|
|
87
|
+
// The answer depends on what was asked — say so, or a cache will serve
|
|
88
|
+
// one origin's preflight to another.
|
|
89
|
+
headers.append("vary", "Origin");
|
|
90
|
+
headers.append("vary", "Access-Control-Request-Headers");
|
|
91
|
+
return new Response(null, { status: 204, headers });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Add CORS headers to a proxied response, without overriding the
|
|
96
|
+
* upstream's own.
|
|
97
|
+
*
|
|
98
|
+
* An app that already sets `Access-Control-Allow-Origin` has an opinion —
|
|
99
|
+
* possibly a deliberately restrictive one that a test is checking — and
|
|
100
|
+
* replacing it would make the environment lie about the app's behaviour.
|
|
101
|
+
*/
|
|
102
|
+
export function augmentCorsResponse(
|
|
103
|
+
req: { headers: { get(n: string): string | null } },
|
|
104
|
+
res: Response,
|
|
105
|
+
): Response {
|
|
106
|
+
const origin = req.headers.get("origin");
|
|
107
|
+
if (!origin) return res;
|
|
108
|
+
if (res.headers.has("access-control-allow-origin")) return res;
|
|
109
|
+
try {
|
|
110
|
+
res.headers.set("access-control-allow-origin", origin);
|
|
111
|
+
res.headers.set("access-control-allow-credentials", "true");
|
|
112
|
+
res.headers.append("vary", "Origin");
|
|
113
|
+
} catch {
|
|
114
|
+
// Some responses carry guarded/immutable headers — a 101 upgrade stub,
|
|
115
|
+
// for instance. Leave those exactly as they are rather than failing the
|
|
116
|
+
// request over a cosmetic header.
|
|
117
|
+
}
|
|
118
|
+
return res;
|
|
119
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The :443 rebind must not be collateral damage for traffic already on the
|
|
3
|
+
* listener.
|
|
4
|
+
*
|
|
5
|
+
* Adding an SNI certificate needs a second `Bun.serve` — `reload()` accepts
|
|
6
|
+
* a new `tls` option and goes on serving the old certificates. The question
|
|
7
|
+
* this file pins down is the *order*: bind the replacement first and drain
|
|
8
|
+
* the old one, rather than stopping the old one and leaving a gap.
|
|
9
|
+
*
|
|
10
|
+
* These drive real sockets rather than a model of them, because the bug was
|
|
11
|
+
* in socket behaviour: a model would have agreed with the broken code.
|
|
12
|
+
* They use plain HTTP — the TLS part is Bun's SNI table, measured
|
|
13
|
+
* separately; what is asserted here is that a listener swap costs no
|
|
14
|
+
* connection, which is the part that was wrong.
|
|
15
|
+
*/
|
|
16
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
17
|
+
|
|
18
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
19
|
+
const servers: any[] = [];
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
for (const s of servers.splice(0)) {
|
|
22
|
+
try {
|
|
23
|
+
s.stop(true);
|
|
24
|
+
} catch {
|
|
25
|
+
/* already stopped */
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
/** A listener shaped like the ingress ones: reusePort, no idle timeout. */
|
|
31
|
+
function bind(port: number, label: string, delayMs = 0) {
|
|
32
|
+
const server = Bun.serve({
|
|
33
|
+
port,
|
|
34
|
+
hostname: "127.0.0.1",
|
|
35
|
+
reusePort: true,
|
|
36
|
+
idleTimeout: 0,
|
|
37
|
+
async fetch() {
|
|
38
|
+
if (delayMs) await Bun.sleep(delayMs);
|
|
39
|
+
return new Response(label);
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
servers.push(server);
|
|
43
|
+
return server;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Can a fresh connection be served right now? */
|
|
47
|
+
async function reachable(port: number): Promise<boolean> {
|
|
48
|
+
try {
|
|
49
|
+
await fetch(`http://127.0.0.1:${port}/`);
|
|
50
|
+
return true;
|
|
51
|
+
} catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe("swapping a listener", () => {
|
|
57
|
+
/** SO_REUSEPORT is what makes an overlap possible at all. Without it on
|
|
58
|
+
* BOTH sockets the second bind fails outright, so this is a precondition
|
|
59
|
+
* of the whole approach rather than a nicety. */
|
|
60
|
+
test("a replacement can bind while the original is still listening", () => {
|
|
61
|
+
const port = 19100;
|
|
62
|
+
bind(port, "old");
|
|
63
|
+
expect(() => bind(port, "new")).not.toThrow();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("without reusePort the second bind is refused", () => {
|
|
67
|
+
const port = 19101;
|
|
68
|
+
const first = Bun.serve({ port, hostname: "127.0.0.1", fetch: () => new Response("a") });
|
|
69
|
+
servers.push(first);
|
|
70
|
+
expect(() =>
|
|
71
|
+
Bun.serve({ port, hostname: "127.0.0.1", fetch: () => new Response("b") }),
|
|
72
|
+
).toThrow();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The regression. A request already in flight when the swap happens must
|
|
77
|
+
* still get its response.
|
|
78
|
+
*
|
|
79
|
+
* With the old ordering — `old.stop(true)` before serving the replacement
|
|
80
|
+
* — this request is force-closed mid-flight and the fetch rejects. That is
|
|
81
|
+
* exactly what the app under test saw: `SocketError: other side closed`,
|
|
82
|
+
* on an upload that had nothing to do with the service being provisioned.
|
|
83
|
+
*/
|
|
84
|
+
test("a request in flight survives the swap", async () => {
|
|
85
|
+
const port = 19102;
|
|
86
|
+
const old = bind(port, "old", 300);
|
|
87
|
+
const inFlight = fetch(`http://127.0.0.1:${port}/`);
|
|
88
|
+
await Bun.sleep(50); // let it be genuinely in flight
|
|
89
|
+
|
|
90
|
+
bind(port, "new"); // replacement first…
|
|
91
|
+
old.stop(false); // …then drain the original
|
|
92
|
+
|
|
93
|
+
await expect(inFlight.then((r) => r.text())).resolves.toBe("old");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The other half of the same bug: stopping first leaves a window where
|
|
98
|
+
* nothing is bound, and a connection arriving then gets ECONNREFUSED.
|
|
99
|
+
* Binding first means the port is continuously served.
|
|
100
|
+
*/
|
|
101
|
+
test("the port is never refused across the swap", async () => {
|
|
102
|
+
const port = 19103;
|
|
103
|
+
const old = bind(port, "old");
|
|
104
|
+
expect(await reachable(port)).toBe(true);
|
|
105
|
+
|
|
106
|
+
const replacement = bind(port, "new");
|
|
107
|
+
expect(await reachable(port)).toBe(true);
|
|
108
|
+
|
|
109
|
+
old.stop(false);
|
|
110
|
+
expect(await reachable(port)).toBe(true);
|
|
111
|
+
|
|
112
|
+
replacement.stop(false);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
/** After the original has drained, the replacement is the only thing
|
|
116
|
+
* serving — otherwise a stale route table would keep answering. */
|
|
117
|
+
test("the replacement serves once the original is gone", async () => {
|
|
118
|
+
const port = 19104;
|
|
119
|
+
const old = bind(port, "old");
|
|
120
|
+
bind(port, "new");
|
|
121
|
+
old.stop(true);
|
|
122
|
+
const body = await fetch(`http://127.0.0.1:${port}/`).then((r) => r.text());
|
|
123
|
+
expect(body).toBe("new");
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { matchRoute } from "./hostmatch";
|
|
4
|
+
import {
|
|
5
|
+
INGRESS_HTTPS_PORT,
|
|
6
|
+
INGRESS_HTTP_PORT,
|
|
7
|
+
bindRoute,
|
|
8
|
+
certEntries,
|
|
9
|
+
clearTables,
|
|
10
|
+
emptyTables,
|
|
11
|
+
planBind,
|
|
12
|
+
registryTarget,
|
|
13
|
+
routesFor,
|
|
14
|
+
unbindRoute,
|
|
15
|
+
type Route,
|
|
16
|
+
} from "./ingress-table";
|
|
17
|
+
|
|
18
|
+
const leaf = (tag: string) => ({ cert: `cert-${tag}`, key: `key-${tag}` });
|
|
19
|
+
const proxy = (service: string, port = 8080): Route => ({ kind: "proxy", service, port });
|
|
20
|
+
|
|
21
|
+
describe("route tables", () => {
|
|
22
|
+
/** The one that matters. Every listener closes over the Map it was bound
|
|
23
|
+
* with, so a caller that receives a *new* Map on the second call would
|
|
24
|
+
* add routes nobody is serving — and nothing would report it, because
|
|
25
|
+
* binding a route has no observable result of its own. */
|
|
26
|
+
test("returns the same table object every time, so listeners stay attached", () => {
|
|
27
|
+
const t = emptyTables();
|
|
28
|
+
const first = routesFor(t, INGRESS_HTTPS_PORT);
|
|
29
|
+
first.set("a.test", proxy("a"));
|
|
30
|
+
const second = routesFor(t, INGRESS_HTTPS_PORT);
|
|
31
|
+
expect(second).toBe(first);
|
|
32
|
+
expect(second.get("a.test")).toEqual(proxy("a"));
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("a route added after a listener captured the table is visible to it", () => {
|
|
36
|
+
const t = emptyTables();
|
|
37
|
+
// Stand in for a listener: capture the table once, as Bun.serve's
|
|
38
|
+
// fetch closure does, then look routes up through the capture.
|
|
39
|
+
const captured = routesFor(t, INGRESS_HTTPS_PORT);
|
|
40
|
+
bindRoute(t, "later.test", proxy("later"));
|
|
41
|
+
expect(matchRoute(captured, "later.test")).toEqual(proxy("later"));
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("different ports get different tables", () => {
|
|
45
|
+
const t = emptyTables();
|
|
46
|
+
expect(routesFor(t, 80)).not.toBe(routesFor(t, 443));
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("binding serves both schemes, matching boot tls", () => {
|
|
50
|
+
const t = emptyTables();
|
|
51
|
+
bindRoute(t, "app.test", proxy("app", 3000));
|
|
52
|
+
expect(routesFor(t, INGRESS_HTTP_PORT).get("app.test")).toEqual(proxy("app", 3000));
|
|
53
|
+
expect(routesFor(t, INGRESS_HTTPS_PORT).get("app.test")).toEqual(proxy("app", 3000));
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("unbinding removes both schemes", () => {
|
|
57
|
+
const t = emptyTables();
|
|
58
|
+
bindRoute(t, "app.test", proxy("app"));
|
|
59
|
+
unbindRoute(t, "app.test");
|
|
60
|
+
expect(routesFor(t, INGRESS_HTTP_PORT).has("app.test")).toBe(false);
|
|
61
|
+
expect(routesFor(t, INGRESS_HTTPS_PORT).has("app.test")).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("unbinding a hostname that was never bound is a no-op", () => {
|
|
65
|
+
const t = emptyTables();
|
|
66
|
+
expect(() => unbindRoute(t, "ghost.test")).not.toThrow();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
/** Removing the cert would cost a :443 rebind at teardown, for nothing:
|
|
70
|
+
* a cert with no route behind it can't serve anything. */
|
|
71
|
+
test("unbinding leaves the certificate in place", () => {
|
|
72
|
+
const t = emptyTables();
|
|
73
|
+
t.certByHost.set("app.test", leaf("app"));
|
|
74
|
+
bindRoute(t, "app.test", proxy("app"));
|
|
75
|
+
unbindRoute(t, "app.test");
|
|
76
|
+
expect(t.certByHost.has("app.test")).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("planBind", () => {
|
|
81
|
+
test("a brand-new hostname needs a cert and a rebind", () => {
|
|
82
|
+
const t = emptyTables();
|
|
83
|
+
expect(planBind(t, "new.test", { httpsListening: true })).toEqual({
|
|
84
|
+
needsCert: true,
|
|
85
|
+
needsHttpsRebind: true,
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("the first bind rebinds even when a cert already covers the name", () => {
|
|
90
|
+
const t = emptyTables();
|
|
91
|
+
t.certByHost.set("app.test", leaf("app"));
|
|
92
|
+
// Nothing is listening yet, so :443 has to be served regardless.
|
|
93
|
+
expect(planBind(t, "app.test", { httpsListening: false })).toEqual({
|
|
94
|
+
needsCert: false,
|
|
95
|
+
needsHttpsRebind: true,
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("re-binding an exact hostname already covered costs nothing", () => {
|
|
100
|
+
const t = emptyTables();
|
|
101
|
+
t.certByHost.set("app.test", leaf("app"));
|
|
102
|
+
expect(planBind(t, "app.test", { httpsListening: true })).toEqual({
|
|
103
|
+
needsCert: false,
|
|
104
|
+
needsHttpsRebind: false,
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
/** The case that makes runtime provisioning cheap: a component claims
|
|
109
|
+
* `*.db.test` up front, then a fake mints endpoints under it in a loop.
|
|
110
|
+
* Rebinding per iteration would restart the listener each time. */
|
|
111
|
+
test("a hostname under an existing wildcard cert needs neither", () => {
|
|
112
|
+
const t = emptyTables();
|
|
113
|
+
t.certByHost.set("*.db.test", leaf("wild"));
|
|
114
|
+
expect(planBind(t, "tenant-1.db.test", { httpsListening: true })).toEqual({
|
|
115
|
+
needsCert: false,
|
|
116
|
+
needsHttpsRebind: false,
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
/** A wildcard cert covers exactly one label (RFC 6125), so a deeper name
|
|
121
|
+
* needs its own leaf. Reporting it covered would hand the client a
|
|
122
|
+
* certificate it rejects, and the failure would surface at request time
|
|
123
|
+
* far from here. */
|
|
124
|
+
test("a deeper name under a wildcard cert still needs its own", () => {
|
|
125
|
+
const t = emptyTables();
|
|
126
|
+
t.certByHost.set("*.db.test", leaf("wild"));
|
|
127
|
+
expect(planBind(t, "a.b.db.test", { httpsListening: true }).needsCert).toBe(true);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("certEntries", () => {
|
|
132
|
+
test("renders the SNI table for Bun.serve", () => {
|
|
133
|
+
const t = emptyTables();
|
|
134
|
+
t.certByHost.set("a.test", leaf("a"));
|
|
135
|
+
t.certByHost.set("*.b.test", leaf("b"));
|
|
136
|
+
expect(certEntries(t)).toEqual([
|
|
137
|
+
{ serverName: "a.test", cert: "cert-a", key: "key-a" },
|
|
138
|
+
{ serverName: "*.b.test", cert: "cert-b", key: "key-b" },
|
|
139
|
+
]);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("an empty table yields no entries rather than throwing", () => {
|
|
143
|
+
expect(certEntries(emptyTables())).toEqual([]);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe("clearTables", () => {
|
|
148
|
+
test("drops routes and certs, so a new project starts clean", () => {
|
|
149
|
+
const t = emptyTables();
|
|
150
|
+
bindRoute(t, "old.test", proxy("old"));
|
|
151
|
+
t.certByHost.set("old.test", leaf("old"));
|
|
152
|
+
clearTables(t);
|
|
153
|
+
expect(t.routesByPort.size).toBe(0);
|
|
154
|
+
expect(t.certByHost.size).toBe(0);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe("registryTarget", () => {
|
|
159
|
+
test("a wildcard goes to the suffix table, which is its only home", () => {
|
|
160
|
+
expect(registryTarget("*.example.com")).toEqual({
|
|
161
|
+
wildcard: true,
|
|
162
|
+
suffix: ".example.com",
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("an exact name goes to the exact table, so it keeps winning", () => {
|
|
167
|
+
expect(registryTarget("api.example.com")).toEqual({
|
|
168
|
+
wildcard: false,
|
|
169
|
+
host: "api.example.com",
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
});
|