mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-14 05:28:57 +00:00
825fcf39a4
Phase 2 of the #5 acquisition-readiness fix from the 2026-05-01 issuer
coverage audit. Phase 1 (commit 593210f) shipped the shared asyncpoll
package and refactored DigiCert as the reference. This commit applies
the same pattern to the remaining three async-CA connectors and adds
the operator-facing docs.
Per-connector refactors:
- Sectigo (sectigo.go): GetOrderStatus now wraps pollEnrollmentOnce in
asyncpoll.Poll. The collectNotReady sentinel (cert approved by SCM
but not yet retrievable from the collect endpoint) maps to
StillPending and rides the backoff schedule rather than the prior
"return pending immediately" branch. Added isPermanentStatusError
helper to distinguish transient HTTP errors (5xx / 429 / network)
from permanent ones (4xx / parse failure) — the wrapped checkStatus
errors get triaged at the poll closure boundary.
- Entrust (entrust.go): GetOrderStatus wraps pollEnrollmentOnce. The
AWAITING_APPROVAL status maps to StillPending; operators using
approval-pending workflows where humans approve enrollments should
bump CERTCTL_ENTRUST_POLL_MAX_WAIT_SECONDS to 86400 (24h) so a
single scheduler tick can wait through the approval window. The
default 10-minute deadline matches the other three connectors.
- GlobalSign (globalsign.go): GetOrderStatus wraps pollCertificateOnce.
GlobalSign tracks orders by serial number rather than order ID, but
the polling shape is identical to the other three. Status-code
triage matches DigiCert: 4xx (not 429) is permanent, 5xx / 429 /
network is transient.
Per-connector Config field added:
- DigiCert.PollMaxWaitSeconds (env CERTCTL_DIGICERT_POLL_MAX_WAIT_SECONDS)
- Sectigo.PollMaxWaitSeconds (env CERTCTL_SECTIGO_POLL_MAX_WAIT_SECONDS)
- Entrust.PollMaxWaitSeconds (env CERTCTL_ENTRUST_POLL_MAX_WAIT_SECONDS)
- GlobalSign.PollMaxWaitSeconds (env CERTCTL_GLOBALSIGN_POLL_MAX_WAIT_SECONDS)
internal/config/config.go env-var loaders updated for all four. Default
is 600 seconds (10 minutes); zero falls back to the asyncpoll package
default.
Test-helper updates: every existing test that exercises the pending
branch (collectNotReady, AWAITING_APPROVAL, status="pending", etc.)
now sets PollMaxWaitSeconds=1 in its Config so the test doesn't block
on the production-default 10-minute deadline. Tests that exercise
permanent-error branches (404, 401, malformed JSON, etc.) continue
to return immediately.
Test sites updated:
- buildSectigoConnector helper + GetOrderStatus_CollectNotReady test
- buildEntrustConnector helper + GetOrderStatus_Pending test
- buildGlobalsignConnector helper + GetOrderStatus_Pending test +
the GetHTTPClient_NoMTLSCertPaths test (network failure now rides
the backoff schedule rather than returning immediately)
Documentation:
- docs/async-polling.md: new operator reference covering the backoff
schedule, status-code triage, the four env vars, failure modes, and
where the implementation lives. Audit blocker citation included.
- docs/connectors.md: per-issuer sections for DigiCert, Sectigo,
Entrust, GlobalSign each gain the PollMaxWaitSeconds env var row
and a cross-link to async-polling.md.
Lint cleanup: simplified the isPermanentStatusError branch to satisfy
staticcheck S1008 (single-line return for a final boolean check).
Verified locally:
- gofmt -l . clean
- go vet ./... clean
- staticcheck ./... clean
- golangci-lint run --timeout 5m ./... → 0 issues
- go test -short -count=1 across all 4 connector packages + config + asyncpoll: green
Audit reference: cowork/issuer-coverage-audit-2026-05-01/RESULTS.md
Top-10 fix #5 — Phase 2.
167 lines
6.0 KiB
Go
167 lines
6.0 KiB
Go
package globalsign_test
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/shankar0123/certctl/internal/connector/issuer/globalsign"
|
|
)
|
|
|
|
// Bundle N.A/B-extended: globalsign failure-mode round-out (78.2% → ≥85%).
|
|
// Targets uncovered branches in getHTTPClient / GetOrderStatus / parseCertDates.
|
|
|
|
func buildGlobalsignConnector(t *testing.T, baseURL string) *globalsign.Connector {
|
|
t.Helper()
|
|
cfg := &globalsign.Config{
|
|
APIUrl: baseURL,
|
|
APIKey: "k",
|
|
APISecret: "s",
|
|
PollMaxWaitSeconds: 1, // keep async-pending tests fast
|
|
}
|
|
// Use NewWithHTTPClient with a test client so getHTTPClient short-circuits
|
|
// (no mTLS cert loading). Custom transport is required so the
|
|
// `httpClient.Transport != nil` test-mode check fires.
|
|
httpClient := &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}} //nolint:gosec
|
|
return globalsign.NewWithHTTPClient(cfg, slog.Default(), httpClient)
|
|
}
|
|
|
|
func TestGlobalsign_GetOrderStatus_403_ReturnsError(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusForbidden)
|
|
_, _ = w.Write([]byte(`{"error":"unauthorized"}`))
|
|
}))
|
|
defer srv.Close()
|
|
c := buildGlobalsignConnector(t, srv.URL)
|
|
_, err := c.GetOrderStatus(context.Background(), "serial-123")
|
|
if err == nil || !strings.Contains(err.Error(), "403") {
|
|
t.Errorf("expected 403 error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestGlobalsign_GetOrderStatus_MalformedJSON(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{not json`))
|
|
}))
|
|
defer srv.Close()
|
|
c := buildGlobalsignConnector(t, srv.URL)
|
|
_, err := c.GetOrderStatus(context.Background(), "serial-123")
|
|
if err == nil || !strings.Contains(err.Error(), "parse") {
|
|
t.Errorf("expected parse error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestGlobalsign_GetOrderStatus_StatusVariants(t *testing.T) {
|
|
cases := []struct {
|
|
statusVal string
|
|
want string
|
|
}{
|
|
{"pending", "pending"},
|
|
{"processing", "pending"},
|
|
{"rejected", "failed"},
|
|
{"denied", "failed"},
|
|
{"failed", "failed"},
|
|
{"weird-new-status", "pending"}, // unknown → default pending
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.statusVal, func(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"status": tc.statusVal,
|
|
"serial_number": "serial-123",
|
|
})
|
|
}))
|
|
defer srv.Close()
|
|
c := buildGlobalsignConnector(t, srv.URL)
|
|
st, err := c.GetOrderStatus(context.Background(), "serial-123")
|
|
if err != nil {
|
|
t.Fatalf("GetOrderStatus: %v", err)
|
|
}
|
|
if st.Status != tc.want {
|
|
t.Errorf("expected status=%q for input=%q, got %q", tc.want, tc.statusVal, st.Status)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGlobalsign_GetOrderStatus_IssuedButCertMissing(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"status":"issued","certificate":""}`))
|
|
}))
|
|
defer srv.Close()
|
|
c := buildGlobalsignConnector(t, srv.URL)
|
|
_, err := c.GetOrderStatus(context.Background(), "serial-123")
|
|
if err == nil || !strings.Contains(err.Error(), "certificate PEM is missing") {
|
|
t.Errorf("expected 'certificate PEM is missing' error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestGlobalsign_GetOrderStatus_IssuedWithMalformedPEM_NonFatalParseDateWarning(t *testing.T) {
|
|
// When status=issued and certificate is non-empty but doesn't parse as PEM,
|
|
// the connector logs a warning but still returns Status=completed (per the
|
|
// existing code: parseCertDates failure is non-fatal).
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"status":"issued","certificate":"not-a-pem-block","serial_number":"sn1"}`))
|
|
}))
|
|
defer srv.Close()
|
|
c := buildGlobalsignConnector(t, srv.URL)
|
|
st, err := c.GetOrderStatus(context.Background(), "serial-123")
|
|
if err != nil {
|
|
t.Fatalf("GetOrderStatus: %v", err)
|
|
}
|
|
if st.Status != "completed" {
|
|
t.Errorf("expected completed (parseCertDates failure is non-fatal), got %q", st.Status)
|
|
}
|
|
}
|
|
|
|
func TestGlobalsign_GetHTTPClient_NoMTLSCertPaths_ReturnsClientAsIs(t *testing.T) {
|
|
// When ClientCertPath and ClientKeyPath are both empty, getHTTPClient
|
|
// returns httpClient as-is — exercises that branch.
|
|
//
|
|
// PollMaxWaitSeconds=1 keeps this test fast: a network failure on
|
|
// the invalid host is now treated as transient by the bounded-
|
|
// polling Poller, so without the deadline the call blocks for
|
|
// the production-default 10 minutes.
|
|
cfg := &globalsign.Config{
|
|
APIUrl: "http://example.invalid",
|
|
APIKey: "k",
|
|
APISecret: "s",
|
|
PollMaxWaitSeconds: 1,
|
|
// no cert paths
|
|
}
|
|
c := globalsign.NewWithHTTPClient(cfg, slog.Default(), &http.Client{})
|
|
// GetOrderStatus blocks briefly (1s) due to bounded polling, then
|
|
// returns a pending OrderStatus (transient network err did not
|
|
// become a permanent failure). The test exercises the
|
|
// no-mTLS branch in getHTTPClient — the post-poll status doesn't
|
|
// matter; we just need GetOrderStatus to be invoked through the
|
|
// branch.
|
|
_, _ = c.GetOrderStatus(context.Background(), "x")
|
|
}
|
|
|
|
func TestGlobalsign_GetHTTPClient_MTLSPathConfigured_LoadsKeyPair(t *testing.T) {
|
|
// Configure cert paths to a non-existent file — exercises the
|
|
// LoadX509KeyPair error branch in getHTTPClient.
|
|
cfg := &globalsign.Config{
|
|
APIUrl: "http://example.invalid",
|
|
APIKey: "k",
|
|
APISecret: "s",
|
|
ClientCertPath: "/nonexistent/cert.pem",
|
|
ClientKeyPath: "/nonexistent/key.pem",
|
|
}
|
|
c := globalsign.New(cfg, slog.Default())
|
|
_, err := c.GetOrderStatus(context.Background(), "x")
|
|
if err == nil || !strings.Contains(err.Error(), "client certificate") {
|
|
t.Errorf("expected 'client certificate' load error, got %v", err)
|
|
}
|
|
}
|