1
0
mirror of https://github.com/jhillyerd/inbucket.git synced 2026-10-29 21:52:31 +00:00
Files
go-inbucket/pkg/server/tlscert/reloader_test.go
T
2026-10-05 14:49:35 -07:00

255 lines
8.0 KiB
Go

package tlscert
import (
"bytes"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/rs/zerolog"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewReloaderMissingFiles(t *testing.T) {
dir := t.TempDir()
_, err := NewReloader(filepath.Join(dir, "cert.pem"), filepath.Join(dir, "key.pem"), testLogger(t))
require.Error(t, err)
}
func TestNewReloaderMismatchedPair(t *testing.T) {
dir := t.TempDir()
certA, _ := generatePair(t, "a")
_, keyB := generatePair(t, "b")
certFile, keyFile := filepath.Join(dir, "cert.pem"), filepath.Join(dir, "key.pem")
writeFile(t, certFile, certA, 1)
writeFile(t, keyFile, keyB, 1)
_, err := NewReloader(certFile, keyFile, testLogger(t))
require.Error(t, err)
}
func TestGetCertificateReloadsOnChange(t *testing.T) {
dir := t.TempDir()
certFile, keyFile := filepath.Join(dir, "cert.pem"), filepath.Join(dir, "key.pem")
writePairFiles(t, certFile, keyFile, "a", 1)
r, err := NewReloader(certFile, keyFile, testLogger(t))
require.NoError(t, err)
assert.Equal(t, "a", commonName(t, r))
writePairFiles(t, certFile, keyFile, "b", 2)
assert.Equal(t, "b", commonName(t, r))
}
func TestGetCertificateKeepsPreviousOnFailure(t *testing.T) {
tcs := []struct {
name string
mangle func(t *testing.T, certFile, keyFile string)
}{
{
name: "invalid certificate",
mangle: func(t *testing.T, certFile, keyFile string) {
t.Helper()
writeFile(t, certFile, []byte("not a certificate"), 2)
},
},
{
name: "certificate rotated before key",
mangle: func(t *testing.T, certFile, keyFile string) {
t.Helper()
certB, _ := generatePair(t, "b")
writeFile(t, certFile, certB, 2)
},
},
{
name: "files removed",
mangle: func(t *testing.T, certFile, keyFile string) {
t.Helper()
require.NoError(t, os.Remove(certFile))
require.NoError(t, os.Remove(keyFile))
},
},
}
for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
certFile, keyFile := filepath.Join(dir, "cert.pem"), filepath.Join(dir, "key.pem")
writePairFiles(t, certFile, keyFile, "a", 1)
r, err := NewReloader(certFile, keyFile, testLogger(t))
require.NoError(t, err)
tc.mangle(t, certFile, keyFile)
assert.Equal(t, "a", commonName(t, r))
// A valid pair written afterward is picked up.
writePairFiles(t, certFile, keyFile, "c", 3)
assert.Equal(t, "c", commonName(t, r))
})
}
}
// TestGetCertificateRetriesUnchangedFiles covers a pair that fails to load and is then fixed
// without changing its files' modification time or size, as a chmod or chown would.
func TestGetCertificateRetriesUnchangedFiles(t *testing.T) {
dir := t.TempDir()
certFile, keyFile := filepath.Join(dir, "cert.pem"), filepath.Join(dir, "key.pem")
writePairFiles(t, certFile, keyFile, "a", 1)
r, err := NewReloader(certFile, keyFile, testLogger(t))
require.NoError(t, err)
now := time.Now()
r.now = func() time.Time { return now }
// The new key is unreadable on the first attempt.
certB, keyB := generatePair(t, "b")
writeFile(t, certFile, certB, 2)
writeFile(t, keyFile, bytes.Repeat([]byte("x"), len(keyB)), 2)
assert.Equal(t, "a", commonName(t, r))
// Fixing the key leaves its modification time and size unchanged.
writeFile(t, keyFile, keyB, 2)
assert.Equal(t, "a", commonName(t, r), "retried before retryInterval elapsed")
now = now.Add(r.retryInterval)
assert.Equal(t, "b", commonName(t, r))
}
// TestGetCertificateFailureWarnings verifies that a persistent failure is attempted, and logged,
// at most once per retryInterval.
func TestGetCertificateFailureWarnings(t *testing.T) {
dir := t.TempDir()
certFile, keyFile := filepath.Join(dir, "cert.pem"), filepath.Join(dir, "key.pem")
writePairFiles(t, certFile, keyFile, "a", 1)
var logs bytes.Buffer
r, err := NewReloader(certFile, keyFile, zerolog.New(&logs))
require.NoError(t, err)
now := time.Now()
r.now = func() time.Time { return now }
warnings := func() int { return strings.Count(logs.String(), "Failed to reload X509 KeyPair") }
require.NoError(t, os.Remove(certFile))
for range 3 {
assert.Equal(t, "a", commonName(t, r))
}
assert.Equal(t, 1, warnings())
now = now.Add(r.retryInterval)
assert.Equal(t, "a", commonName(t, r))
assert.Equal(t, 2, warnings())
}
// TestGetCertificateSymlinkSwap mirrors how the kubelet updates a mounted Secret: the files are
// symlinks through a ..data symlink, which is atomically repointed at a new directory.
func TestGetCertificateSymlinkSwap(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Windows cannot rename over an existing directory symlink")
}
dir := t.TempDir()
writePairFiles(t, filepath.Join(dir, "v1", "cert.pem"), filepath.Join(dir, "v1", "key.pem"), "a", 1)
require.NoError(t, os.Symlink("v1", filepath.Join(dir, "..data")))
certFile, keyFile := filepath.Join(dir, "cert.pem"), filepath.Join(dir, "key.pem")
require.NoError(t, os.Symlink(filepath.Join("..data", "cert.pem"), certFile))
require.NoError(t, os.Symlink(filepath.Join("..data", "key.pem"), keyFile))
r, err := NewReloader(certFile, keyFile, testLogger(t))
require.NoError(t, err)
assert.Equal(t, "a", commonName(t, r))
writePairFiles(t, filepath.Join(dir, "v2", "cert.pem"), filepath.Join(dir, "v2", "key.pem"), "b", 2)
require.NoError(t, os.Symlink("v2", filepath.Join(dir, "..data_tmp")))
require.NoError(t, os.Rename(filepath.Join(dir, "..data_tmp"), filepath.Join(dir, "..data")))
assert.Equal(t, "b", commonName(t, r))
}
func TestGetCertificateConcurrent(t *testing.T) {
dir := t.TempDir()
certFile, keyFile := filepath.Join(dir, "cert.pem"), filepath.Join(dir, "key.pem")
writePairFiles(t, certFile, keyFile, "a", 1)
r, err := NewReloader(certFile, keyFile, testLogger(t))
require.NoError(t, err)
done := make(chan struct{})
for range 8 {
go func() {
defer func() { done <- struct{}{} }()
for range 50 {
cert, err := r.GetCertificate(nil)
assert.NoError(t, err)
assert.NotNil(t, cert)
}
}()
}
writePairFiles(t, certFile, keyFile, "b", 2)
for range 8 {
<-done
}
assert.Equal(t, "b", commonName(t, r))
}
func testLogger(t *testing.T) zerolog.Logger {
t.Helper()
return zerolog.New(zerolog.NewTestWriter(t))
}
// commonName returns the subject common name of the certificate r currently serves.
func commonName(t *testing.T, r *Reloader) string {
t.Helper()
cert, err := r.GetCertificate(&tls.ClientHelloInfo{})
require.NoError(t, err)
require.NotNil(t, cert)
leaf, err := x509.ParseCertificate(cert.Certificate[0])
require.NoError(t, err)
return leaf.Subject.CommonName
}
// writePairFiles writes a new key pair for commonName, stamping both files with a modification
// time derived from version so each version is distinguishable regardless of timestamp
// granularity.
func writePairFiles(t *testing.T, certFile, keyFile, commonName string, version int) {
t.Helper()
certPEM, keyPEM := generatePair(t, commonName)
writeFile(t, certFile, certPEM, version)
writeFile(t, keyFile, keyPEM, version)
}
func writeFile(t *testing.T, name string, data []byte, version int) {
t.Helper()
require.NoError(t, os.MkdirAll(filepath.Dir(name), 0o700))
require.NoError(t, os.WriteFile(name, data, 0o600))
mtime := time.Date(2026, 1, 1, 0, 0, version, 0, time.UTC)
require.NoError(t, os.Chtimes(name, mtime, mtime))
}
// generatePair returns PEM encoded certificate and private key for a self-signed certificate.
func generatePair(t *testing.T, commonName string) (certPEM, keyPEM []byte) {
t.Helper()
priv, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: commonName},
}
der, err := x509.CreateCertificate(rand.Reader, template, template, &priv.PublicKey, priv)
require.NoError(t, err)
certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
keyPEM = pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)})
return certPEM, keyPEM
}