Skip to content

Commit af8936c

Browse files
committed
sure
1 parent 53b9a10 commit af8936c

6 files changed

Lines changed: 1684 additions & 982 deletions

File tree

sdk/ts/__tests__/tls.test.ts

Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
/**
2+
* mTLS end-to-end test.
3+
*
4+
* Spins up a real beyond-objects binary with TLS enabled, generates ephemeral
5+
* CA / server / client certificates via @peculiar/x509 + WebCrypto, and
6+
* verifies that:
7+
* 1. A client configured with `tls` options can connect and make real requests.
8+
* 2. A client without TLS options cannot connect (connection error / rejection).
9+
*/
10+
11+
// @peculiar/x509 depends on tsyringe which requires Reflect metadata.
12+
import "reflect-metadata";
13+
14+
import {
15+
BasicConstraintsExtension,
16+
ExtendedKeyUsageExtension,
17+
SubjectAlternativeNameExtension,
18+
X509CertificateGenerator,
19+
} from "@peculiar/x509";
20+
import { type ChildProcess, spawn } from "node:child_process";
21+
import { randomUUID } from "node:crypto";
22+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
23+
import { createServer } from "node:net";
24+
import { tmpdir } from "node:os";
25+
import { join, resolve } from "node:path";
26+
import { fileURLToPath } from "node:url";
27+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
28+
import { createObjectsClient } from "../src/index.js";
29+
30+
const __dirname = fileURLToPath(new URL(".", import.meta.url));
31+
32+
// ── Certificate generation ───────────────────────────────────────────────────
33+
34+
interface CertBundle {
35+
caPem: string;
36+
serverCertPem: string;
37+
serverKeyPem: string;
38+
clientCertPem: string;
39+
clientKeyPem: string;
40+
}
41+
42+
function toPem(label: string, der: ArrayBuffer): string {
43+
const b64 = Buffer.from(der).toString("base64");
44+
return `-----BEGIN ${label}-----\n${b64.match(/.{1,64}/g)!.join("\n")}\n-----END ${label}-----\n`;
45+
}
46+
47+
async function generateTestCerts(): Promise<CertBundle> {
48+
const alg = { name: "ECDSA", namedCurve: "P-256" };
49+
const signingAlgorithm = { name: "ECDSA", hash: "SHA-256" } as EcdsaParams;
50+
const notBefore = new Date("2020-01-01");
51+
const notAfter = new Date("2099-12-31");
52+
53+
// CA
54+
const caKeys = await crypto.subtle.generateKey(alg, true, ["sign", "verify"]);
55+
const caCert = await X509CertificateGenerator.createSelfSigned({
56+
keys: caKeys,
57+
name: "CN=Test CA",
58+
notBefore,
59+
notAfter,
60+
signingAlgorithm,
61+
extensions: [new BasicConstraintsExtension(true, undefined, true)],
62+
});
63+
64+
// Server cert — localhost DNS + 127.0.0.1 IP SAN, serverAuth + clientAuth EKU
65+
const serverKeys = await crypto.subtle.generateKey(alg, true, [
66+
"sign",
67+
"verify",
68+
]);
69+
const serverCert = await X509CertificateGenerator.create({
70+
subject: "CN=localhost",
71+
issuer: caCert.subject,
72+
publicKey: serverKeys.publicKey,
73+
signingKey: caKeys.privateKey,
74+
notBefore,
75+
notAfter,
76+
signingAlgorithm,
77+
extensions: [
78+
new SubjectAlternativeNameExtension([
79+
{ type: "dns", value: "localhost" },
80+
{ type: "ip", value: "127.0.0.1" },
81+
]),
82+
new ExtendedKeyUsageExtension([
83+
"1.3.6.1.5.5.7.3.1", // serverAuth
84+
"1.3.6.1.5.5.7.3.2", // clientAuth
85+
]),
86+
],
87+
});
88+
89+
// Client cert — clientAuth EKU only
90+
const clientKeys = await crypto.subtle.generateKey(alg, true, [
91+
"sign",
92+
"verify",
93+
]);
94+
const clientCert = await X509CertificateGenerator.create({
95+
subject: "CN=client",
96+
issuer: caCert.subject,
97+
publicKey: clientKeys.publicKey,
98+
signingKey: caKeys.privateKey,
99+
notBefore,
100+
notAfter,
101+
signingAlgorithm,
102+
extensions: [
103+
new ExtendedKeyUsageExtension([
104+
"1.3.6.1.5.5.7.3.2", // clientAuth
105+
]),
106+
],
107+
});
108+
109+
return {
110+
caPem: caCert.toString("pem"),
111+
serverCertPem: serverCert.toString("pem"),
112+
serverKeyPem: toPem(
113+
"PRIVATE KEY",
114+
await crypto.subtle.exportKey("pkcs8", serverKeys.privateKey),
115+
),
116+
clientCertPem: clientCert.toString("pem"),
117+
clientKeyPem: toPem(
118+
"PRIVATE KEY",
119+
await crypto.subtle.exportKey("pkcs8", clientKeys.privateKey),
120+
),
121+
};
122+
}
123+
124+
// ── Server lifecycle ─────────────────────────────────────────────────────────
125+
126+
function findFreePort(): Promise<number> {
127+
return new Promise((res, rej) => {
128+
const srv = createServer();
129+
srv.listen(0, "127.0.0.1", () => {
130+
const { port } = srv.address() as { port: number };
131+
srv.close((err) => (err ? rej(err) : res(port)));
132+
});
133+
srv.on("error", rej);
134+
});
135+
}
136+
137+
async function waitForHealthy(
138+
url: string,
139+
caPem: string,
140+
clientCertPem: string,
141+
clientKeyPem: string,
142+
timeoutMs = 30_000,
143+
): Promise<void> {
144+
const { request } = await import("node:https");
145+
146+
function getOk(target: string): Promise<boolean> {
147+
return new Promise((resolve) => {
148+
try {
149+
const req = request(
150+
target,
151+
{
152+
ca: caPem,
153+
cert: clientCertPem,
154+
key: clientKeyPem,
155+
rejectUnauthorized: true,
156+
method: "GET",
157+
},
158+
(res) => {
159+
res.resume();
160+
resolve((res.statusCode ?? 0) >= 200 && (res.statusCode ?? 0) < 300);
161+
},
162+
);
163+
req.on("error", () => resolve(false));
164+
req.end();
165+
} catch {
166+
resolve(false);
167+
}
168+
});
169+
}
170+
171+
const deadline = Date.now() + timeoutMs;
172+
while (Date.now() < deadline) {
173+
if (await getOk(`${url}/livez`)) return;
174+
await new Promise<void>((r) => setTimeout(r, 150));
175+
}
176+
throw new Error(
177+
`beyond-objects TLS server did not become healthy at ${url} within ${timeoutMs}ms`,
178+
);
179+
}
180+
181+
// ── Test state ───────────────────────────────────────────────────────────────
182+
183+
let serverProcess: ChildProcess | undefined;
184+
let tempDir: string | undefined;
185+
let serverUrl: string;
186+
let rootToken: string;
187+
let certs: CertBundle;
188+
189+
beforeAll(async () => {
190+
certs = await generateTestCerts();
191+
192+
tempDir = mkdtempSync(join(tmpdir(), "beyond-objects-tls-test-"));
193+
const dataDir = join(tempDir, "data");
194+
const indexDir = join(tempDir, ".index");
195+
196+
// Write cert files to temp dir
197+
const certFile = join(tempDir, "server.crt");
198+
const keyFile = join(tempDir, "server.key");
199+
const caFile = join(tempDir, "ca.crt");
200+
writeFileSync(certFile, certs.serverCertPem);
201+
writeFileSync(keyFile, certs.serverKeyPem);
202+
writeFileSync(caFile, certs.caPem);
203+
204+
const [httpPort, metricsPort] = await Promise.all([
205+
findFreePort(),
206+
findFreePort(),
207+
]);
208+
rootToken = randomUUID();
209+
serverUrl = `https://127.0.0.1:${httpPort}`;
210+
211+
const binaryPath =
212+
process.env["BEYOND_OBJECTS_BINARY"] ??
213+
resolve(__dirname, "../../../target/debug/beyond-objects");
214+
215+
serverProcess = spawn(binaryPath, ["serve"], {
216+
env: {
217+
...process.env,
218+
OBJECTS_ROOT_TOKEN: rootToken,
219+
OBJECTS_DATA_DIR: dataDir,
220+
OBJECTS_INDEX_DIR: indexDir,
221+
ADDRESS: `127.0.0.1:${httpPort}`,
222+
METRICS_ADDRESS: `127.0.0.1:${metricsPort}`,
223+
OBJECTS_URL: serverUrl,
224+
LOG_LEVEL: "error",
225+
RUST_LOG: "error",
226+
BEYOND_TLS_CERT: certFile,
227+
BEYOND_TLS_KEY: keyFile,
228+
BEYOND_TLS_CA: caFile,
229+
},
230+
stdio: ["pipe", "pipe", "inherit"],
231+
});
232+
233+
serverProcess.on("error", (err) => {
234+
throw new Error(
235+
`Failed to start beyond-objects TLS server: ${err.message}`,
236+
);
237+
});
238+
239+
await waitForHealthy(serverUrl, certs.caPem, certs.clientCertPem, certs.clientKeyPem);
240+
}, 60_000);
241+
242+
afterAll(async () => {
243+
serverProcess?.kill("SIGTERM");
244+
serverProcess = undefined;
245+
if (tempDir != null) {
246+
rmSync(tempDir, { recursive: true, force: true });
247+
tempDir = undefined;
248+
}
249+
});
250+
251+
// ── Tests ────────────────────────────────────────────────────────────────────
252+
253+
describe("mTLS client support", () => {
254+
it("succeeds with valid CA + client cert", async () => {
255+
const client = createObjectsClient({
256+
url: serverUrl,
257+
token: rootToken,
258+
tls: {
259+
ca: certs.caPem,
260+
cert: certs.clientCertPem,
261+
key: certs.clientKeyPem,
262+
},
263+
});
264+
265+
const { data, error } = await client.buckets.list();
266+
expect(error).toBeUndefined();
267+
expect(Array.isArray(data)).toBe(true);
268+
});
269+
270+
it("fails without TLS options (no client cert, untrusted CA)", async () => {
271+
const client = createObjectsClient({
272+
url: serverUrl,
273+
token: rootToken,
274+
// No tls — plain undici H2 fetch with default system CAs; the
275+
// self-signed CA will not be trusted so the TLS handshake fails.
276+
timeout: 5_000,
277+
retries: 0,
278+
});
279+
280+
await expect(client.buckets.list()).rejects.toThrow();
281+
});
282+
});

0 commit comments

Comments
 (0)