|
| 1 | +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; |
| 2 | +import * as k8s from '@kubernetes/client-node'; |
| 3 | +import { kubeConfigToBunTls, BunTlsHttpLibrary, makeApiClient } from './kubeconfig'; |
| 4 | + |
| 5 | +/** |
| 6 | + * Regression guards for the Bun TLS shim (`kubeConfigToBunTls`, |
| 7 | + * `BunTlsHttpLibrary`, `makeApiClient`). This is security-sensitive auth/TLS |
| 8 | + * code: the most important assertion in this file is that the **default path |
| 9 | + * never disables certificate verification** — see `does NOT set |
| 10 | + * rejectUnauthorized on the default path`. |
| 11 | + * |
| 12 | + * Tests build real `KubeConfig` objects via `loadFromOptions` (no disk/network) |
| 13 | + * rather than mocking SDK internals, so they keep working across SDK patch bumps. |
| 14 | + */ |
| 15 | + |
| 16 | +// PEM-shaped placeholders. `applyToHTTPSOptions` only base64-decodes the *Data |
| 17 | +// fields into Buffers and copies them; it does not parse/validate the contents. |
| 18 | +const CA_PEM = '-----BEGIN CERTIFICATE-----\nMIIBfakeCApem\n-----END CERTIFICATE-----\n'; |
| 19 | +const CERT_PEM = '-----BEGIN CERTIFICATE-----\nMIIBfakeClientpem\n-----END CERTIFICATE-----\n'; |
| 20 | +const KEY_PEM = '-----BEGIN PRIVATE KEY-----\nMIIBfakeKeypem\n-----END PRIVATE KEY-----\n'; |
| 21 | + |
| 22 | +const b64 = (s: string) => Buffer.from(s).toString('base64'); |
| 23 | + |
| 24 | +interface KcOpts { |
| 25 | + skipTLSVerify?: boolean; |
| 26 | + tlsServerName?: string; |
| 27 | + token?: string; |
| 28 | + withClientCert?: boolean; |
| 29 | + caData?: string | null; |
| 30 | +} |
| 31 | + |
| 32 | +function makeKubeConfig(opts: KcOpts = {}): k8s.KubeConfig { |
| 33 | + const kc = new k8s.KubeConfig(); |
| 34 | + const cluster: Record<string, unknown> = { |
| 35 | + name: 'test-cluster', |
| 36 | + server: 'https://api.example.test:443', |
| 37 | + skipTLSVerify: !!opts.skipTLSVerify, |
| 38 | + }; |
| 39 | + if (opts.caData !== null) cluster.caData = opts.caData ?? b64(CA_PEM); |
| 40 | + if (opts.tlsServerName) cluster.tlsServerName = opts.tlsServerName; |
| 41 | + |
| 42 | + const user: Record<string, unknown> = { name: 'test-user' }; |
| 43 | + if (opts.token) user.token = opts.token; |
| 44 | + if (opts.withClientCert) { |
| 45 | + user.certData = b64(CERT_PEM); |
| 46 | + user.keyData = b64(KEY_PEM); |
| 47 | + } |
| 48 | + |
| 49 | + kc.loadFromOptions({ |
| 50 | + clusters: [cluster as any], |
| 51 | + users: [user as any], |
| 52 | + contexts: [{ name: 'test-ctx', cluster: 'test-cluster', user: 'test-user' }], |
| 53 | + currentContext: 'test-ctx', |
| 54 | + }); |
| 55 | + return kc; |
| 56 | +} |
| 57 | + |
| 58 | +describe('kubeConfigToBunTls', () => { |
| 59 | + it('maps ca/cert/key into the Bun tls option', async () => { |
| 60 | + const tls = await kubeConfigToBunTls(makeKubeConfig({ withClientCert: true })); |
| 61 | + expect(tls).toBeDefined(); |
| 62 | + expect(Buffer.isBuffer(tls!.ca)).toBe(true); |
| 63 | + expect(Buffer.isBuffer(tls!.cert)).toBe(true); |
| 64 | + expect(Buffer.isBuffer(tls!.key)).toBe(true); |
| 65 | + expect(tls!.ca!.toString()).toBe(CA_PEM); |
| 66 | + }); |
| 67 | + |
| 68 | + it('maps the kubeconfig SNI (servername) to Bun camelCase serverName', async () => { |
| 69 | + const tls = await kubeConfigToBunTls(makeKubeConfig({ token: 'tok', tlsServerName: 'sni.override.test' })); |
| 70 | + expect(tls).toBeDefined(); |
| 71 | + expect(tls!.serverName).toBe('sni.override.test'); |
| 72 | + }); |
| 73 | + |
| 74 | + it('does NOT set serverName when the kubeconfig has no tls-server-name', async () => { |
| 75 | + const tls = await kubeConfigToBunTls(makeKubeConfig({ token: 'tok' })); |
| 76 | + // ca is still present, so tls is defined; serverName must be absent. |
| 77 | + expect(tls).toBeDefined(); |
| 78 | + expect(tls!.serverName).toBeUndefined(); |
| 79 | + }); |
| 80 | + |
| 81 | + it('sets rejectUnauthorized=false when skipTLSVerify is true', async () => { |
| 82 | + const tls = await kubeConfigToBunTls(makeKubeConfig({ token: 'tok', skipTLSVerify: true })); |
| 83 | + expect(tls).toBeDefined(); |
| 84 | + expect(tls!.rejectUnauthorized).toBe(false); |
| 85 | + }); |
| 86 | + |
| 87 | + // *** KEY SECURITY REGRESSION GUARD *** |
| 88 | + it('does NOT set rejectUnauthorized on the default (verifying) path', async () => { |
| 89 | + const tls = await kubeConfigToBunTls(makeKubeConfig({ token: 'tok' })); |
| 90 | + expect(tls).toBeDefined(); |
| 91 | + // Must be absent (not `true`, not `false`) so Bun keeps verification ON. |
| 92 | + expect(tls!.rejectUnauthorized).toBeUndefined(); |
| 93 | + expect(Object.prototype.hasOwnProperty.call(tls!, 'rejectUnauthorized')).toBe(false); |
| 94 | + }); |
| 95 | + |
| 96 | + it('returns undefined when the kubeconfig configures no TLS material', async () => { |
| 97 | + // No CA, no client cert, token auth, verification left on → nothing to map. |
| 98 | + const tls = await kubeConfigToBunTls(makeKubeConfig({ token: 'tok', caData: null })); |
| 99 | + expect(tls).toBeUndefined(); |
| 100 | + }); |
| 101 | +}); |
| 102 | + |
| 103 | +describe('BunTlsHttpLibrary.send', () => { |
| 104 | + const realFetch = globalThis.fetch; |
| 105 | + let calls: Array<{ url: string; options: any }>; |
| 106 | + |
| 107 | + beforeEach(() => { |
| 108 | + calls = []; |
| 109 | + }); |
| 110 | + afterEach(() => { |
| 111 | + globalThis.fetch = realFetch; |
| 112 | + }); |
| 113 | + |
| 114 | + function stubFetch(status = 200, body = '{"ok":true}') { |
| 115 | + globalThis.fetch = (async (url: any, options: any) => { |
| 116 | + calls.push({ url: String(url), options }); |
| 117 | + return new Response(body, { status, headers: { 'content-type': 'application/json' } }); |
| 118 | + }) as typeof fetch; |
| 119 | + } |
| 120 | + |
| 121 | + function makeRequest(): k8s.RequestContext { |
| 122 | + const req = new k8s.RequestContext('https://api.example.test:443/healthz', k8s.HttpMethod.GET); |
| 123 | + // Mirror how the generated client applies auth before send() runs. |
| 124 | + req.setHeaderParam('Authorization', 'Bearer test-token-123'); |
| 125 | + return req; |
| 126 | + } |
| 127 | + |
| 128 | + it('passes the mapped tls material to fetch', async () => { |
| 129 | + stubFetch(); |
| 130 | + const lib = new BunTlsHttpLibrary(makeKubeConfig({ withClientCert: true, tlsServerName: 'sni.test' })); |
| 131 | + await lib.send(makeRequest()).toPromise(); |
| 132 | + |
| 133 | + expect(calls).toHaveLength(1); |
| 134 | + const tls = calls[0].options.tls; |
| 135 | + expect(tls).toBeDefined(); |
| 136 | + expect(Buffer.isBuffer(tls.ca)).toBe(true); |
| 137 | + expect(tls.serverName).toBe('sni.test'); |
| 138 | + }); |
| 139 | + |
| 140 | + it('preserves the Authorization header applied upstream (auth survives the override)', async () => { |
| 141 | + stubFetch(); |
| 142 | + const lib = new BunTlsHttpLibrary(makeKubeConfig({ token: 'ignored' })); |
| 143 | + await lib.send(makeRequest()).toPromise(); |
| 144 | + |
| 145 | + const headers = new Headers(calls[0].options.headers); |
| 146 | + expect(headers.get('Authorization')).toBe('Bearer test-token-123'); |
| 147 | + }); |
| 148 | + |
| 149 | + it('omits tls entirely when the kubeconfig configures none', async () => { |
| 150 | + stubFetch(); |
| 151 | + const lib = new BunTlsHttpLibrary(makeKubeConfig({ token: 'tok', caData: null })); |
| 152 | + await lib.send(makeRequest()).toPromise(); |
| 153 | + expect(calls[0].options.tls).toBeUndefined(); |
| 154 | + }); |
| 155 | + |
| 156 | + it('returns a ResponseContext (not a thrown error) for a non-2xx response', async () => { |
| 157 | + stubFetch(404, '{"kind":"Status","code":404}'); |
| 158 | + const lib = new BunTlsHttpLibrary(makeKubeConfig({ token: 'tok' })); |
| 159 | + const res = await lib.send(makeRequest()).toPromise(); |
| 160 | + |
| 161 | + expect(res).toBeInstanceOf(k8s.ResponseContext); |
| 162 | + expect(res.httpStatusCode).toBe(404); |
| 163 | + expect(await res.body.text()).toContain('Status'); |
| 164 | + }); |
| 165 | + |
| 166 | + it('resolves the kubeconfig TLS material only once across multiple requests', async () => { |
| 167 | + stubFetch(); |
| 168 | + const kc = makeKubeConfig({ withClientCert: true }); |
| 169 | + let applyCount = 0; |
| 170 | + const orig = kc.applyToHTTPSOptions.bind(kc); |
| 171 | + kc.applyToHTTPSOptions = (async (opts: any) => { |
| 172 | + applyCount += 1; |
| 173 | + return orig(opts); |
| 174 | + }) as typeof kc.applyToHTTPSOptions; |
| 175 | + |
| 176 | + const lib = new BunTlsHttpLibrary(kc); |
| 177 | + await lib.send(makeRequest()).toPromise(); |
| 178 | + await lib.send(makeRequest()).toPromise(); |
| 179 | + await lib.send(makeRequest()).toPromise(); |
| 180 | + |
| 181 | + // Cached after the first call — guards against re-running the auth/cert |
| 182 | + // pipeline (e.g. exec credential plugins) on every request. |
| 183 | + expect(applyCount).toBe(1); |
| 184 | + }); |
| 185 | +}); |
| 186 | + |
| 187 | +describe('makeApiClient', () => { |
| 188 | + it('builds a typed API client wired to the Bun TLS http library', () => { |
| 189 | + const api = makeApiClient(makeKubeConfig({ token: 'tok' }), k8s.CoreV1Api); |
| 190 | + expect(api).toBeInstanceOf(k8s.CoreV1Api); |
| 191 | + }); |
| 192 | + |
| 193 | + it('throws when the kubeconfig has no active cluster', () => { |
| 194 | + const kc = new k8s.KubeConfig(); |
| 195 | + expect(() => makeApiClient(kc, k8s.CoreV1Api)).toThrow('No active cluster!'); |
| 196 | + }); |
| 197 | +}); |
0 commit comments