From 989ad403e3d360c8709dd5fff77277c6b68bfca6 Mon Sep 17 00:00:00 2001 From: shankar0123 Date: Sat, 2 May 2026 00:08:24 +0000 Subject: [PATCH] ejbca: wire mTLS client cert in New() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the #2 acquisition-readiness blocker from the 2026-05-01 issuer coverage audit. New() at ejbca.go:L79-L88 previously constructed an http.Client with only Timeout set — no Transport, no TLSClientConfig. When AuthMode=mtls (the default), the client never presented the configured ClientCert/ClientKey. The OAuth2 path worked; mTLS always failed authentication. Tests passed because they injected a pre-built *http.Client via NewWithHTTPClient, a path the production factory never took. This commit: - Rewrites New() to load ClientCertPath + ClientKeyPath via tls.LoadX509KeyPair when AuthMode=mtls, configure *http.Transport.TLSClientConfig with MinVersion: TLS 1.2 (compatibility floor for on-prem EJBCA installs that may predate TLS 1.3), and return (*Connector, error). Constructs a fresh *http.Transport — does NOT clone http.DefaultTransport, which would leak mutation across the package boundary. - OAuth2 mode unchanged: returns a client with no transport customization (the Bearer header path is wired in setAuthHeaders). - Invalid auth_mode values return (nil, error) immediately rather than falling through to the mtls default and erroring at cert load. - Updates the factory call site at issuerfactory/factory.go for the new signature; the factory's outer (issuer.Connector, error) shape was already in place. - Adds TestNew_MTLSWiresClientCert: calls production New() (NOT NewWithHTTPClient) with real cert/key files generated via stdlib crypto/x509, asserts httpClient.Transport.TLSClientConfig.Certificates is non-empty. Includes an httptest TLS server with ClientAuth: tls.RequireAndVerifyClientCert that proves the cert is actually presented on the wire — not just stashed in a struct field. - Adds TestNew_MTLSCertLoadFailure: missing-cert path returns an error wrapping fs.ErrNotExist (verified via errors.Is). - Adds TestNew_OAuth2NoTransportTuning: OAuth2 path leaves Transport nil, ensuring no accidental mTLS bleedthrough. - Adds TestNew_InvalidAuthMode: explicit guard that auth_mode values other than "mtls"/"oauth2" return (nil, error) at New() time. - Adds export_test.go with HTTPClientForTest helper so the external ejbca_test package can inspect the connector's internal *http.Client for the wiring assertions. Compile-only during `go test`; production builds don't expose it. - Adds mustNewForValidateConfig test helper (OAuth2 placeholder connector) for the existing ValidateConfig-only tests; pre-fix they used New(nil, ...) which is no longer valid because nil config falls into the mTLS default branch that requires non-nil cert paths. - Updates ejbca_stubs_test.go (internal package) for the new (*Connector, error) signature; switches the dummy connector to OAuth2 mode so Config{} doesn't error at New(). Out of scope (separate follow-ups, per the prompt's explicit fence): - OAuth2 token refresh missing - Config.Token plaintext at runtime (needs SecretRef abstraction) - RevokeCertificate composite OrderID parsing (the issuerDN := "" line at ejbca.go:L313) Verified locally: - gofmt clean - go vet ./... clean - staticcheck ./... clean - golangci-lint run --timeout 5m ./... → 0 issues - go test -short -count=1 ./internal/connector/issuer/ejbca/ green - go test -short -count=1 ./internal/connector/issuerfactory/ green - go test -short -count=1 ./internal/service/ green - go build ./... success Audit reference: cowork/issuer-coverage-audit-2026-05-01/RESULTS.md Top-10 fix #2. --- internal/connector/issuer/ejbca/ejbca.go | 68 +++- .../issuer/ejbca/ejbca_stubs_test.go | 24 +- internal/connector/issuer/ejbca/ejbca_test.go | 297 +++++++++++++++++- .../connector/issuer/ejbca/export_test.go | 14 + internal/connector/issuerfactory/factory.go | 6 +- 5 files changed, 382 insertions(+), 27 deletions(-) create mode 100644 internal/connector/issuer/ejbca/export_test.go diff --git a/internal/connector/issuer/ejbca/ejbca.go b/internal/connector/issuer/ejbca/ejbca.go index 8567f9c..ec23b22 100644 --- a/internal/connector/issuer/ejbca/ejbca.go +++ b/internal/connector/issuer/ejbca/ejbca.go @@ -20,6 +20,7 @@ package ejbca import ( "bytes" "context" + "crypto/tls" "crypto/x509" "encoding/base64" "encoding/json" @@ -77,13 +78,66 @@ type Connector struct { } // New creates a new EJBCA connector with the given configuration and logger. -func New(config *Config, logger *slog.Logger) *Connector { - return &Connector{ - config: config, - logger: logger, - httpClient: &http.Client{ - Timeout: 30 * time.Second, - }, +// +// When config.AuthMode is "mtls" (or empty — mtls is the default), New +// loads config.ClientCertPath + config.ClientKeyPath via tls.LoadX509KeyPair +// and configures *http.Transport.TLSClientConfig so the client presents the +// cert on every request. When AuthMode is "oauth2", New returns a client +// with no transport customization (the OAuth2 Bearer header path is wired +// in setAuthHeaders). Any other AuthMode value returns (nil, error). +// +// Returns an error if mTLS cert/key load fails (missing file, malformed +// PEM, mismatched cert/key) so misconfigured operators get an immediate +// failure at issuer construction rather than a cryptic 401 at first +// issuance. +// +// Callers wanting to inject a pre-built *http.Client (tests, fake EJBCA +// servers) should use NewWithHTTPClient. +func New(config *Config, logger *slog.Logger) (*Connector, error) { + authMode := "mtls" + if config != nil && config.AuthMode != "" { + authMode = config.AuthMode + } + + switch authMode { + case "mtls": + // Build a fresh *http.Transport (do NOT clone http.DefaultTransport + // — mutation would leak across the package boundary). Set + // MinVersion: TLS 1.2 as a compatibility floor for on-prem EJBCA + // installs that may predate TLS 1.3. + if config == nil || config.ClientCertPath == "" || config.ClientKeyPath == "" { + return nil, fmt.Errorf("EJBCA mTLS requires client_cert_path and client_key_path") + } + cert, err := tls.LoadX509KeyPair(config.ClientCertPath, config.ClientKeyPath) + if err != nil { + return nil, fmt.Errorf("EJBCA mTLS cert load: %w", err) + } + transport := &http.Transport{ + TLSClientConfig: &tls.Config{ + Certificates: []tls.Certificate{cert}, + MinVersion: tls.VersionTLS12, + }, + } + return &Connector{ + config: config, + logger: logger, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + Transport: transport, + }, + }, nil + case "oauth2": + // OAuth2 path uses default transport; setAuthHeaders adds the + // Bearer header on every request. + return &Connector{ + config: config, + logger: logger, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + }, nil + default: + return nil, fmt.Errorf("EJBCA invalid auth_mode %q (must be \"mtls\" or \"oauth2\")", authMode) } } diff --git a/internal/connector/issuer/ejbca/ejbca_stubs_test.go b/internal/connector/issuer/ejbca/ejbca_stubs_test.go index b81e350..7017444 100644 --- a/internal/connector/issuer/ejbca/ejbca_stubs_test.go +++ b/internal/connector/issuer/ejbca/ejbca_stubs_test.go @@ -21,28 +21,40 @@ func quietStubLogger() *slog.Logger { } func TestStub_GenerateCRL(t *testing.T) { - c := New(&Config{}, quietStubLogger()) - _, err := c.GenerateCRL(context.Background(), nil) + c, err := New(&Config{AuthMode: "oauth2", Token: "dummy"}, quietStubLogger()) + if err != nil { + t.Fatalf("New: %v", err) + } + _, err = c.GenerateCRL(context.Background(), nil) if err == nil { t.Fatal("expected error from stub GenerateCRL") } } func TestStub_SignOCSPResponse(t *testing.T) { - c := New(&Config{}, quietStubLogger()) - _, err := c.SignOCSPResponse(context.Background(), issuer.OCSPSignRequest{}) + c, err := New(&Config{AuthMode: "oauth2", Token: "dummy"}, quietStubLogger()) + if err != nil { + t.Fatalf("New: %v", err) + } + _, err = c.SignOCSPResponse(context.Background(), issuer.OCSPSignRequest{}) if err == nil { t.Fatal("expected error from stub SignOCSPResponse") } } func TestStub_GetCACertPEM(t *testing.T) { - c := New(&Config{}, quietStubLogger()) + c, err := New(&Config{AuthMode: "oauth2", Token: "dummy"}, quietStubLogger()) + if err != nil { + t.Fatalf("New: %v", err) + } _, _ = c.GetCACertPEM(context.Background()) } func TestStub_GetRenewalInfo(t *testing.T) { - c := New(&Config{}, quietStubLogger()) + c, err := New(&Config{AuthMode: "oauth2", Token: "dummy"}, quietStubLogger()) + if err != nil { + t.Fatalf("New: %v", err) + } res, err := c.GetRenewalInfo(context.Background(), "any-pem") _ = res _ = err diff --git a/internal/connector/issuer/ejbca/ejbca_test.go b/internal/connector/issuer/ejbca/ejbca_test.go index 1842e58..c6c6563 100644 --- a/internal/connector/issuer/ejbca/ejbca_test.go +++ b/internal/connector/issuer/ejbca/ejbca_test.go @@ -2,31 +2,63 @@ package ejbca_test import ( "context" + "crypto/ecdsa" + "crypto/elliptic" "crypto/rand" "crypto/rsa" + "crypto/tls" "crypto/x509" "crypto/x509/pkix" "encoding/base64" "encoding/json" "encoding/pem" + "errors" "fmt" + "io/fs" "log/slog" "math/big" "net/http" "net/http/httptest" "os" + "path/filepath" "strings" "testing" + "time" "github.com/shankar0123/certctl/internal/connector/issuer" "github.com/shankar0123/certctl/internal/connector/issuer/ejbca" ) +// mustNewForValidateConfig returns an EJBCA connector wired in OAuth2 mode +// with a placeholder token. ValidateConfig parses raw JSON independently of +// the connector's auth wiring, so this dummy connector is sufficient for +// ValidateConfig-only tests. The pre-existing tests called New(nil, ...) for +// this; with the new (*Connector, error) signature that requires a non-nil +// config, the OAuth2 placeholder is the cheapest substitute. +func mustNewForValidateConfig(t *testing.T, logger *slog.Logger) *ejbca.Connector { + t.Helper() + c, err := ejbca.New(&ejbca.Config{ + APIUrl: "https://placeholder", + AuthMode: "oauth2", + Token: "placeholder", + CAName: "placeholder", + }, logger) + if err != nil { + t.Fatalf("ejbca.New (OAuth2 dummy): %v", err) + } + return c +} + func TestEJBCAConnector(t *testing.T) { logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})) ctx := context.Background() t.Run("ValidateConfig_Success_mTLS", func(t *testing.T) { + // Use a placeholder connector for ValidateConfig — the JSON + // shape is what's being validated, not the connector's mTLS + // wiring. (Production New() with these fake paths would fail + // at tls.LoadX509KeyPair, which is the correct behavior tested + // separately by TestNew_MTLSCertLoadFailure.) config := ejbca.Config{ APIUrl: "https://ejbca.example.com:8443/ejbca/ejbca-rest-api/v1", AuthMode: "mtls", @@ -35,7 +67,7 @@ func TestEJBCAConnector(t *testing.T) { CAName: "Management CA", } - connector := ejbca.New(&config, logger) + connector := mustNewForValidateConfig(t, logger) rawConfig, _ := json.Marshal(config) err := connector.ValidateConfig(ctx, rawConfig) if err != nil { @@ -51,9 +83,12 @@ func TestEJBCAConnector(t *testing.T) { CAName: "Management CA", } - connector := ejbca.New(&config, logger) + connector, err := ejbca.New(&config, logger) + if err != nil { + t.Fatalf("ejbca.New (OAuth2): %v", err) + } rawConfig, _ := json.Marshal(config) - err := connector.ValidateConfig(ctx, rawConfig) + err = connector.ValidateConfig(ctx, rawConfig) if err != nil { t.Fatalf("ValidateConfig failed: %v", err) } @@ -65,7 +100,7 @@ func TestEJBCAConnector(t *testing.T) { CAName: "Management CA", } - connector := ejbca.New(nil, logger) + connector := mustNewForValidateConfig(t, logger) rawConfig, _ := json.Marshal(config) err := connector.ValidateConfig(ctx, rawConfig) if err == nil { @@ -82,7 +117,7 @@ func TestEJBCAConnector(t *testing.T) { AuthMode: "mtls", } - connector := ejbca.New(nil, logger) + connector := mustNewForValidateConfig(t, logger) rawConfig, _ := json.Marshal(config) err := connector.ValidateConfig(ctx, rawConfig) if err == nil { @@ -101,7 +136,7 @@ func TestEJBCAConnector(t *testing.T) { CAName: "Management CA", } - connector := ejbca.New(nil, logger) + connector := mustNewForValidateConfig(t, logger) rawConfig, _ := json.Marshal(config) err := connector.ValidateConfig(ctx, rawConfig) if err == nil { @@ -119,7 +154,7 @@ func TestEJBCAConnector(t *testing.T) { CAName: "Management CA", } - connector := ejbca.New(nil, logger) + connector := mustNewForValidateConfig(t, logger) rawConfig, _ := json.Marshal(config) err := connector.ValidateConfig(ctx, rawConfig) if err == nil { @@ -137,7 +172,7 @@ func TestEJBCAConnector(t *testing.T) { CAName: "Management CA", } - connector := ejbca.New(nil, logger) + connector := mustNewForValidateConfig(t, logger) rawConfig, _ := json.Marshal(config) err := connector.ValidateConfig(ctx, rawConfig) if err == nil { @@ -496,7 +531,10 @@ func TestEJBCAConnector(t *testing.T) { Token: "test-token", CAName: "Management CA", } - connector := ejbca.New(config, logger) + connector, err := ejbca.New(config, logger) + if err != nil { + t.Fatalf("ejbca.New: %v", err) + } result, err := connector.GetRenewalInfo(ctx, "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----") if err != nil { @@ -514,9 +552,12 @@ func TestEJBCAConnector(t *testing.T) { Token: "test-token", CAName: "Management CA", } - connector := ejbca.New(config, logger) + connector, err := ejbca.New(config, logger) + if err != nil { + t.Fatalf("ejbca.New: %v", err) + } - _, err := connector.GenerateCRL(ctx, []issuer.RevokedCertEntry{}) + _, err = connector.GenerateCRL(ctx, []issuer.RevokedCertEntry{}) if err == nil { t.Fatal("Expected error for unsupported GenerateCRL") } @@ -532,9 +573,12 @@ func TestEJBCAConnector(t *testing.T) { Token: "test-token", CAName: "Management CA", } - connector := ejbca.New(config, logger) + connector, err := ejbca.New(config, logger) + if err != nil { + t.Fatalf("ejbca.New: %v", err) + } - _, err := connector.SignOCSPResponse(ctx, issuer.OCSPSignRequest{}) + _, err = connector.SignOCSPResponse(ctx, issuer.OCSPSignRequest{}) if err == nil { t.Fatal("Expected error for unsupported SignOCSPResponse") } @@ -544,6 +588,233 @@ func TestEJBCAConnector(t *testing.T) { }) } +// TestNew_MTLSWiresClientCert closes the audit's #2 D11 blocker by exercising +// the production New() path (NOT NewWithHTTPClient). Pre-fix, New() built an +// http.Client with only Timeout set; mTLS mode advertised support but never +// loaded the cert. Tests passed via NewWithHTTPClient mock injection — a path +// the production constructor never took. This test calls New() with real +// cert/key files and asserts: +// +// 1. Error is nil (cert load succeeded). +// 2. The connector's HTTP client has a non-nil Transport. +// 3. Transport.TLSClientConfig.Certificates carries the loaded cert. +// +// As an end-to-end proof, the test then makes a request against an +// httptest.NewTLSServer with ClientAuth: tls.RequireAndVerifyClientCert +// and asserts the request succeeds — proving the cert was actually +// presented on the wire (not just stashed in a struct field). +func TestNew_MTLSWiresClientCert(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelError})) + + // 1. Generate a CA cert + a client cert signed by the CA. Use ECDSA-P256 + // to match the codebase's preferred algorithm. + caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("CA key gen: %v", err) + } + caTemplate := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "EJBCA-Test-CA"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + IsCA: true, + BasicConstraintsValid: true, + } + caDER, err := x509.CreateCertificate(rand.Reader, &caTemplate, &caTemplate, &caKey.PublicKey, caKey) + if err != nil { + t.Fatalf("CA cert: %v", err) + } + caCert, err := x509.ParseCertificate(caDER) + if err != nil { + t.Fatalf("parse CA: %v", err) + } + + clientKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("client key gen: %v", err) + } + clientTemplate := x509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: pkix.Name{CommonName: "ejbca-test-client"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + } + clientDER, err := x509.CreateCertificate(rand.Reader, &clientTemplate, caCert, &clientKey.PublicKey, caKey) + if err != nil { + t.Fatalf("client cert: %v", err) + } + + // 2. Write cert + key to temp files (Go stdlib's tls.LoadX509KeyPair + // requires file paths). + dir := t.TempDir() + certPath := filepath.Join(dir, "client.crt") + keyPath := filepath.Join(dir, "client.key") + clientCertPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: clientDER}) + if err := os.WriteFile(certPath, clientCertPEM, 0o600); err != nil { + t.Fatalf("write client cert: %v", err) + } + clientKeyDER, err := x509.MarshalECPrivateKey(clientKey) + if err != nil { + t.Fatalf("marshal key: %v", err) + } + clientKeyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: clientKeyDER}) + if err := os.WriteFile(keyPath, clientKeyPEM, 0o600); err != nil { + t.Fatalf("write client key: %v", err) + } + + // 3. Call production New() (NOT NewWithHTTPClient) with the cert paths. + cfg := &ejbca.Config{ + APIUrl: "https://placeholder", + AuthMode: "mtls", + ClientCertPath: certPath, + ClientKeyPath: keyPath, + CAName: "Management CA", + } + conn, err := ejbca.New(cfg, logger) + if err != nil { + t.Fatalf("ejbca.New: %v", err) + } + if conn == nil { + t.Fatal("New returned nil connector") + } + + // 4. Assert via the exported HTTPClient accessor that the transport + // is wired and carries the loaded cert. (Connector exposes + // HTTPClient only in test builds via the helper below.) + httpClient := ejbca.HTTPClientForTest(conn) + if httpClient == nil { + t.Fatal("connector httpClient is nil") + } + tr, ok := httpClient.Transport.(*http.Transport) + if !ok { + t.Fatalf("expected *http.Transport, got %T", httpClient.Transport) + } + if tr.TLSClientConfig == nil { + t.Fatal("Transport.TLSClientConfig is nil — mTLS not wired") + } + if len(tr.TLSClientConfig.Certificates) == 0 { + t.Fatal("Transport.TLSClientConfig.Certificates is empty — cert not loaded") + } + + // 5. End-to-end proof: spin up an httptest TLS server that requires + // a client cert signed by our CA. Hit it with the connector's + // client and assert the request succeeds (cert was presented). + pool := x509.NewCertPool() + pool.AddCert(caCert) + + srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if len(r.TLS.PeerCertificates) == 0 { + http.Error(w, "no client cert", http.StatusUnauthorized) + return + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + })) + srv.TLS = &tls.Config{ + ClientAuth: tls.RequireAndVerifyClientCert, + ClientCAs: pool, + } + srv.StartTLS() + defer srv.Close() + + // The httptest server's cert isn't trusted by our client; for the + // purpose of this test we replace the RootCAs to trust it. We + // intentionally keep the Certificates (client cert) intact — the + // test is about whether the client cert is presented, not about + // the server cert chain. + srvCertDER := srv.Certificate().Raw + srvCert, err := x509.ParseCertificate(srvCertDER) + if err != nil { + t.Fatalf("parse srv cert: %v", err) + } + srvPool := x509.NewCertPool() + srvPool.AddCert(srvCert) + tr.TLSClientConfig.RootCAs = srvPool + + resp, err := httpClient.Get(srv.URL) + if err != nil { + t.Fatalf("HTTPS request to mTLS server failed: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d (cert was probably not presented)", resp.StatusCode) + } +} + +// TestNew_MTLSCertLoadFailure asserts that a missing-cert path returns an +// error wrapping fs.ErrNotExist. This is the negative path: misconfigured +// operators must get an immediate failure at issuer construction, not a +// cryptic 401 at first issuance. +func TestNew_MTLSCertLoadFailure(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelError})) + + cfg := &ejbca.Config{ + APIUrl: "https://placeholder", + AuthMode: "mtls", + ClientCertPath: "/nonexistent/path/to/cert.pem", + ClientKeyPath: "/nonexistent/path/to/key.pem", + CAName: "Management CA", + } + _, err := ejbca.New(cfg, logger) + if err == nil { + t.Fatal("expected error from missing cert path") + } + if !errors.Is(err, fs.ErrNotExist) { + t.Errorf("expected error to wrap fs.ErrNotExist, got: %v", err) + } +} + +// TestNew_OAuth2NoTransportTuning asserts that the OAuth2 path does NOT +// accidentally apply mTLS-style transport customization. This catches the +// reverse class of bug: someone modifying New() in a way that leaks mTLS +// transport into the OAuth2 path. +func TestNew_OAuth2NoTransportTuning(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelError})) + + cfg := &ejbca.Config{ + APIUrl: "https://placeholder", + AuthMode: "oauth2", + Token: "test-token", + CAName: "Management CA", + } + conn, err := ejbca.New(cfg, logger) + if err != nil { + t.Fatalf("ejbca.New (OAuth2): %v", err) + } + httpClient := ejbca.HTTPClientForTest(conn) + if httpClient == nil { + t.Fatal("connector httpClient is nil") + } + if httpClient.Transport != nil { + t.Fatalf("expected Transport to be nil for OAuth2 mode, got: %T", httpClient.Transport) + } +} + +// TestNew_InvalidAuthMode asserts that any auth_mode other than "mtls" or +// "oauth2" returns (nil, error) immediately rather than falling through to +// the default (mtls) which would then fail at cert load. +func TestNew_InvalidAuthMode(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelError})) + + cfg := &ejbca.Config{ + APIUrl: "https://placeholder", + AuthMode: "invalid", + Token: "test-token", + CAName: "Management CA", + } + _, err := ejbca.New(cfg, logger) + if err == nil { + t.Fatal("expected error from invalid auth_mode") + } + if !strings.Contains(err.Error(), "invalid auth_mode") { + t.Errorf("expected 'invalid auth_mode' error, got: %v", err) + } +} + // generateTestCert creates a self-signed test certificate and returns the PEM string. func generateTestCert(t *testing.T) (certPEM string, keyPEM string) { t.Helper() diff --git a/internal/connector/issuer/ejbca/export_test.go b/internal/connector/issuer/ejbca/export_test.go new file mode 100644 index 0000000..88857f5 --- /dev/null +++ b/internal/connector/issuer/ejbca/export_test.go @@ -0,0 +1,14 @@ +package ejbca + +import "net/http" + +// HTTPClientForTest exposes the connector's internal *http.Client to the +// external ejbca_test package. The mTLS-wiring tests need to inspect +// Transport.TLSClientConfig.Certificates to assert the cert was loaded; +// that field is unexported, so we provide this test-only accessor. +// +// The "_test.go" suffix means this file is only compiled during `go test`, +// so production builds don't expose the internal httpClient field. +func HTTPClientForTest(c *Connector) *http.Client { + return c.httpClient +} diff --git a/internal/connector/issuerfactory/factory.go b/internal/connector/issuerfactory/factory.go index f16e64b..b45952d 100644 --- a/internal/connector/issuerfactory/factory.go +++ b/internal/connector/issuerfactory/factory.go @@ -122,7 +122,11 @@ func NewFromConfig(ctx context.Context, issuerType string, configJSON json.RawMe if err := json.Unmarshal(configJSON, &cfg); err != nil { return nil, fmt.Errorf("invalid EJBCA config: %w", err) } - return ejbca.New(&cfg, logger), nil + conn, err := ejbca.New(&cfg, logger) + if err != nil { + return nil, fmt.Errorf("EJBCA init: %w", err) + } + return conn, nil default: return nil, fmt.Errorf("unknown issuer type: %q", issuerType)