package certificate import ( "crypto/x509" "encoding/pem" "fmt" "os" "strings" "testing" "time" "helix-proxy/internal/config" "helix-proxy/internal/store" ) func TestGenerateSelfSignedTestCert(t *testing.T) { domains := []string{"foo.example.com", "bar.example.com"} certPEM, keyPEM, err := generateSelfSignedTestCert(domains) if err != nil { t.Fatalf("generate: %v", err) } if !strings.Contains(string(certPEM), "BEGIN CERTIFICATE") { t.Error("no cert pem marker") } if !strings.Contains(string(keyPEM), "BEGIN RSA PRIVATE KEY") { t.Error("no key pem marker") } // parse and check block, _ := pem.Decode(certPEM) if block == nil { t.Fatal("decode failed") } cert, err := x509.ParseCertificate(block.Bytes) if err != nil { t.Fatalf("parse: %v", err) } if cert.NotAfter.Sub(cert.NotBefore) < 89*24*time.Hour { t.Error("validity too short") } found := false for _, d := range cert.DNSNames { if d == "foo.example.com" { found = true } } if !found { t.Error("dns names not in cert") } } func TestIssueTestCertPath(t *testing.T) { // use temp data dir, reset config config.ResetForTest() tmp := t.TempDir() t.Setenv("DATA_DIR", tmp) // ensure dirs so writes don't fail _ = config.EnsureDataDirs() // minimal store: use New which is bbolt in the tmp (via reset+env) st, err := store.New() if err != nil { t.Fatalf("new store: %v", err) } if c, ok := st.(interface{ Close() error }); ok { defer c.Close() } cm := NewManager(st) // create a cert record (simulating what API would do) cert := store.Certificate{ Provider: "letsencrypt", NiceName: "test local", DomainNames: []string{"mytest.example.com"}, // any domain; in dev mode all LE become test certs Meta: map[string]any{}, } created, err := st.CreateCertificate(cert) if err != nil { t.Fatalf("create cert: %v", err) } // dev mode => all letsencrypt certs are test/self-signed t.Setenv("PROXY_MODE", "development") email := cm.GetEmailForCert(created) if err := cm.Issue(created, email); err != nil { t.Fatalf("Issue test cert: %v", err) } // reload from store got, ok := st.GetCertificate(created.ID) if !ok { t.Fatal("cert not found after issue") } if got.ExpiresOn == "" { t.Error("no expires set") } if v, _ := got.Meta["test_cert"].(bool); !v { t.Error("test_cert marker not set") } if _, ok := got.Meta["certificate"]; !ok { t.Error("certificate pem not in meta") } if _, ok := got.Meta["key"]; !ok { t.Error("key not in meta") } // file written? if _, err := os.Stat(config.Resolve("certs")); err != nil { t.Error("certs dir missing") } // the per-id dir perID := fmt.Sprintf("%s/%d", config.Resolve("certs"), got.ID) if _, err := os.Stat(perID); err != nil { t.Logf("per-id cert dir %s not present (may be ok): %v", perID, err) } // exercise non-local LE path meta parsing (staging/key_type from meta) in *production* mode // (real LE path; will fail on obtain due to no DNS/reach, but pre-LE branches + meta covered) t.Setenv("PROXY_MODE", "") c2 := store.Certificate{ Provider: "letsencrypt", DomainNames: []string{"nonlocal.example.com"}, Meta: map[string]any{"staging": "true", "key_type": "rsa4096"}, } created2, _ := st.CreateCertificate(c2) email2 := cm.GetEmailForCert(created2) if err2 := cm.Issue(created2, email2); err2 == nil || strings.Contains(err2.Error(), "not a letsencrypt") { t.Logf("non-local LE Issue err (expected without public reach/DNS; meta parse covered): %v", err2) } } func TestIssueRejectsNonLE(t *testing.T) { config.ResetForTest() tmp := t.TempDir() t.Setenv("DATA_DIR", tmp) _ = config.EnsureDataDirs() st, err := store.New() if err != nil { t.Fatalf("store: %v", err) } if c, ok := st.(interface{ Close() error }); ok { defer c.Close() } cm := NewManager(st) c := store.Certificate{Provider: "other", DomainNames: []string{"ex.com"}} created, _ := st.CreateCertificate(c) err = cm.Issue(created, "e@e.com") if err == nil || !strings.Contains(err.Error(), "not a letsencrypt") { t.Errorf("expected reject non-letsencrypt: got %v", err) } } func TestShouldRenew(t *testing.T) { config.ResetForTest() tmp := t.TempDir() t.Setenv("DATA_DIR", tmp) _ = config.EnsureDataDirs() st, err := store.New() if err != nil { t.Fatalf("store: %v", err) } if c, ok := st.(interface{ Close() error }); ok { defer c.Close() } cm := NewManager(st) now := time.Now().UTC() tests := []struct { name string exp string want bool }{ {"empty", "", true}, {"past", now.Add(-40 * 24 * time.Hour).Format("2006-01-02 15:04:05"), true}, {"near 29d", now.Add(29 * 24 * time.Hour).Format("2006-01-02 15:04:05"), true}, {"far 31d", now.Add(31 * 24 * time.Hour).Format("2006-01-02 15:04:05"), false}, {"rfc3339 near", now.Add(10 * 24 * time.Hour).Format(time.RFC3339), true}, {"bad format", "not-a-date", true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { c := store.Certificate{Provider: "letsencrypt", ExpiresOn: tt.exp} if got := cm.ShouldRenew(c); got != tt.want { t.Errorf("ShouldRenew(%s) = %v want %v", tt.exp, got, tt.want) } }) } } func TestProcessRenewals(t *testing.T) { config.ResetForTest() tmp := t.TempDir() t.Setenv("DATA_DIR", tmp) _ = config.EnsureDataDirs() st, err := store.New() if err != nil { t.Fatalf("store: %v", err) } if c, ok := st.(interface{ Close() error }); ok { defer c.Close() } cm := NewManager(st) // dev mode: *all* letsencrypt certs (any domain) use test/self-signed path in Issue/renew t.Setenv("PROXY_MODE", "development") // LE due soon dueExp := time.Now().Add(5 * 24 * time.Hour).Format("2006-01-02 15:04:05") leDue := store.Certificate{Provider: "letsencrypt", DomainNames: []string{"due.example.com"}, ExpiresOn: dueExp} createdDue, _ := st.CreateCertificate(leDue) // LE far future (no renew) farExp := time.Now().Add(40 * 24 * time.Hour).Format("2006-01-02 15:04:05") leFar := store.Certificate{Provider: "letsencrypt", DomainNames: []string{"far.example.com"}, ExpiresOn: farExp} createdFar, _ := st.CreateCertificate(leFar) // non-LE (ignored even if "due") nonLE := store.Certificate{Provider: "other", DomainNames: []string{"x.com"}, ExpiresOn: time.Now().Add(-1 * time.Hour).Format("2006-01-02 15:04:05")} st.CreateCertificate(nonLE) cm.ProcessRenewals() gDue, _ := st.GetCertificate(createdDue.ID) if tDue, _ := time.Parse("2006-01-02 15:04:05", gDue.ExpiresOn); time.Until(tDue) < 80*24*time.Hour { t.Error("due LE cert was not renewed (expires not extended)") } gFar, _ := st.GetCertificate(createdFar.ID) if gFar.ExpiresOn != farExp { t.Error("far LE should not have been touched") } }