Files
certctl/internal/service/digest_test.go
T
shankar0123 ec21c9bb29 feat(m28+m29+m30): ACME ARI, email digest, and Helm chart
M28: ACME Renewal Information (RFC 9702) — CA-directed renewal timing
with cert ID computation, directory endpoint discovery, graceful
degradation for non-ARI CAs. 19 tests.

M29: Email notifier wiring + scheduled certificate digest — SMTP
connector bridged to service layer via NotifierAdapter, DigestService
with HTML email template, 7th scheduler loop (24h), digest preview/send
API endpoints and GUI card. 21 tests.

M30: Production-ready Helm chart — server Deployment, PostgreSQL
StatefulSet, agent DaemonSet, ConfigMaps, Secrets, Ingress, security
contexts, health probes, example values for dev/prod/ACME scenarios.

Also: OpenAPI spec updates, MCP tool additions, CI helm-lint job,
documentation updates across 5 doc files and README.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 21:18:35 -04:00

310 lines
9.5 KiB
Go

package service
import (
"context"
"errors"
"strings"
"testing"
"time"
"github.com/shankar0123/certctl/internal/domain"
)
// mockHTMLEmailSender implements HTMLEmailSender for testing.
type mockHTMLEmailSender struct {
sentEmails []sentHTMLEmail
sendErr error
}
type sentHTMLEmail struct {
recipient string
subject string
body string
}
func (m *mockHTMLEmailSender) SendHTML(ctx context.Context, recipient string, subject string, htmlBody string) error {
if m.sendErr != nil {
return m.sendErr
}
m.sentEmails = append(m.sentEmails, sentHTMLEmail{
recipient: recipient,
subject: subject,
body: htmlBody,
})
return nil
}
func TestDigestService_GenerateDigest(t *testing.T) {
certRepo := &mockCertRepo{
Certs: make(map[string]*domain.ManagedCertificate),
Versions: make(map[string][]*domain.CertificateVersion),
}
// Add test certificates
now := time.Now()
certRepo.Certs["cert-1"] = &domain.ManagedCertificate{
ID: "cert-1",
CommonName: "example.com",
ExpiresAt: now.AddDate(0, 0, 10),
OwnerID: "owner-1",
}
certRepo.Certs["cert-2"] = &domain.ManagedCertificate{
ID: "cert-2",
CommonName: "api.example.com",
ExpiresAt: now.AddDate(0, 0, 25),
OwnerID: "owner-2",
}
certRepo.Certs["cert-3"] = &domain.ManagedCertificate{
ID: "cert-3",
CommonName: "old.example.com",
ExpiresAt: now.AddDate(0, 0, -5), // expired
OwnerID: "owner-1",
}
jobRepo := &mockJobRepo{Jobs: make(map[string]*domain.Job)}
agentRepo := &mockAgentRepo{Agents: make(map[string]*domain.Agent)}
statsService := NewStatsService(certRepo, jobRepo, agentRepo)
sender := &mockHTMLEmailSender{}
digestService := NewDigestService(statsService, certRepo, nil, sender, []string{"admin@example.com"}, nil)
digest, err := digestService.GenerateDigest(context.Background())
if err != nil {
t.Fatalf("GenerateDigest failed: %v", err)
}
if digest.TotalCertificates != 3 {
t.Errorf("expected 3 total certs, got %d", digest.TotalCertificates)
}
if len(digest.ExpiringCerts) != 2 {
t.Errorf("expected 2 expiring certs (10d and 25d), got %d", len(digest.ExpiringCerts))
}
}
func TestDigestService_GenerateDigest_Empty(t *testing.T) {
certRepo := &mockCertRepo{
Certs: make(map[string]*domain.ManagedCertificate),
Versions: make(map[string][]*domain.CertificateVersion),
}
jobRepo := &mockJobRepo{Jobs: make(map[string]*domain.Job)}
agentRepo := &mockAgentRepo{Agents: make(map[string]*domain.Agent)}
statsService := NewStatsService(certRepo, jobRepo, agentRepo)
sender := &mockHTMLEmailSender{}
digestService := NewDigestService(statsService, certRepo, nil, sender, nil, nil)
digest, err := digestService.GenerateDigest(context.Background())
if err != nil {
t.Fatalf("GenerateDigest failed: %v", err)
}
if digest.TotalCertificates != 0 {
t.Errorf("expected 0 total certs, got %d", digest.TotalCertificates)
}
if len(digest.ExpiringCerts) != 0 {
t.Errorf("expected 0 expiring certs, got %d", len(digest.ExpiringCerts))
}
}
func TestDigestService_RenderDigestHTML(t *testing.T) {
digestService := &DigestService{}
data := &DigestData{
GeneratedAt: time.Now(),
TotalCertificates: 42,
ExpiringCertificates: 5,
ExpiredCertificates: 2,
ActiveAgents: 3,
PendingJobs: 1,
ExpiringCerts: []DigestCertEntry{
{ID: "c1", CommonName: "example.com", ExpiresAt: time.Now().AddDate(0, 0, 5), DaysLeft: 5},
},
}
html, err := digestService.RenderDigestHTML(data)
if err != nil {
t.Fatalf("RenderDigestHTML failed: %v", err)
}
if !strings.Contains(html, "certctl Certificate Digest") {
t.Error("expected HTML to contain 'certctl Certificate Digest'")
}
if !strings.Contains(html, "42") {
t.Error("expected HTML to contain total certificate count '42'")
}
if !strings.Contains(html, "example.com") {
t.Error("expected HTML to contain 'example.com'")
}
if !strings.Contains(html, "5 days") {
t.Error("expected HTML to contain '5 days'")
}
}
func TestDigestService_RenderDigestHTML_Empty(t *testing.T) {
digestService := &DigestService{}
data := &DigestData{
GeneratedAt: time.Now(),
}
html, err := digestService.RenderDigestHTML(data)
if err != nil {
t.Fatalf("RenderDigestHTML failed: %v", err)
}
if !strings.Contains(html, "No certificates expiring in the next 30 days") {
t.Error("expected empty state message in HTML")
}
}
func TestDigestService_SendDigest_Success(t *testing.T) {
certRepo := &mockCertRepo{
Certs: make(map[string]*domain.ManagedCertificate),
Versions: make(map[string][]*domain.CertificateVersion),
}
jobRepo := &mockJobRepo{Jobs: make(map[string]*domain.Job)}
agentRepo := &mockAgentRepo{Agents: make(map[string]*domain.Agent)}
statsService := NewStatsService(certRepo, jobRepo, agentRepo)
sender := &mockHTMLEmailSender{}
recipients := []string{"admin@example.com", "ops@example.com"}
digestService := NewDigestService(statsService, certRepo, nil, sender, recipients, nil)
err := digestService.SendDigest(context.Background())
if err != nil {
t.Fatalf("SendDigest failed: %v", err)
}
if len(sender.sentEmails) != 2 {
t.Fatalf("expected 2 emails sent, got %d", len(sender.sentEmails))
}
if sender.sentEmails[0].recipient != "admin@example.com" {
t.Errorf("expected first recipient admin@example.com, got %s", sender.sentEmails[0].recipient)
}
if !strings.Contains(sender.sentEmails[0].subject, "certctl Certificate Digest") {
t.Errorf("expected subject to contain 'certctl Certificate Digest', got %s", sender.sentEmails[0].subject)
}
}
func TestDigestService_SendDigest_NoSender(t *testing.T) {
certRepo := &mockCertRepo{
Certs: make(map[string]*domain.ManagedCertificate),
Versions: make(map[string][]*domain.CertificateVersion),
}
jobRepo := &mockJobRepo{Jobs: make(map[string]*domain.Job)}
agentRepo := &mockAgentRepo{Agents: make(map[string]*domain.Agent)}
statsService := NewStatsService(certRepo, jobRepo, agentRepo)
digestService := NewDigestService(statsService, certRepo, nil, nil, []string{"admin@example.com"}, nil)
err := digestService.SendDigest(context.Background())
if err == nil {
t.Fatal("expected error when sender is nil")
}
if !strings.Contains(err.Error(), "email sender not configured") {
t.Errorf("expected 'email sender not configured' error, got: %v", err)
}
}
func TestDigestService_SendDigest_SendError(t *testing.T) {
certRepo := &mockCertRepo{
Certs: make(map[string]*domain.ManagedCertificate),
Versions: make(map[string][]*domain.CertificateVersion),
}
jobRepo := &mockJobRepo{Jobs: make(map[string]*domain.Job)}
agentRepo := &mockAgentRepo{Agents: make(map[string]*domain.Agent)}
statsService := NewStatsService(certRepo, jobRepo, agentRepo)
sender := &mockHTMLEmailSender{sendErr: errors.New("SMTP connection refused")}
digestService := NewDigestService(statsService, certRepo, nil, sender, []string{"admin@example.com"}, nil)
err := digestService.SendDigest(context.Background())
if err == nil {
t.Fatal("expected error when send fails")
}
if !strings.Contains(err.Error(), "failed to send digest") {
t.Errorf("expected 'failed to send digest' error, got: %v", err)
}
}
func TestDigestService_SendDigest_NoRecipients(t *testing.T) {
certRepo := &mockCertRepo{
Certs: make(map[string]*domain.ManagedCertificate),
Versions: make(map[string][]*domain.CertificateVersion),
}
jobRepo := &mockJobRepo{Jobs: make(map[string]*domain.Job)}
agentRepo := &mockAgentRepo{Agents: make(map[string]*domain.Agent)}
statsService := NewStatsService(certRepo, jobRepo, agentRepo)
sender := &mockHTMLEmailSender{}
// No explicit recipients and no owner repo
digestService := NewDigestService(statsService, certRepo, nil, sender, nil, nil)
err := digestService.SendDigest(context.Background())
// Should succeed without error (just no recipients)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(sender.sentEmails) != 0 {
t.Errorf("expected 0 emails sent, got %d", len(sender.sentEmails))
}
}
func TestDigestService_PreviewDigest(t *testing.T) {
certRepo := &mockCertRepo{
Certs: make(map[string]*domain.ManagedCertificate),
Versions: make(map[string][]*domain.CertificateVersion),
}
jobRepo := &mockJobRepo{Jobs: make(map[string]*domain.Job)}
agentRepo := &mockAgentRepo{Agents: make(map[string]*domain.Agent)}
statsService := NewStatsService(certRepo, jobRepo, agentRepo)
sender := &mockHTMLEmailSender{}
digestService := NewDigestService(statsService, certRepo, nil, sender, nil, nil)
html, err := digestService.PreviewDigest(context.Background())
if err != nil {
t.Fatalf("PreviewDigest failed: %v", err)
}
if !strings.Contains(html, "<!DOCTYPE html>") {
t.Error("expected valid HTML document")
}
if !strings.Contains(html, "certctl Certificate Digest") {
t.Error("expected HTML to contain 'certctl Certificate Digest'")
}
}
func TestDigestService_ProcessDigest(t *testing.T) {
certRepo := &mockCertRepo{
Certs: make(map[string]*domain.ManagedCertificate),
Versions: make(map[string][]*domain.CertificateVersion),
}
jobRepo := &mockJobRepo{Jobs: make(map[string]*domain.Job)}
agentRepo := &mockAgentRepo{Agents: make(map[string]*domain.Agent)}
statsService := NewStatsService(certRepo, jobRepo, agentRepo)
sender := &mockHTMLEmailSender{}
digestService := NewDigestService(statsService, certRepo, nil, sender, []string{"test@example.com"}, nil)
err := digestService.ProcessDigest(context.Background())
if err != nil {
t.Fatalf("ProcessDigest failed: %v", err)
}
if len(sender.sentEmails) != 1 {
t.Errorf("expected 1 email sent, got %d", len(sender.sentEmails))
}
}