mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 14:21:37 +00:00
ec21c9bb29
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>
252 lines
7.2 KiB
Go
252 lines
7.2 KiB
Go
package acme
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
)
|
|
|
|
// TestComputeARICertID_InvalidPEM_Input tests the ARI certificate ID computation with invalid PEM.
|
|
func TestComputeARICertID_InvalidPEM_Input(t *testing.T) {
|
|
// Test with invalid PEM data
|
|
_, err := computeARICertID("not a valid pem")
|
|
if err == nil {
|
|
t.Error("expected error for invalid PEM")
|
|
}
|
|
}
|
|
|
|
func TestConstructARIURLFallback_LetsEncrypt(t *testing.T) {
|
|
directoryURL := "https://acme-v02.api.letsencrypt.org/directory"
|
|
certID := "abc123"
|
|
|
|
url := constructARIURLFallback(directoryURL, certID)
|
|
|
|
expected := "https://acme-v02.api.letsencrypt.org/renewalInfo/abc123"
|
|
if url != expected {
|
|
t.Errorf("constructARIURLFallback: expected %s, got %s", expected, url)
|
|
}
|
|
}
|
|
|
|
func TestConstructARIURLFallback_NoDirectory(t *testing.T) {
|
|
directoryURL := "https://example.com/acme"
|
|
certID := "xyz789"
|
|
|
|
url := constructARIURLFallback(directoryURL, certID)
|
|
|
|
expected := "https://example.com/acme/renewalInfo/xyz789"
|
|
if url != expected {
|
|
t.Errorf("constructARIURLFallback: expected %s, got %s", expected, url)
|
|
}
|
|
}
|
|
|
|
// TestGetRenewalInfo_Disabled tests that ARI returns nil when disabled.
|
|
func TestGetRenewalInfo_Disabled(t *testing.T) {
|
|
config := &Config{
|
|
DirectoryURL: "https://acme.invalid/directory",
|
|
Email: "test@example.com",
|
|
ChallengeType: "http-01",
|
|
ARIEnabled: false,
|
|
}
|
|
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
connector := New(config, logger)
|
|
|
|
ctx := context.Background()
|
|
|
|
result, err := connector.GetRenewalInfo(ctx, "any-cert-pem")
|
|
if err != nil {
|
|
t.Fatalf("GetRenewalInfo failed: %v", err)
|
|
}
|
|
|
|
if result != nil {
|
|
t.Error("GetRenewalInfo should return nil when ARI is disabled")
|
|
}
|
|
}
|
|
|
|
// TestGetRenewalInfo_NotFound tests handling of 404 response (CA doesn't support ARI).
|
|
func TestGetRenewalInfo_NotFound(t *testing.T) {
|
|
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Mock directory endpoint
|
|
if r.URL.Path == "/directory" && r.Method == http.MethodGet {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]string{
|
|
"newOrder": "/acme/new-order",
|
|
"newAccount": "/acme/new-account",
|
|
})
|
|
return
|
|
}
|
|
|
|
// All other endpoints return 404
|
|
http.Error(w, "not found", http.StatusNotFound)
|
|
}))
|
|
defer mockServer.Close()
|
|
|
|
config := &Config{
|
|
DirectoryURL: mockServer.URL + "/directory",
|
|
Email: "test@example.com",
|
|
ChallengeType: "http-01",
|
|
ARIEnabled: true,
|
|
}
|
|
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
connector := New(config, logger)
|
|
|
|
ctx := context.Background()
|
|
|
|
// GetRenewalInfo will fail when parsing the cert PEM, which is expected
|
|
result, err := connector.GetRenewalInfo(ctx, "invalid-cert-pem")
|
|
if err == nil {
|
|
// If it doesn't fail on cert parsing, that's also okay
|
|
// The 404 handling happens after cert ID computation
|
|
if result != nil {
|
|
t.Error("GetRenewalInfo should return nil for 404 response")
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestGetRenewalInfo_ServerError tests handling of server errors.
|
|
func TestGetRenewalInfo_ServerError(t *testing.T) {
|
|
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Mock directory endpoint
|
|
if r.URL.Path == "/directory" && r.Method == http.MethodGet {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]string{
|
|
"newOrder": "/acme/new-order",
|
|
"newAccount": "/acme/new-account",
|
|
})
|
|
return
|
|
}
|
|
|
|
// All other endpoints return 500
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
}))
|
|
defer mockServer.Close()
|
|
|
|
config := &Config{
|
|
DirectoryURL: mockServer.URL + "/directory",
|
|
Email: "test@example.com",
|
|
ChallengeType: "http-01",
|
|
ARIEnabled: true,
|
|
}
|
|
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
connector := New(config, logger)
|
|
|
|
ctx := context.Background()
|
|
|
|
_, err := connector.GetRenewalInfo(ctx, "invalid-cert-pem")
|
|
// Error is expected because cert parsing fails first
|
|
if err == nil {
|
|
// If we get here, the server error handling should catch it
|
|
t.Error("expected error for invalid cert or 500 response")
|
|
}
|
|
}
|
|
|
|
// TestGetRenewalInfo_InvalidPEM tests handling of invalid PEM input.
|
|
func TestGetRenewalInfo_InvalidPEM(t *testing.T) {
|
|
config := &Config{
|
|
DirectoryURL: "https://acme.invalid/directory",
|
|
Email: "test@example.com",
|
|
ChallengeType: "http-01",
|
|
ARIEnabled: true,
|
|
}
|
|
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
connector := New(config, logger)
|
|
|
|
ctx := context.Background()
|
|
|
|
_, err := connector.GetRenewalInfo(ctx, "invalid pem data")
|
|
if err == nil {
|
|
t.Error("GetRenewalInfo should return error for invalid PEM")
|
|
}
|
|
}
|
|
|
|
// TestGetRenewalInfo_MalformedResponse tests handling of malformed JSON response.
|
|
func TestGetRenewalInfo_MalformedResponse(t *testing.T) {
|
|
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Mock directory endpoint
|
|
if r.URL.Path == "/directory" && r.Method == http.MethodGet {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]string{
|
|
"renewalInfo": "/acme/renewalInfo",
|
|
})
|
|
return
|
|
}
|
|
|
|
// Mock renewalInfo with malformed JSON
|
|
if r.URL.Path != "/directory" && r.Method == http.MethodGet {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Write([]byte(`{"suggestedWindow": invalid json}`))
|
|
return
|
|
}
|
|
|
|
http.Error(w, "not found", http.StatusNotFound)
|
|
}))
|
|
defer mockServer.Close()
|
|
|
|
config := &Config{
|
|
DirectoryURL: mockServer.URL + "/directory",
|
|
Email: "test@example.com",
|
|
ChallengeType: "http-01",
|
|
ARIEnabled: true,
|
|
}
|
|
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
connector := New(config, logger)
|
|
|
|
ctx := context.Background()
|
|
|
|
_, err := connector.GetRenewalInfo(ctx, "invalid-cert-pem")
|
|
// Error is expected
|
|
if err == nil {
|
|
t.Error("GetRenewalInfo should return error for malformed response or invalid cert")
|
|
}
|
|
}
|
|
|
|
// TestGetRenewalInfo_MissingWindow tests handling of missing suggestedWindow.
|
|
func TestGetRenewalInfo_MissingWindow(t *testing.T) {
|
|
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Mock directory endpoint
|
|
if r.URL.Path == "/directory" && r.Method == http.MethodGet {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]string{
|
|
"renewalInfo": "/acme/renewalInfo",
|
|
})
|
|
return
|
|
}
|
|
|
|
// Mock renewalInfo without suggestedWindow
|
|
if r.URL.Path != "/directory" && r.Method == http.MethodGet {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{})
|
|
return
|
|
}
|
|
|
|
http.Error(w, "not found", http.StatusNotFound)
|
|
}))
|
|
defer mockServer.Close()
|
|
|
|
config := &Config{
|
|
DirectoryURL: mockServer.URL + "/directory",
|
|
Email: "test@example.com",
|
|
ChallengeType: "http-01",
|
|
ARIEnabled: true,
|
|
}
|
|
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
connector := New(config, logger)
|
|
|
|
ctx := context.Background()
|
|
|
|
_, err := connector.GetRenewalInfo(ctx, "invalid-cert-pem")
|
|
// Error is expected due to invalid cert PEM
|
|
if err == nil {
|
|
t.Error("expected error for invalid cert or missing window")
|
|
}
|
|
}
|