Files
certctl/internal/connector/issuer/ejbca/ejbca_test.go
T
shankar0123 3669556e57 ejbca: wire mTLS client cert in New()
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.
2026-05-02 00:08:24 +00:00

884 lines
27 KiB
Go

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",
ClientCertPath: "/etc/ssl/certs/client.crt",
ClientKeyPath: "/etc/ssl/private/client.key",
CAName: "Management CA",
}
connector := mustNewForValidateConfig(t, logger)
rawConfig, _ := json.Marshal(config)
err := connector.ValidateConfig(ctx, rawConfig)
if err != nil {
t.Fatalf("ValidateConfig failed: %v", err)
}
})
t.Run("ValidateConfig_Success_OAuth2", func(t *testing.T) {
config := ejbca.Config{
APIUrl: "https://ejbca.example.com:8443/ejbca/ejbca-rest-api/v1",
AuthMode: "oauth2",
Token: "test-oauth2-token",
CAName: "Management CA",
}
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)
if err != nil {
t.Fatalf("ValidateConfig failed: %v", err)
}
})
t.Run("ValidateConfig_MissingAPIUrl", func(t *testing.T) {
config := ejbca.Config{
AuthMode: "mtls",
CAName: "Management CA",
}
connector := mustNewForValidateConfig(t, logger)
rawConfig, _ := json.Marshal(config)
err := connector.ValidateConfig(ctx, rawConfig)
if err == nil {
t.Fatal("Expected error for missing api_url")
}
if !strings.Contains(err.Error(), "api_url is required") {
t.Errorf("Expected api_url required error, got: %v", err)
}
})
t.Run("ValidateConfig_MissingCAName", func(t *testing.T) {
config := ejbca.Config{
APIUrl: "https://ejbca.example.com:8443/ejbca/ejbca-rest-api/v1",
AuthMode: "mtls",
}
connector := mustNewForValidateConfig(t, logger)
rawConfig, _ := json.Marshal(config)
err := connector.ValidateConfig(ctx, rawConfig)
if err == nil {
t.Fatal("Expected error for missing ca_name")
}
if !strings.Contains(err.Error(), "ca_name is required") {
t.Errorf("Expected ca_name required error, got: %v", err)
}
})
t.Run("ValidateConfig_mTLS_MissingCertPath", func(t *testing.T) {
config := ejbca.Config{
APIUrl: "https://ejbca.example.com:8443/ejbca/ejbca-rest-api/v1",
AuthMode: "mtls",
ClientKeyPath: "/etc/ssl/private/client.key",
CAName: "Management CA",
}
connector := mustNewForValidateConfig(t, logger)
rawConfig, _ := json.Marshal(config)
err := connector.ValidateConfig(ctx, rawConfig)
if err == nil {
t.Fatal("Expected error for missing client_cert_path with auth_mode=mtls")
}
if !strings.Contains(err.Error(), "client_cert_path is required") {
t.Errorf("Expected client_cert_path required error, got: %v", err)
}
})
t.Run("ValidateConfig_OAuth2_MissingToken", func(t *testing.T) {
config := ejbca.Config{
APIUrl: "https://ejbca.example.com:8443/ejbca/ejbca-rest-api/v1",
AuthMode: "oauth2",
CAName: "Management CA",
}
connector := mustNewForValidateConfig(t, logger)
rawConfig, _ := json.Marshal(config)
err := connector.ValidateConfig(ctx, rawConfig)
if err == nil {
t.Fatal("Expected error for missing token with auth_mode=oauth2")
}
if !strings.Contains(err.Error(), "token is required") {
t.Errorf("Expected token required error, got: %v", err)
}
})
t.Run("ValidateConfig_InvalidAuthMode", func(t *testing.T) {
config := ejbca.Config{
APIUrl: "https://ejbca.example.com:8443/ejbca/ejbca-rest-api/v1",
AuthMode: "invalid",
CAName: "Management CA",
}
connector := mustNewForValidateConfig(t, logger)
rawConfig, _ := json.Marshal(config)
err := connector.ValidateConfig(ctx, rawConfig)
if err == nil {
t.Fatal("Expected error for invalid auth_mode")
}
if !strings.Contains(err.Error(), "auth_mode must be") {
t.Errorf("Expected auth_mode validation error, got: %v", err)
}
})
t.Run("IssueCertificate_Synchronous", func(t *testing.T) {
testCertPEM, _ := generateTestCert(t)
testChainPEM, _ := generateTestCert(t)
// Extract DER from PEM for encoding
certBlock, _ := pem.Decode([]byte(testCertPEM))
chainBlock, _ := pem.Decode([]byte(testChainPEM))
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/certificate/pkcs10enroll") && r.Method == http.MethodPost {
// Parse the CSR from request
var enrollReq map[string]interface{}
json.NewDecoder(r.Body).Decode(&enrollReq)
// Verify CSR is base64-encoded
if csrB64, ok := enrollReq["certificate_request"].(string); ok {
// Decode to verify it's valid base64
if _, err := base64.StdEncoding.DecodeString(csrB64); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
}
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "application/json")
respData := map[string]interface{}{
"certificate": base64.StdEncoding.EncodeToString(certBlock.Bytes),
"certificate_chain": []string{base64.StdEncoding.EncodeToString(chainBlock.Bytes)},
"serial_number": "123456",
}
json.NewEncoder(w).Encode(respData)
return
}
http.NotFound(w, r)
}))
defer srv.Close()
config := &ejbca.Config{
APIUrl: srv.URL,
AuthMode: "oauth2",
Token: "test-token",
CAName: "Management CA",
}
connector := ejbca.NewWithHTTPClient(config, logger, srv.Client())
_, csrPEM := generateTestCSR(t, "test.example.com")
req := issuer.IssuanceRequest{
CommonName: "test.example.com",
SANs: []string{"test.example.com"},
CSRPEM: csrPEM,
}
result, err := connector.IssueCertificate(ctx, req)
if err != nil {
t.Fatalf("IssueCertificate failed: %v", err)
}
if result.CertPEM == "" {
t.Error("CertPEM should not be empty")
}
if result.OrderID == "" {
t.Error("OrderID should not be empty")
}
if !strings.Contains(result.OrderID, "::") {
t.Errorf("OrderID should contain issuer_dn::serial separator, got: %s", result.OrderID)
}
t.Logf("EJBCA issued cert: serial=%s, orderID=%s", result.Serial, result.OrderID)
})
t.Run("IssueCertificate_WithProfiles", func(t *testing.T) {
testCertPEM, _ := generateTestCert(t)
certBlock, _ := pem.Decode([]byte(testCertPEM))
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/certificate/pkcs10enroll") && r.Method == http.MethodPost {
// Verify profiles are in request
var enrollReq map[string]interface{}
json.NewDecoder(r.Body).Decode(&enrollReq)
if certProfile, ok := enrollReq["certificate_profile_name"].(string); !ok || certProfile != "ENDUSER" {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(`{"error":"invalid certificate_profile_name"}`))
return
}
if eeProfile, ok := enrollReq["end_entity_profile_name"].(string); !ok || eeProfile != "ENDUSER" {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(`{"error":"invalid end_entity_profile_name"}`))
return
}
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "application/json")
respData := map[string]interface{}{
"certificate": base64.StdEncoding.EncodeToString(certBlock.Bytes),
"certificate_chain": []string{},
"serial_number": "789012",
}
json.NewEncoder(w).Encode(respData)
return
}
http.NotFound(w, r)
}))
defer srv.Close()
config := &ejbca.Config{
APIUrl: srv.URL,
AuthMode: "oauth2",
Token: "test-token",
CAName: "Management CA",
CertProfile: "ENDUSER",
EEProfile: "ENDUSER",
}
connector := ejbca.NewWithHTTPClient(config, logger, srv.Client())
_, csrPEM := generateTestCSR(t, "app.example.com")
req := issuer.IssuanceRequest{
CommonName: "app.example.com",
CSRPEM: csrPEM,
}
result, err := connector.IssueCertificate(ctx, req)
if err != nil {
t.Fatalf("IssueCertificate with profiles failed: %v", err)
}
if result.CertPEM == "" {
t.Error("CertPEM should not be empty")
}
})
t.Run("IssueCertificate_Error", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(`{"error":"invalid CSR"}`))
}))
defer srv.Close()
config := &ejbca.Config{
APIUrl: srv.URL,
AuthMode: "oauth2",
Token: "test-token",
CAName: "Management CA",
}
connector := ejbca.NewWithHTTPClient(config, logger, srv.Client())
req := issuer.IssuanceRequest{
CommonName: "test.example.com",
CSRPEM: "invalid-csr",
}
_, err := connector.IssueCertificate(ctx, req)
if err == nil {
t.Fatal("Expected error for invalid CSR")
}
})
t.Run("GetOrderStatus_Issued", func(t *testing.T) {
testCertPEM, _ := generateTestCert(t)
certBlock, _ := pem.Decode([]byte(testCertPEM))
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/certificate/") && r.Method == http.MethodGet {
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "application/json")
respData := map[string]interface{}{
"certificate": base64.StdEncoding.EncodeToString(certBlock.Bytes),
"certificate_chain": []string{},
"serial_number": "123456",
}
json.NewEncoder(w).Encode(respData)
return
}
http.NotFound(w, r)
}))
defer srv.Close()
config := &ejbca.Config{
APIUrl: srv.URL,
AuthMode: "oauth2",
Token: "test-token",
CAName: "Management CA",
}
connector := ejbca.NewWithHTTPClient(config, logger, srv.Client())
orderID := "CN=Test CA::123456"
status, err := connector.GetOrderStatus(ctx, orderID)
if err != nil {
t.Fatalf("GetOrderStatus failed: %v", err)
}
if status.Status != "completed" {
t.Errorf("Expected status 'completed', got '%s'", status.Status)
}
if status.CertPEM == nil || *status.CertPEM == "" {
t.Error("CertPEM should not be empty for issued order")
}
})
t.Run("RenewCertificate_Success", func(t *testing.T) {
testCertPEM, _ := generateTestCert(t)
certBlock, _ := pem.Decode([]byte(testCertPEM))
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/certificate/pkcs10enroll") && r.Method == http.MethodPost {
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "application/json")
respData := map[string]interface{}{
"certificate": base64.StdEncoding.EncodeToString(certBlock.Bytes),
"certificate_chain": []string{},
"serial_number": "654321",
}
json.NewEncoder(w).Encode(respData)
return
}
http.NotFound(w, r)
}))
defer srv.Close()
config := &ejbca.Config{
APIUrl: srv.URL,
AuthMode: "oauth2",
Token: "test-token",
CAName: "Management CA",
}
connector := ejbca.NewWithHTTPClient(config, logger, srv.Client())
_, csrPEM := generateTestCSR(t, "renew.example.com")
renewReq := issuer.RenewalRequest{
CommonName: "renew.example.com",
CSRPEM: csrPEM,
}
result, err := connector.RenewCertificate(ctx, renewReq)
if err != nil {
t.Fatalf("RenewCertificate failed: %v", err)
}
if result.CertPEM == "" {
t.Error("CertPEM should not be empty")
}
if result.OrderID == "" {
t.Error("OrderID should not be empty")
}
})
t.Run("RevokeCertificate_Success", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/revoke") && r.Method == http.MethodPut {
// Verify reason is in request
var revokeReq map[string]interface{}
json.NewDecoder(r.Body).Decode(&revokeReq)
if _, ok := revokeReq["reason"]; !ok {
w.WriteHeader(http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusNoContent)
return
}
http.NotFound(w, r)
}))
defer srv.Close()
config := &ejbca.Config{
APIUrl: srv.URL,
AuthMode: "oauth2",
Token: "test-token",
CAName: "Management CA",
}
connector := ejbca.NewWithHTTPClient(config, logger, srv.Client())
reason := "keyCompromise"
revokeReq := issuer.RevocationRequest{
Serial: "123456",
Reason: &reason,
}
err := connector.RevokeCertificate(ctx, revokeReq)
if err != nil {
t.Fatalf("RevokeCertificate failed: %v", err)
}
})
t.Run("RevokeCertificate_ReasonMapping", func(t *testing.T) {
reasons := []struct {
name string
code int
mappedTo string
}{
{"keyCompromise", 1, "keyCompromise"},
{"caCompromise", 2, "caCompromise"},
{"superseded", 4, "superseded"},
{"cessationOfOperation", 5, "cessationOfOperation"},
}
for _, tc := range reasons {
t.Run(tc.name, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/revoke") && r.Method == http.MethodPut {
var revokeReq map[string]interface{}
json.NewDecoder(r.Body).Decode(&revokeReq)
// Verify the reason code matches
if reason, ok := revokeReq["reason"].(float64); ok {
if int(reason) != tc.code {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(fmt.Sprintf(`{"error":"expected reason %d, got %d"}`, tc.code, int(reason))))
return
}
}
w.WriteHeader(http.StatusNoContent)
return
}
http.NotFound(w, r)
}))
defer srv.Close()
config := &ejbca.Config{
APIUrl: srv.URL,
AuthMode: "oauth2",
Token: "test-token",
CAName: "Management CA",
}
connector := ejbca.NewWithHTTPClient(config, logger, srv.Client())
revokeReq := issuer.RevocationRequest{
Serial: "test-serial",
Reason: &tc.name,
}
err := connector.RevokeCertificate(ctx, revokeReq)
if err != nil {
t.Fatalf("RevokeCertificate with reason %s failed: %v", tc.name, err)
}
})
}
})
t.Run("GetRenewalInfo_ReturnsNil", func(t *testing.T) {
config := &ejbca.Config{
APIUrl: "https://ejbca.example.com:8443/ejbca/ejbca-rest-api/v1",
AuthMode: "oauth2",
Token: "test-token",
CAName: "Management CA",
}
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 {
t.Fatalf("GetRenewalInfo should not return error, got: %v", err)
}
if result != nil {
t.Fatal("GetRenewalInfo should return nil for EJBCA")
}
})
t.Run("GenerateCRL_Unsupported", func(t *testing.T) {
config := &ejbca.Config{
APIUrl: "https://ejbca.example.com:8443/ejbca/ejbca-rest-api/v1",
AuthMode: "oauth2",
Token: "test-token",
CAName: "Management CA",
}
connector, err := ejbca.New(config, logger)
if err != nil {
t.Fatalf("ejbca.New: %v", err)
}
_, err = connector.GenerateCRL(ctx, []issuer.RevokedCertEntry{})
if err == nil {
t.Fatal("Expected error for unsupported GenerateCRL")
}
if !strings.Contains(err.Error(), "CRL distribution") {
t.Errorf("Expected CRL distribution error, got: %v", err)
}
})
t.Run("SignOCSPResponse_Unsupported", func(t *testing.T) {
config := &ejbca.Config{
APIUrl: "https://ejbca.example.com:8443/ejbca/ejbca-rest-api/v1",
AuthMode: "oauth2",
Token: "test-token",
CAName: "Management CA",
}
connector, err := ejbca.New(config, logger)
if err != nil {
t.Fatalf("ejbca.New: %v", err)
}
_, err = connector.SignOCSPResponse(ctx, issuer.OCSPSignRequest{})
if err == nil {
t.Fatal("Expected error for unsupported SignOCSPResponse")
}
if !strings.Contains(err.Error(), "OCSP") {
t.Errorf("Expected OCSP error, got: %v", err)
}
})
}
// 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()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("Failed to generate key: %v", err)
}
serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
template := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{
CommonName: fmt.Sprintf("Test Certificate %s", serial.String()[:8]),
},
DNSNames: []string{"test.example.com"},
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
}
certBytes, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
t.Fatalf("Failed to create certificate: %v", err)
}
certPEM = string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certBytes}))
keyPEM = string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}))
return certPEM, keyPEM
}
// generateTestCSR creates a test CSR for the given common name.
func generateTestCSR(t *testing.T, commonName string) (*x509.CertificateRequest, string) {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("Failed to generate key: %v", err)
}
csrTemplate := x509.CertificateRequest{
Subject: pkix.Name{
CommonName: commonName,
},
DNSNames: []string{commonName},
SignatureAlgorithm: x509.SHA256WithRSA,
}
csrBytes, err := x509.CreateCertificateRequest(rand.Reader, &csrTemplate, key)
if err != nil {
t.Fatalf("Failed to create CSR: %v", err)
}
csrPEM := string(pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE REQUEST",
Bytes: csrBytes,
}))
csr, err := x509.ParseCertificateRequest(csrBytes)
if err != nil {
t.Fatalf("Failed to parse CSR: %v", err)
}
return csr, csrPEM
}