Files
xboard-node/internal/cert/cert_test.go
T

237 lines
7.9 KiB
Go

package cert
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"path/filepath"
"testing"
"time"
"github.com/caddyserver/certmagic"
"github.com/cedar2025/xboard-node/internal/config"
)
// generateSelfSignedPair returns a fresh self-signed certificate and matching
// private key in PEM form, suitable for storing as a libdns/certmagic site
// resource in tests.
func generateSelfSignedPair(t *testing.T, domain string) (certPEM, keyPEM []byte) {
t.Helper()
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("gen key: %v", err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: domain},
DNSNames: []string{domain},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv)
if err != nil {
t.Fatalf("create cert: %v", err)
}
certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
keyDER, err := x509.MarshalECPrivateKey(priv)
if err != nil {
t.Fatalf("marshal key: %v", err)
}
keyPEM = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
return
}
// TestLoadPEMFromStorage_RestartScenario simulates the warm-restart bug fix:
// certmagic.ObtainCertSync returns nil without firing cert_obtained when a
// valid cert already exists in storage. The manager must still populate its
// in-memory PEM cache by reading storage explicitly.
func TestLoadPEMFromStorage_RestartScenario(t *testing.T) {
dir := t.TempDir()
storage := &certmagic.FileStorage{Path: dir}
const domain = "example.test"
const issuerKey = "test-issuer"
certPEM, keyPEM := generateSelfSignedPair(t, domain)
ctx := context.Background()
if err := storage.Store(ctx, certmagic.StorageKeys.SiteCert(issuerKey, domain), certPEM); err != nil {
t.Fatalf("seed cert: %v", err)
}
if err := storage.Store(ctx, certmagic.StorageKeys.SitePrivateKey(issuerKey, domain), keyPEM); err != nil {
t.Fatalf("seed key: %v", err)
}
m := NewManager(config.CertConfig{})
if m.HasCert() {
t.Fatal("manager should start with no cert in memory")
}
if err := m.loadPEMFromStorage(ctx, storage, issuerKey, domain); err != nil {
t.Fatalf("loadPEMFromStorage: %v", err)
}
if !m.HasCert() {
t.Fatal("expected HasCert() == true after explicit load")
}
got := m.TLSCert()
if string(got.CertPEM) != string(certPEM) {
t.Errorf("certPEM mismatch")
}
if string(got.KeyPEM) != string(keyPEM) {
t.Errorf("keyPEM mismatch")
}
}
// TestLoadPEMFromStorage_RenewalRefresh confirms that calling load a second
// time with newer bytes on disk swaps the in-memory cache atomically — this
// is the renewal hot-reload path triggered from the cert_obtained event.
func TestLoadPEMFromStorage_RenewalRefresh(t *testing.T) {
dir := t.TempDir()
storage := &certmagic.FileStorage{Path: dir}
const domain = "example.test"
const issuerKey = "test-issuer"
ctx := context.Background()
certV1, keyV1 := generateSelfSignedPair(t, domain)
if err := storage.Store(ctx, certmagic.StorageKeys.SiteCert(issuerKey, domain), certV1); err != nil {
t.Fatalf("seed v1 cert: %v", err)
}
if err := storage.Store(ctx, certmagic.StorageKeys.SitePrivateKey(issuerKey, domain), keyV1); err != nil {
t.Fatalf("seed v1 key: %v", err)
}
m := NewManager(config.CertConfig{})
if err := m.loadPEMFromStorage(ctx, storage, issuerKey, domain); err != nil {
t.Fatalf("initial load: %v", err)
}
certV2, keyV2 := generateSelfSignedPair(t, domain)
if err := storage.Store(ctx, certmagic.StorageKeys.SiteCert(issuerKey, domain), certV2); err != nil {
t.Fatalf("seed v2 cert: %v", err)
}
if err := storage.Store(ctx, certmagic.StorageKeys.SitePrivateKey(issuerKey, domain), keyV2); err != nil {
t.Fatalf("seed v2 key: %v", err)
}
if err := m.loadPEMFromStorage(ctx, storage, issuerKey, domain); err != nil {
t.Fatalf("refresh load: %v", err)
}
got := m.TLSCert()
if string(got.CertPEM) != string(certV2) {
t.Error("expected v2 cert after refresh")
}
if string(got.KeyPEM) != string(keyV2) {
t.Error("expected v2 key after refresh")
}
}
// TestLoadPEMFromStorage_MissingFiles ensures we surface a clear error when
// storage doesn't have the expected keys (defensive — should not happen in
// practice because ObtainCertSync guarantees presence on success).
func TestLoadPEMFromStorage_MissingFiles(t *testing.T) {
dir := t.TempDir()
storage := &certmagic.FileStorage{Path: dir}
m := NewManager(config.CertConfig{})
err := m.loadPEMFromStorage(context.Background(), storage, "test-issuer", "example.test")
if err == nil {
t.Fatal("expected error when storage is empty")
}
}
// TestLoadPEMFromStorage_RejectsMismatchedPair guards against silently loading
// a cert and key that do not pair (e.g. partial write or external tampering).
func TestLoadPEMFromStorage_RejectsMismatchedPair(t *testing.T) {
dir := t.TempDir()
storage := &certmagic.FileStorage{Path: dir}
const domain = "example.test"
const issuerKey = "test-issuer"
ctx := context.Background()
certA, _ := generateSelfSignedPair(t, domain)
_, keyB := generateSelfSignedPair(t, domain)
if err := storage.Store(ctx, certmagic.StorageKeys.SiteCert(issuerKey, domain), certA); err != nil {
t.Fatalf("seed cert: %v", err)
}
if err := storage.Store(ctx, certmagic.StorageKeys.SitePrivateKey(issuerKey, domain), keyB); err != nil {
t.Fatalf("seed key: %v", err)
}
m := NewManager(config.CertConfig{})
err := m.loadPEMFromStorage(ctx, storage, issuerKey, domain)
if err == nil {
t.Fatal("expected error for mismatched cert/key pair")
}
if m.HasCert() {
t.Error("manager should not store invalid pair")
}
_ = filepath.Join(dir, "stub")
}
// TestACMEFingerprint covers the config-equality logic that decides whether
// Reconfigure must tear down a running certmagic instance.
func TestACMEFingerprint(t *testing.T) {
base := config.CertConfig{
CertMode: "dns",
Domain: "a.example.com",
Email: "ops@example.com",
DNSProvider: "cloudflare",
DNSEnv: map[string]string{"CLOUDFLARE_API_TOKEN": "tok"},
}
cases := []struct {
name string
mutate func(*config.CertConfig)
differs bool
}{
{"same", func(*config.CertConfig) {}, false},
{"domain", func(c *config.CertConfig) { c.Domain = "b.example.com" }, true},
{"mode", func(c *config.CertConfig) { c.CertMode = "http" }, true},
{"email", func(c *config.CertConfig) { c.Email = "other@example.com" }, true},
{"provider", func(c *config.CertConfig) { c.DNSProvider = "alidns" }, true},
{"env_value", func(c *config.CertConfig) { c.DNSEnv = map[string]string{"CLOUDFLARE_API_TOKEN": "other"} }, true},
{"env_key_only", func(c *config.CertConfig) { c.DNSEnv = map[string]string{"OTHER": "tok"} }, true},
}
baseFp := acmeFingerprint(base)
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cfg := base
if tc.mutate != nil {
// deep-copy DNSEnv before mutation to avoid sharing
cfg.DNSEnv = map[string]string{}
for k, v := range base.DNSEnv {
cfg.DNSEnv[k] = v
}
tc.mutate(&cfg)
}
got := acmeFingerprint(cfg)
if (got != baseFp) != tc.differs {
t.Errorf("differs=%v but got==base=%v", tc.differs, got == baseFp)
}
})
}
}
// TestACMEFingerprintEnvOrderStable ensures map iteration order does not
// affect the fingerprint (must be sorted internally).
func TestACMEFingerprintEnvOrderStable(t *testing.T) {
a := config.CertConfig{
CertMode: "dns", Domain: "x", DNSProvider: "p",
DNSEnv: map[string]string{"A": "1", "B": "2", "C": "3"},
}
b := config.CertConfig{
CertMode: "dns", Domain: "x", DNSProvider: "p",
DNSEnv: map[string]string{"C": "3", "A": "1", "B": "2"},
}
if acmeFingerprint(a) != acmeFingerprint(b) {
t.Fatal("fingerprint not order-independent")
}
}