package acmecert import ( "context" "crypto/rand" "crypto/rsa" "crypto/x509" "crypto/x509/pkix" "encoding/pem" "math/big" "net/http" "net/http/httptest" "os" "path/filepath" "testing" "time" "github.com/go-acme/lego/v4/registration" "gopkg.in/ini.v1" "mailgoserver/internal/tlsutil" ) func TestLoadOrCreateAccountGeneratesAndPersistsKey(t *testing.T) { dir := t.TempDir() user1, err := loadOrCreateAccount(dir, "admin@example.com") if err != nil { t.Fatal(err) } if user1.Registration != nil { t.Fatal("expected no registration on a brand-new account") } if user1.GetPrivateKey() == nil { t.Fatal("expected a generated private key") } // Reload: must reuse the same key, not generate a new one. user2, err := loadOrCreateAccount(dir, "admin@example.com") if err != nil { t.Fatal(err) } keyBytes1, _ := os.ReadFile(accountKeyPath(dir)) if len(keyBytes1) == 0 { t.Fatal("expected a persisted key file") } // Re-reading shouldn't rewrite the file with different bytes. keyBytes2, _ := os.ReadFile(accountKeyPath(dir)) if string(keyBytes1) != string(keyBytes2) { t.Fatal("expected the same key to be reused across loads") } _ = user2 } func TestSaveRegistrationRoundTrip(t *testing.T) { dir := t.TempDir() reg := ®istration.Resource{URI: "https://example.com/acme/acct/123"} if err := saveRegistration(dir, reg); err != nil { t.Fatal(err) } user, err := loadOrCreateAccount(dir, "admin@example.com") if err != nil { t.Fatal(err) } if user.Registration == nil || user.Registration.URI != reg.URI { t.Fatalf("expected registration to round-trip, got %+v", user.Registration) } } // writeFixtureCert writes a minimal self-signed cert with the given expiry to certFile // (no matching key needed — NeedsRenewal only reads the cert). func writeFixtureCert(t *testing.T, certFile string, notAfter time.Time) { t.Helper() priv, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatal(err) } tmpl := x509.Certificate{ SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "test"}, NotBefore: time.Now().Add(-time.Hour), NotAfter: notAfter, } der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &priv.PublicKey, priv) if err != nil { t.Fatal(err) } pemBytes := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) if err := os.WriteFile(certFile, pemBytes, 0o644); err != nil { t.Fatal(err) } } func TestNeedsRenewal(t *testing.T) { dir := t.TempDir() mgr := &Manager{Cfg: ini.Empty(), CertFile: filepath.Join(dir, "server.crt")} writeFixtureCert(t, mgr.CertFile, time.Now().Add(200*24*time.Hour)) if needs, err := mgr.NeedsRenewal(); err != nil || needs { t.Fatalf("expected NeedsRenewal=false for a cert expiring in 200 days, got %v (err=%v)", needs, err) } writeFixtureCert(t, mgr.CertFile, time.Now().Add(5*24*time.Hour)) if needs, err := mgr.NeedsRenewal(); err != nil || !needs { t.Fatalf("expected NeedsRenewal=true for a cert expiring in 5 days, got %v (err=%v)", needs, err) } } func TestNeedsRenewalTrueForSelfSignedPlaceholderEvenWithLongExpiry(t *testing.T) { dir := t.TempDir() certFile := filepath.Join(dir, "server.crt") keyFile := filepath.Join(dir, "server.key") if err := tlsutil.GenerateSelfSignedCert(certFile, keyFile); err != nil { t.Fatal(err) } mgr := &Manager{Cfg: ini.Empty(), CertFile: certFile} // tlsutil's self-signed cert is valid for a year — a pure expiry check would say // "no renewal needed," which is exactly the bug: a restart must still recognize // this as "no real certificate obtained yet" and trigger the first real obtain. needs, err := mgr.NeedsRenewal() if err != nil { t.Fatal(err) } if !needs { t.Fatal("expected NeedsRenewal=true for the self-signed placeholder despite its long expiry") } } func TestNeedsRenewalUsesShortThresholdForHTTP01IncludeIP(t *testing.T) { dir := t.TempDir() certFile := filepath.Join(dir, "server.crt") cfg := ini.Empty() cfg.Section("LetsEncryptHTTP").Key("include_ip").SetValue("true") mgr := &Manager{Cfg: cfg, Section: "LetsEncryptHTTP", ChallengeType: "http-01", CertFile: certFile} // 4 days left: not within the 1-day short threshold, even though it WOULD be // within the normal 30-day one — proves the short threshold is actually in effect. writeFixtureCert(t, certFile, time.Now().Add(4*24*time.Hour)) if needs, err := mgr.NeedsRenewal(); err != nil || needs { t.Fatalf("expected NeedsRenewal=false with 4 days left under the short threshold, got %v (err=%v)", needs, err) } // 12 hours left: within the 1-day short threshold. writeFixtureCert(t, certFile, time.Now().Add(12*time.Hour)) if needs, err := mgr.NeedsRenewal(); err != nil || !needs { t.Fatalf("expected NeedsRenewal=true with 12 hours left, got %v (err=%v)", needs, err) } } func TestNeedsRenewalUsesNormalThresholdForHTTP01WithoutIncludeIP(t *testing.T) { dir := t.TempDir() certFile := filepath.Join(dir, "server.crt") cfg := ini.Empty() cfg.Section("LetsEncryptHTTP").Key("include_ip").SetValue("false") mgr := &Manager{Cfg: cfg, Section: "LetsEncryptHTTP", ChallengeType: "http-01", CertFile: certFile} // 4 days left: within the normal 30-day threshold — confirms include_ip=false // gets the normal threshold, not the short one. writeFixtureCert(t, certFile, time.Now().Add(4*24*time.Hour)) if needs, err := mgr.NeedsRenewal(); err != nil || !needs { t.Fatalf("expected NeedsRenewal=true with 4 days left under the normal threshold, got %v (err=%v)", needs, err) } } func TestNeedsRenewalMissingCertIsTrue(t *testing.T) { mgr := &Manager{Cfg: ini.Empty(), CertFile: filepath.Join(t.TempDir(), "does-not-exist.crt")} needs, err := mgr.NeedsRenewal() if err != nil { t.Fatal(err) } if !needs { t.Fatal("expected NeedsRenewal=true when no certificate exists yet") } } func TestBuildDNSProviderUnknownName(t *testing.T) { cfg := ini.Empty() cfg.Section("LetsEncrypt").Key("dns_provider").SetValue("not-a-real-provider") if _, err := buildDNSProvider(cfg); err == nil { t.Fatal("expected an error for an unknown DNS provider name") } } func TestBuildDNSProviderDigitalOceanRequiresToken(t *testing.T) { cfg := ini.Empty() cfg.Section("LetsEncrypt").Key("dns_provider").SetValue("digitalocean") // AuthToken deliberately left blank — DigitalOcean's constructor validates this // locally (no network call) and errors immediately. if _, err := buildDNSProvider(cfg); err == nil { t.Fatal("expected an error when digitalocean_api_token is blank") } } func TestDetectWANIP(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("203.0.113.42\n")) })) defer srv.Close() old := wanIPServiceURL wanIPServiceURL = srv.URL defer func() { wanIPServiceURL = old }() ip, err := DetectWANIP(context.Background()) if err != nil { t.Fatal(err) } if ip != "203.0.113.42" { t.Fatalf("expected trimmed IP %q, got %q", "203.0.113.42", ip) } } func TestResolveIdentifiersDNS01IgnoresIPSettings(t *testing.T) { cfg := ini.Empty() sec := cfg.Section("LetsEncrypt") sec.Key("domains").SetValue("mail.example.com") sec.Key("include_ip").SetValue("true") // should be ignored outside http-01 mgr := &Manager{Cfg: cfg, Section: "LetsEncrypt", ChallengeType: "dns-01"} got, err := mgr.resolveIdentifiers(context.Background()) if err != nil { t.Fatal(err) } if len(got) != 1 || got[0] != "mail.example.com" { t.Fatalf("expected just the domain, got %v", got) } } func TestDomainsAreLowercased(t *testing.T) { cfg := ini.Empty() sec := cfg.Section("LetsEncrypt") sec.Key("domains").SetValue("adsl-1-2-3-4.example.ISP.COM, Mail.Example.com") mgr := &Manager{Cfg: cfg, Section: "LetsEncrypt", ChallengeType: "dns-01"} got := mgr.domains() want := []string{"adsl-1-2-3-4.example.isp.com", "mail.example.com"} if len(got) != 2 || got[0] != want[0] || got[1] != want[1] { t.Fatalf("expected lowercased %v, got %v", want, got) } } func TestResolveIdentifiersHTTP01WithManualIPOverride(t *testing.T) { cfg := ini.Empty() sec := cfg.Section("LetsEncryptHTTP") sec.Key("domains").SetValue("mail.example.com") sec.Key("include_ip").SetValue("true") sec.Key("ip_override").SetValue("198.51.100.7") mgr := &Manager{Cfg: cfg, Section: "LetsEncryptHTTP", ChallengeType: "http-01"} got, err := mgr.resolveIdentifiers(context.Background()) if err != nil { t.Fatal(err) } want := []string{"mail.example.com", "198.51.100.7"} if len(got) != 2 || got[0] != want[0] || got[1] != want[1] { t.Fatalf("expected %v, got %v", want, got) } } func TestResolveIdentifiersHTTP01AutodetectsIPWhenOverrideBlank(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("203.0.113.99")) })) defer srv.Close() old := wanIPServiceURL wanIPServiceURL = srv.URL defer func() { wanIPServiceURL = old }() cfg := ini.Empty() sec := cfg.Section("LetsEncryptHTTP") sec.Key("include_ip").SetValue("true") mgr := &Manager{Cfg: cfg, Section: "LetsEncryptHTTP", ChallengeType: "http-01"} got, err := mgr.resolveIdentifiers(context.Background()) if err != nil { t.Fatal(err) } if len(got) != 1 || got[0] != "203.0.113.99" { t.Fatalf("expected autodetected IP as the sole identifier, got %v", got) } } func TestResolveIdentifiersEmptyIsError(t *testing.T) { mgr := &Manager{Cfg: ini.Empty(), Section: "LetsEncrypt", ChallengeType: "dns-01"} if _, err := mgr.resolveIdentifiers(context.Background()); err == nil { t.Fatal("expected an error when no domains and no IP are configured") } } func TestBuildDNSProviderCloudflare(t *testing.T) { cfg := ini.Empty() sec := cfg.Section("LetsEncrypt") sec.Key("dns_provider").SetValue("cloudflare") sec.Key("cloudflare_api_token").SetValue("fake-token-for-local-construction-only") provider, err := buildDNSProvider(cfg) if err != nil { t.Fatalf("expected local provider construction to succeed without a network call, got: %v", err) } if provider == nil { t.Fatal("expected a non-nil provider") } }