internal/kafka/tls_test.go
Rama main · réplica de lectura del repositorio original.
package kafka
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/Shopify/sarama"
"github.com/flexprice/flexprice/internal/config"
)
// writeTestCA generates a throwaway self-signed CA and writes it as PEM,
// returning the path and the certificate's subject. Using a real generated cert
// (rather than a fixture) keeps the test independent of any expiry date.
func writeTestCA(t *testing.T, dir, name string) (string, pkix.Name) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("generate key: %v", err)
}
subject := pkix.Name{CommonName: "flexprice-test-kafka-ca-" + name}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: subject,
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
IsCA: true,
KeyUsage: x509.KeyUsageCertSign,
BasicConstraintsValid: true,
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatalf("create cert: %v", err)
}
path := filepath.Join(dir, name+".pem")
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
if err := os.WriteFile(path, pemBytes, 0o600); err != nil {
t.Fatalf("write cert: %v", err)
}
return path, subject
}
func TestBuildTLSConfig_NoCAFileUsesSystemRoots(t *testing.T) {
tlsCfg, err := BuildTLSConfig(&config.KafkaConfig{})
if err != nil {
t.Fatalf("BuildTLSConfig() error = %v, want nil", err)
}
if tlsCfg.RootCAs != nil {
t.Error("RootCAs should be nil so Go falls back to the OS trust store")
}
if tlsCfg.InsecureSkipVerify {
t.Error("InsecureSkipVerify must stay false — verification is not optional")
}
}
func TestBuildTLSConfig_LoadsCAIntoRootPool(t *testing.T) {
dir := t.TempDir()
path, subject := writeTestCA(t, dir, "ca")
tlsCfg, err := BuildTLSConfig(&config.KafkaConfig{TLSCACertFile: path})
if err != nil {
t.Fatalf("BuildTLSConfig() error = %v, want nil", err)
}
if tlsCfg.RootCAs == nil {
t.Fatal("RootCAs is nil — the configured CA was not loaded")
}
if tlsCfg.InsecureSkipVerify {
t.Error("InsecureSkipVerify must stay false even with a custom CA")
}
// The custom CA must actually be in the pool, not merely a non-nil pool.
var found bool
for _, rawSubject := range tlsCfg.RootCAs.Subjects() { //nolint:staticcheck // Subjects() is the only way to inspect pool contents
if strings.Contains(string(rawSubject), subject.CommonName) {
found = true
break
}
}
if !found {
t.Errorf("configured CA %q not present in RootCAs", subject.CommonName)
}
}
// A broker chaining to a public root must keep working when a private CA is
// also configured — the pool has to be system roots PLUS ours, never ours
// alone. Asserted by cloning the system pool the same way BuildTLSConfig does
// and requiring the result to be a strict superset.
//
// Subjects() is empty on platforms where Go defers to the system verifier
// (macOS), so the comparison is made against a freshly cloned system pool
// rather than against a raw count, and is skipped where Go exposes no roots.
func TestBuildTLSConfig_CAAugmentsRatherThanReplacesSystemRoots(t *testing.T) {
systemPool, err := x509.SystemCertPool()
if err != nil || systemPool == nil {
t.Skip("no system cert pool on this platform")
}
systemSubjects := systemPool.Subjects() //nolint:staticcheck // see above
if len(systemSubjects) == 0 {
t.Skip("Go exposes no system roots on this platform (macOS defers to the system verifier)")
}
dir := t.TempDir()
path, _ := writeTestCA(t, dir, "ca")
tlsCfg, err := BuildTLSConfig(&config.KafkaConfig{TLSCACertFile: path})
if err != nil {
t.Fatalf("BuildTLSConfig() error = %v, want nil", err)
}
got := make(map[string]bool)
for _, s := range tlsCfg.RootCAs.Subjects() { //nolint:staticcheck // see above
got[string(s)] = true
}
for _, want := range systemSubjects {
if !got[string(want)] {
t.Fatalf("a system root was dropped from RootCAs — configuring a private CA must not "+
"replace the OS trust store (%d of %d system roots retained)", len(got)-1, len(systemSubjects))
}
}
if len(got) != len(systemSubjects)+1 {
t.Errorf("RootCAs has %d subjects, want %d (all system roots + 1 custom CA)",
len(got), len(systemSubjects)+1)
}
}
func TestBuildTLSConfig_MissingFileErrors(t *testing.T) {
_, err := BuildTLSConfig(&config.KafkaConfig{
TLSCACertFile: filepath.Join(t.TempDir(), "does-not-exist.pem"),
})
if err == nil {
t.Fatal("BuildTLSConfig() error = nil, want an error for a missing CA file")
}
if !strings.Contains(err.Error(), "read CA file") {
t.Errorf("error %q should name the failure as reading the CA file", err)
}
}
// A JKS truststore is the exact wrong-format case operators hit, since that is
// what Java-based Kafka tooling produces. The error must say so rather than
// failing later at connect time with an opaque TLS handshake error.
func TestBuildTLSConfig_NonPEMFileErrors(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "truststore.jks")
// First bytes of a real JKS file (magic number 0xFEEDFEED).
if err := os.WriteFile(path, []byte{0xFE, 0xED, 0xFE, 0xED, 0x00, 0x00, 0x00, 0x02}, 0o600); err != nil {
t.Fatalf("write file: %v", err)
}
_, err := BuildTLSConfig(&config.KafkaConfig{TLSCACertFile: path})
if err == nil {
t.Fatal("BuildTLSConfig() error = nil, want an error for a non-PEM file")
}
if !strings.Contains(err.Error(), "JKS") {
t.Errorf("error %q should point the operator at the JKS→PEM conversion", err)
}
}
// The realistic operator mistakes after the JKS case: a file that is valid PEM
// but carries no certificate, and a file that is a private key rather than the
// CA certificate. Both reach AppendCertsFromPEM == false and must be rejected at
// startup rather than producing a pool that silently verifies nothing.
func TestBuildTLSConfig_PEMWithoutCertificateErrors(t *testing.T) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("generate key: %v", err)
}
der, err := x509.MarshalECPrivateKey(key)
if err != nil {
t.Fatalf("marshal key: %v", err)
}
cases := []struct {
name string
filename string
contents []byte
}{
{"empty file", "empty.pem", nil},
{"no PEM blocks", "notes.pem", []byte("# CA goes here\n")},
{
"private key instead of certificate",
"ca-key.pem",
pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der}),
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), c.filename)
if err := os.WriteFile(path, c.contents, 0o600); err != nil {
t.Fatalf("write file: %v", err)
}
_, err := BuildTLSConfig(&config.KafkaConfig{TLSCACertFile: path})
if err == nil {
t.Fatal("BuildTLSConfig() error = nil, want an error when no certificate parses")
}
if !strings.Contains(err.Error(), "no certificates parsed") {
t.Errorf("error %q should say no certificates were parsed", err)
}
})
}
}
// A directory path is an easy misconfiguration when the CA is mounted from a
// Secret: pointing at the mount root rather than the key inside it.
func TestBuildTLSConfig_DirectoryPathErrors(t *testing.T) {
_, err := BuildTLSConfig(&config.KafkaConfig{TLSCACertFile: t.TempDir()})
if err == nil {
t.Fatal("BuildTLSConfig() error = nil, want an error when the CA path is a directory")
}
if !strings.Contains(err.Error(), "read CA file") {
t.Errorf("error %q should name the failure as reading the CA file", err)
}
}
// TLS policy must hold on both paths — with and without a configured CA.
func TestBuildTLSConfig_SetsMinVersionTLS12(t *testing.T) {
dir := t.TempDir()
path, _ := writeTestCA(t, dir, "ca")
for _, c := range []struct {
name string
cfg *config.KafkaConfig
}{
{"no CA file", &config.KafkaConfig{}},
{"with CA file", &config.KafkaConfig{TLSCACertFile: path}},
} {
t.Run(c.name, func(t *testing.T) {
tlsCfg, err := BuildTLSConfig(c.cfg)
if err != nil {
t.Fatalf("BuildTLSConfig() error = %v, want nil", err)
}
if tlsCfg.MinVersion != tls.VersionTLS12 {
t.Errorf("MinVersion = %#x, want %#x (TLS 1.2)", tlsCfg.MinVersion, tls.VersionTLS12)
}
})
}
}
// ServerName defaults to empty so Go verifies against the dial address; it is
// carried through only when the operator overrides it for an SNI mismatch.
func TestBuildTLSConfig_ServerNameOverride(t *testing.T) {
tlsCfg, err := BuildTLSConfig(&config.KafkaConfig{})
if err != nil {
t.Fatalf("BuildTLSConfig() error = %v, want nil", err)
}
if tlsCfg.ServerName != "" {
t.Errorf("ServerName = %q, want empty so verification uses the dial address", tlsCfg.ServerName)
}
tlsCfg, err = BuildTLSConfig(&config.KafkaConfig{TLSServerName: "broker.internal"})
if err != nil {
t.Fatalf("BuildTLSConfig() error = %v, want nil", err)
}
if tlsCfg.ServerName != "broker.internal" {
t.Errorf("ServerName = %q, want %q", tlsCfg.ServerName, "broker.internal")
}
}
// SASL_SSL is the protocol the SCRAM-SHA-512 setup uses: enabling SASL must
// enable TLS and carry the CA through, even when kafka.tls is left false.
func TestGetSaramaConfig_SASLEnablesTLSWithCA(t *testing.T) {
dir := t.TempDir()
path, _ := writeTestCA(t, dir, "ca")
cfg := &config.KafkaConfig{
TLS: false, // deliberately false — UseSASL must still force TLS
UseSASL: true,
SASLMechanism: sarama.SASLTypeSCRAMSHA512,
SASLUser: "flexprice",
SASLPassword: "secret",
TLSCACertFile: path,
}
saramaCfg, err := GetSaramaConfig(cfg)
if err != nil {
t.Fatalf("GetSaramaConfig() error = %v, want nil", err)
}
if !saramaCfg.Net.TLS.Enable {
t.Error("Net.TLS.Enable = false, want true — SASL implies SASL_SSL")
}
if saramaCfg.Net.TLS.Config == nil || saramaCfg.Net.TLS.Config.RootCAs == nil {
t.Fatal("custom CA was not applied to the SASL TLS config")
}
if !saramaCfg.Net.SASL.Enable {
t.Error("Net.SASL.Enable = false, want true")
}
if saramaCfg.Net.SASL.Mechanism != sarama.SASLTypeSCRAMSHA512 {
t.Errorf("Mechanism = %q, want %q", saramaCfg.Net.SASL.Mechanism, sarama.SASLTypeSCRAMSHA512)
}
if saramaCfg.Net.SASL.SCRAMClientGeneratorFunc == nil {
t.Fatal("SCRAMClientGeneratorFunc is nil — SCRAM auth would fail at handshake")
}
// The generator must produce a SHA-512 client; a SHA-256 client against a
// SHA-512 broker fails with an opaque authentication error.
client, ok := saramaCfg.Net.SASL.SCRAMClientGeneratorFunc().(*XDGSCRAMClient)
if !ok {
t.Fatal("SCRAMClientGeneratorFunc did not return an *XDGSCRAMClient")
}
if got := client.HashGeneratorFcn().Size(); got != 64 {
t.Errorf("SCRAM hash size = %d bytes, want 64 (SHA-512)", got)
}
}
func TestGetSaramaConfig_BadCAFilePropagatesError(t *testing.T) {
_, err := GetSaramaConfig(&config.KafkaConfig{
UseSASL: true,
SASLMechanism: sarama.SASLTypeSCRAMSHA512,
TLSCACertFile: filepath.Join(t.TempDir(), "missing.pem"),
})
if err == nil {
t.Fatal("GetSaramaConfig() error = nil, want the CA failure surfaced as a startup error")
}
}
// Without SASL or TLS the CA is irrelevant and must not be read — a plaintext
// local broker should not fail because a stale path is configured.
func TestGetSaramaConfig_PlaintextIgnoresCA(t *testing.T) {
saramaCfg, err := GetSaramaConfig(&config.KafkaConfig{
TLS: false,
UseSASL: false,
TLSCACertFile: filepath.Join(t.TempDir(), "missing.pem"),
})
if err != nil {
t.Fatalf("GetSaramaConfig() error = %v, want nil for a plaintext broker", err)
}
if saramaCfg.Net.TLS.Enable {
t.Error("Net.TLS.Enable = true, want false for a plaintext broker")
}
}
// TLS without SASL is a valid combination (a broker on 9094 with no auth), and
// must honour the CA too.
func TestGetSaramaConfig_TLSWithoutSASLUsesCA(t *testing.T) {
dir := t.TempDir()
path, _ := writeTestCA(t, dir, "ca")
saramaCfg, err := GetSaramaConfig(&config.KafkaConfig{
TLS: true,
UseSASL: false,
TLSCACertFile: path,
})
if err != nil {
t.Fatalf("GetSaramaConfig() error = %v, want nil", err)
}
if !saramaCfg.Net.TLS.Enable {
t.Fatal("Net.TLS.Enable = false, want true")
}
if saramaCfg.Net.TLS.Config == nil || saramaCfg.Net.TLS.Config.RootCAs == nil {
t.Error("custom CA was not applied when TLS is enabled without SASL")
}
}