// SPDX-FileCopyrightText: 2026 Echolot contributors // SPDX-License-Identifier: GPL-3.0-or-later package certreload import ( "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/x509" "crypto/x509/pkix" "encoding/pem" "math/big" "os" "path/filepath" "testing" "time" ) func writePair(t *testing.T, dir, cn string) (string, string) { t.Helper() key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { t.Fatal(err) } tmpl := x509.Certificate{ SerialNumber: big.NewInt(time.Now().UnixNano()), Subject: pkix.Name{CommonName: cn}, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(24 * time.Hour), } der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &key.PublicKey, key) if err != nil { t.Fatal(err) } certPath := filepath.Join(dir, "cert.pem") keyPath := filepath.Join(dir, "key.pem") cb := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) kb, _ := x509.MarshalECPrivateKey(key) if err := os.WriteFile(certPath, cb, 0o600); err != nil { t.Fatal(err) } if err := os.WriteFile(keyPath, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: kb}), 0o600); err != nil { t.Fatal(err) } return certPath, keyPath } func TestBadPathsFailAtStartupNotAtHandshake(t *testing.T) { if _, err := New("/nonexistent/cert.pem", "/nonexistent/key.pem"); err == nil { t.Fatal("a missing certificate was accepted; the failure would surface as an " + "unexplained connection error at the client instead") } } // The whole point: a renewal that drops new files in place is picked up without a restart and // without a reload hook that can silently stop working. func TestANewCertificateOnDiskIsPickedUp(t *testing.T) { dir := t.TempDir() certPath, keyPath := writePair(t, dir, "first") r, err := New(certPath, keyPath) if err != nil { t.Fatal(err) } r.interval = 0 // check on every handshake, rather than waiting out the rate limit got, err := r.getCertificate(nil) if err != nil { t.Fatal(err) } first := got.Leaf time.Sleep(10 * time.Millisecond) // ensure a distinct mtime writePair(t, dir, "second") got, err = r.getCertificate(nil) if err != nil { t.Fatal(err) } if got.Leaf != nil && first != nil && got.Leaf.SerialNumber.Cmp(first.SerialNumber) == 0 { t.Fatal("the replaced certificate was not picked up") } } // Renewal tools write the certificate and the key separately, so there is a window where the two // do not match. Serving the previous certificate through it beats serving none. func TestAHalfWrittenPairKeepsTheOldCertificate(t *testing.T) { dir := t.TempDir() certPath, keyPath := writePair(t, dir, "good") r, err := New(certPath, keyPath) if err != nil { t.Fatal(err) } r.interval = 0 time.Sleep(10 * time.Millisecond) if err := os.WriteFile(certPath, []byte("-----BEGIN CERTIFICATE-----\ntruncated\n"), 0o600); err != nil { t.Fatal(err) } got, err := r.getCertificate(nil) if err != nil { t.Fatalf("a torn write took the listener down: %v", err) } if got == nil { t.Fatal("no certificate served during a torn write") } } func TestExpiryIsVisible(t *testing.T) { dir := t.TempDir() certPath, keyPath := writePair(t, dir, "x") r, err := New(certPath, keyPath) if err != nil { t.Fatal(err) } if got := r.NotAfter(); got.IsZero() || time.Until(got) > 48*time.Hour { t.Fatalf("expiry not reported sensibly: %v", got) } }