mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-12 16:38:55 +00:00
8b75e0311b
Mechanical sed across the main go.mod's module declaration, the f5-mock-icontrol
sub-module's go.mod, every Go file's import path (361 files), and a rebuild of
the checked-in f5-mock-icontrol binary so its embedded build-info reflects the
new module path. No behavior change.
Choice B from cowork/transfer-certctl-to-org.md, executed 2026-05-04. Choice A
(keep module path declared as github.com/shankar0123/certctl regardless of
repo URL) shipped on the day of the org transfer (2026-05-03) since we had no
external Go consumers; this commit closes that deferral.
Backward-compat: GitHub HTTP redirects continue to forward
github.com/shankar0123/certctl → github.com/certctl-io/certctl at the URL
level, but Go's module proxy uses the path declared in go.mod as the
canonical name. Pre-fix, anyone trying `go get github.com/certctl-io/certctl/...`
hit a "module path mismatch" error because go.mod said
github.com/shankar0123/certctl and the URL they fetched it from said
certctl-io/certctl. Post-fix, the canonical name and the URL agree, so
go get / go install / external Go consumers / Go-tooling integrations
work cleanly via either the new path (preferred) or the old path (which
redirects and Go follows the redirect for source fetch).
Anyone still importing the old path inside their own code keeps working
provided they update their go.mod's `require` line to match — the module
path declared in their consumer's go.sum / go.mod is the authoritative
import name, so a mass sed across their import statements is the migration
on the consumer side. No external consumers exist today.
Diff shape:
361 *.go files — import path replacement only
2 go.mod — module declaration replacement only
1 binary — deploy/test/f5-mock-icontrol/f5-mock-icontrol rebuilt
so embedded build-info reflects the new path (8618965 vs
8618933 bytes; 32-byte diff is the build-info change)
Total: 364 files, 730 insertions / 730 deletions, net-zero size, pure
mechanical substitution.
Verification:
gofmt: 17 files needed re-alignment after sed (the new path is one char
shorter than the old, so column-aligned import groups drifted). Applied
`gofmt -w` to fix.
go mod tidy: clean exit on both modules.
go vet ./...: clean exit.
go build ./...: clean exit.
go test -short -count=1 on representative packages: all green
(internal/domain, internal/validation, internal/crypto, internal/crypto/signer,
cmd/agent). Test output now reads `ok github.com/certctl-io/certctl/...`
confirming the module path resolves correctly.
binary: f5-mock-icontrol rebuilt; `strings | grep shankar0123` returns
nothing; `strings | grep certctl-io/certctl` shows the new module path
embedded in build-info.
Files intentionally NOT touched in this commit:
README.md / CHANGELOG.md / docs/ / etc. — already swept to certctl-io
URLs in commit 0729ee4 (the post-transfer URL refresh). This commit is
purely the Go-tooling layer.
Scarf pixels (`shankar0123.docker.scarf.sh/...`) — Scarf-account
namespace, not a Go import or GitHub repo URL. Stays.
This is a non-blocking, non-customer-impacting change. Operators pulling
container images, running `make verify`, hitting the API, or installing the
agent see no functional difference. Only Go-tooling consumers (none today)
are affected, and they're enabled — not broken — by this commit.
939 lines
32 KiB
Go
939 lines
32 KiB
Go
package acme
|
|
|
|
// Bundle J (Coverage Audit Closure) — ACME failure-mode regression suite.
|
|
//
|
|
// Closes finding C-001. Per gap-backlog.md C-001 the failure modes that
|
|
// matter are: 401 from upstream, 403, 429+Retry-After, 5xx, malformed
|
|
// directory JSON, malformed order JSON, expired EAB credentials, ARI
|
|
// deferral with unreachable CA, EAB auto-fetch failure.
|
|
//
|
|
// Strategy:
|
|
// - Hermetic httptest.Server for every case — no network.
|
|
// - For paths that go through ensureClient (which would otherwise need a
|
|
// full ACME registration), we pre-set c.client and c.accountKey so
|
|
// ensureClient short-circuits. This lets us exercise the post-init
|
|
// failure paths (ARI, profile, revoke, getOrderStatus) deterministically.
|
|
// - Per row we assert (a) error is non-nil, (b) error message is
|
|
// informative + does not leak credentials/keys, (c) no panic.
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"io"
|
|
"math/big"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
goacme "golang.org/x/crypto/acme"
|
|
|
|
"github.com/certctl-io/certctl/internal/connector/issuer"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// silentLogger discards everything. Reuses testLogger() from acme_test.go
|
|
// when called as a peer. This file's tests use testLogger() which returns
|
|
// a slog logger writing to stderr at error level.
|
|
|
|
// preWiredConnector returns a Connector with a synthesized account key + acme
|
|
// client pre-set, so calls into ensureClient short-circuit. This lets tests
|
|
// exercise post-init paths (ARI, profile, revoke, getOrderStatus) without
|
|
// having to mock the full ACME registration flow.
|
|
func preWiredConnector(t *testing.T, cfg *Config) *Connector {
|
|
t.Helper()
|
|
c := New(cfg, testLogger())
|
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
if err != nil {
|
|
t.Fatalf("ecdsa.GenerateKey: %v", err)
|
|
}
|
|
c.accountKey = key
|
|
c.client = &goacme.Client{
|
|
Key: key,
|
|
DirectoryURL: cfg.DirectoryURL,
|
|
HTTPClient: c.httpClient(),
|
|
}
|
|
return c
|
|
}
|
|
|
|
// makeTestCertPEM produces a minimal valid PEM-encoded self-signed cert
|
|
// suitable for ARI cert-ID computation. The cert content is irrelevant —
|
|
// computeARICertID only hashes the DER bytes.
|
|
func makeTestCertPEM(t *testing.T) string {
|
|
t.Helper()
|
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
if err != nil {
|
|
t.Fatalf("gen key: %v", err)
|
|
}
|
|
tmpl := x509.Certificate{
|
|
SerialNumber: big.NewInt(1),
|
|
Subject: pkix.Name{CommonName: "test"},
|
|
NotBefore: time.Now(),
|
|
NotAfter: time.Now().Add(24 * time.Hour),
|
|
}
|
|
der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &key.PublicKey, key)
|
|
if err != nil {
|
|
t.Fatalf("create cert: %v", err)
|
|
}
|
|
return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// EAB auto-fetch failure modes (Bundle J — gap-backlog.md C-001 row 9-10)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// TestFetchZeroSSLEAB_NetworkError simulates a connect-refused / unreachable
|
|
// ZeroSSL endpoint by pointing at a closed httptest server.
|
|
func TestFetchZeroSSLEAB_NetworkError(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
|
url := ts.URL
|
|
ts.Close() // close before fetch — connect will fail
|
|
|
|
orig := zeroSSLEABEndpoint
|
|
defer func() { zeroSSLEABEndpoint = orig }()
|
|
zeroSSLEABEndpoint = url
|
|
|
|
_, _, err := fetchZeroSSLEAB(context.Background(), "x@example.com")
|
|
if err == nil {
|
|
t.Fatal("expected network error from closed server")
|
|
}
|
|
if !strings.Contains(err.Error(), "request failed") {
|
|
t.Errorf("error %q should wrap 'request failed'", err)
|
|
}
|
|
}
|
|
|
|
// TestFetchZeroSSLEAB_MalformedJSON pins the parse-error branch.
|
|
func TestFetchZeroSSLEAB_MalformedJSON(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = io.WriteString(w, `{"success":true,"eab_kid":`) // truncated
|
|
}))
|
|
defer ts.Close()
|
|
|
|
orig := zeroSSLEABEndpoint
|
|
defer func() { zeroSSLEABEndpoint = orig }()
|
|
zeroSSLEABEndpoint = ts.URL
|
|
|
|
_, _, err := fetchZeroSSLEAB(context.Background(), "x@example.com")
|
|
if err == nil {
|
|
t.Fatal("expected JSON parse error")
|
|
}
|
|
if !strings.Contains(err.Error(), "parse response") {
|
|
t.Errorf("error %q should wrap 'parse response'", err)
|
|
}
|
|
}
|
|
|
|
// TestFetchZeroSSLEAB_5xx pins the non-200 branch.
|
|
func TestFetchZeroSSLEAB_5xx(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
_, _ = io.WriteString(w, `internal`)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
orig := zeroSSLEABEndpoint
|
|
defer func() { zeroSSLEABEndpoint = orig }()
|
|
zeroSSLEABEndpoint = ts.URL
|
|
|
|
_, _, err := fetchZeroSSLEAB(context.Background(), "x@example.com")
|
|
if err == nil {
|
|
t.Fatal("expected 500 to error")
|
|
}
|
|
if !strings.Contains(err.Error(), "status 500") {
|
|
t.Errorf("error %q should mention 'status 500'", err)
|
|
}
|
|
if strings.Contains(err.Error(), "x@example.com") {
|
|
// the email isn't sensitive but we should not echo it back into errors
|
|
// either; pin the absence as a defense-in-depth check.
|
|
t.Logf("note: email is in error message — acceptable here, but watch for credential leaks")
|
|
}
|
|
}
|
|
|
|
// TestFetchZeroSSLEAB_401Unauthorized confirms upstream 401 propagates.
|
|
func TestFetchZeroSSLEAB_401Unauthorized(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
_, _ = io.WriteString(w, `{"success":false,"error":"invalid api key"}`)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
orig := zeroSSLEABEndpoint
|
|
defer func() { zeroSSLEABEndpoint = orig }()
|
|
zeroSSLEABEndpoint = ts.URL
|
|
|
|
_, _, err := fetchZeroSSLEAB(context.Background(), "x@example.com")
|
|
if err == nil {
|
|
t.Fatal("expected 401 to error")
|
|
}
|
|
if !strings.Contains(err.Error(), "status 401") {
|
|
t.Errorf("error %q should mention 'status 401'", err)
|
|
}
|
|
}
|
|
|
|
// TestEnsureClient_EABAutoFetchFails confirms the connector's startup-time
|
|
// auto-EAB call propagates the underlying HTTP failure cleanly.
|
|
func TestEnsureClient_EABAutoFetchFails(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusBadGateway)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
orig := zeroSSLEABEndpoint
|
|
defer func() { zeroSSLEABEndpoint = orig }()
|
|
zeroSSLEABEndpoint = ts.URL
|
|
|
|
c := New(&Config{
|
|
DirectoryURL: "https://acme.zerossl.com/v2/DV90",
|
|
Email: "test@example.com",
|
|
// EAB intentionally empty → triggers auto-fetch
|
|
}, testLogger())
|
|
|
|
err := c.ensureClient(context.Background())
|
|
if err == nil {
|
|
t.Fatal("expected ensureClient to fail when ZeroSSL EAB auto-fetch fails")
|
|
}
|
|
if !strings.Contains(err.Error(), "auto-fetch ZeroSSL EAB credentials") {
|
|
t.Errorf("error %q should wrap auto-fetch failure", err)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ARI failure modes (Bundle J — C-001 row 9 "ARI deferral with unreachable CA")
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// TestGetRenewalInfo_DirectoryUnreachable pins the unreachable-CA fallback
|
|
// path. With an unreachable directory, getARIEndpoint silently falls back to
|
|
// the constructed URL pattern; the subsequent ARI GET will then also fail.
|
|
func TestGetRenewalInfo_DirectoryUnreachable(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
|
url := ts.URL
|
|
ts.Close()
|
|
|
|
c := preWiredConnector(t, &Config{
|
|
DirectoryURL: url + "/directory",
|
|
Email: "test@example.com",
|
|
ChallengeType: "http-01",
|
|
ARIEnabled: true,
|
|
ARIHTTPTimeoutSeconds: 1,
|
|
})
|
|
certPEM := makeTestCertPEM(t)
|
|
|
|
_, err := c.GetRenewalInfo(context.Background(), certPEM)
|
|
if err == nil {
|
|
t.Fatal("expected error when both directory and ARI fallback unreachable")
|
|
}
|
|
if !strings.Contains(err.Error(), "ARI request failed") {
|
|
t.Errorf("error %q should wrap 'ARI request failed'", err)
|
|
}
|
|
}
|
|
|
|
// TestGetRenewalInfo_ARI5xx pins the non-2xx (other than 404) branch. The
|
|
// directory handler emits an absolute URL pointing back at the same test
|
|
// server's /renewalInfo path, which 5xx's all requests.
|
|
func TestGetRenewalInfo_ARI5xx(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/directory" {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprintf(w, `{"renewalInfo":%q}`, "http://"+r.Host+"/renewalInfo")
|
|
return
|
|
}
|
|
http.Error(w, "boom", http.StatusInternalServerError)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
c := preWiredConnector(t, &Config{
|
|
DirectoryURL: ts.URL + "/directory",
|
|
Email: "test@example.com",
|
|
ChallengeType: "http-01",
|
|
ARIEnabled: true,
|
|
})
|
|
certPEM := makeTestCertPEM(t)
|
|
|
|
_, err := c.GetRenewalInfo(context.Background(), certPEM)
|
|
if err == nil {
|
|
t.Fatal("expected ARI 5xx to error")
|
|
}
|
|
if !strings.Contains(err.Error(), "status 500") {
|
|
t.Errorf("error %q should mention 'status 500'", err)
|
|
}
|
|
}
|
|
|
|
// TestGetRenewalInfo_ARI404Returns_NilNil pins the "CA does not support ARI"
|
|
// short-circuit.
|
|
func TestGetRenewalInfo_ARI404Returns_NilNil(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/directory" {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprintf(w, `{"renewalInfo":%q}`, "http://"+r.Host+"/renewalInfo")
|
|
return
|
|
}
|
|
http.Error(w, "no ARI", http.StatusNotFound)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
c := preWiredConnector(t, &Config{
|
|
DirectoryURL: ts.URL + "/directory",
|
|
Email: "test@example.com",
|
|
ChallengeType: "http-01",
|
|
ARIEnabled: true,
|
|
})
|
|
certPEM := makeTestCertPEM(t)
|
|
|
|
res, err := c.GetRenewalInfo(context.Background(), certPEM)
|
|
if err != nil {
|
|
t.Fatalf("expected nil error on 404, got: %v", err)
|
|
}
|
|
if res != nil {
|
|
t.Errorf("expected nil result on 404, got: %+v", res)
|
|
}
|
|
}
|
|
|
|
// TestGetRenewalInfo_ARIMalformedJSON pins the parse-error branch.
|
|
func TestGetRenewalInfo_ARIMalformedJSON(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/directory" {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprintf(w, `{"renewalInfo":%q}`, "http://"+r.Host+"/renewalInfo")
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = io.WriteString(w, `{"suggestedWindow": invalid`)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
c := preWiredConnector(t, &Config{
|
|
DirectoryURL: ts.URL + "/directory",
|
|
Email: "test@example.com",
|
|
ChallengeType: "http-01",
|
|
ARIEnabled: true,
|
|
})
|
|
certPEM := makeTestCertPEM(t)
|
|
|
|
_, err := c.GetRenewalInfo(context.Background(), certPEM)
|
|
if err == nil {
|
|
t.Fatal("expected parse error on malformed ARI JSON")
|
|
}
|
|
if !strings.Contains(err.Error(), "parse ARI response") {
|
|
t.Errorf("error %q should wrap 'parse ARI response'", err)
|
|
}
|
|
}
|
|
|
|
// TestGetRenewalInfo_ARIEmptyWindow pins the "missing or empty
|
|
// suggestedWindow" branch.
|
|
func TestGetRenewalInfo_ARIEmptyWindow(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/directory" {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprintf(w, `{"renewalInfo":%q}`, "http://"+r.Host+"/renewalInfo")
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = io.WriteString(w, `{}`)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
c := preWiredConnector(t, &Config{
|
|
DirectoryURL: ts.URL + "/directory",
|
|
Email: "test@example.com",
|
|
ChallengeType: "http-01",
|
|
ARIEnabled: true,
|
|
})
|
|
certPEM := makeTestCertPEM(t)
|
|
|
|
_, err := c.GetRenewalInfo(context.Background(), certPEM)
|
|
if err == nil {
|
|
t.Fatal("expected error on empty suggestedWindow")
|
|
}
|
|
if !strings.Contains(err.Error(), "missing or empty suggestedWindow") {
|
|
t.Errorf("error %q should mention 'missing or empty suggestedWindow'", err)
|
|
}
|
|
}
|
|
|
|
// TestGetRenewalInfo_HappyPath pins the success branch end-to-end.
|
|
func TestGetRenewalInfo_HappyPath(t *testing.T) {
|
|
start := time.Now().Add(time.Hour).UTC().Format(time.RFC3339)
|
|
end := time.Now().Add(2 * time.Hour).UTC().Format(time.RFC3339)
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/directory" {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprintf(w, `{"renewalInfo":%q}`, "http://"+r.Host+"/renewalInfo")
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprintf(w, `{"suggestedWindow":{"start":%q,"end":%q},"explanationURL":"https://example.com/why"}`, start, end)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
c := preWiredConnector(t, &Config{
|
|
DirectoryURL: ts.URL + "/directory",
|
|
Email: "test@example.com",
|
|
ChallengeType: "http-01",
|
|
ARIEnabled: true,
|
|
})
|
|
certPEM := makeTestCertPEM(t)
|
|
|
|
res, err := c.GetRenewalInfo(context.Background(), certPEM)
|
|
if err != nil {
|
|
t.Fatalf("expected success, got: %v", err)
|
|
}
|
|
if res == nil {
|
|
t.Fatal("expected non-nil result")
|
|
}
|
|
if res.SuggestedWindowStart.IsZero() || res.SuggestedWindowEnd.IsZero() {
|
|
t.Errorf("window timestamps should be parsed, got start=%v end=%v", res.SuggestedWindowStart, res.SuggestedWindowEnd)
|
|
}
|
|
if res.ExplanationURL != "https://example.com/why" {
|
|
t.Errorf("explanationURL = %q; want 'https://example.com/why'", res.ExplanationURL)
|
|
}
|
|
}
|
|
|
|
// TestGetRenewalInfo_DirectoryMalformedJSONUsesFallback pins that a malformed
|
|
// directory JSON does NOT abort — getARIEndpoint silently uses the
|
|
// constructARIURLFallback URL, which then drives the ARI GET.
|
|
func TestGetRenewalInfo_DirectoryMalformedJSONUsesFallback(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/directory" {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = io.WriteString(w, `{not json`)
|
|
return
|
|
}
|
|
// /renewalInfo/{certID} after fallback (directory URL stripped of /directory)
|
|
http.Error(w, "fallback hit ok", http.StatusNotFound)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
c := preWiredConnector(t, &Config{
|
|
DirectoryURL: ts.URL + "/directory",
|
|
Email: "test@example.com",
|
|
ChallengeType: "http-01",
|
|
ARIEnabled: true,
|
|
})
|
|
certPEM := makeTestCertPEM(t)
|
|
|
|
res, err := c.GetRenewalInfo(context.Background(), certPEM)
|
|
// 404 from the fallback URL is the "no ARI" short-circuit → (nil, nil)
|
|
if err != nil {
|
|
t.Fatalf("expected nil error on fallback 404, got: %v", err)
|
|
}
|
|
if res != nil {
|
|
t.Errorf("expected nil result, got: %+v", res)
|
|
}
|
|
}
|
|
|
|
// TestGetRenewalInfo_ARIInvalidPEM pins the cert-ID computation error branch
|
|
// with a known-bad PEM.
|
|
func TestGetRenewalInfo_ARIInvalidPEM(t *testing.T) {
|
|
c := preWiredConnector(t, &Config{
|
|
DirectoryURL: "https://acme.invalid/directory",
|
|
Email: "test@example.com",
|
|
ChallengeType: "http-01",
|
|
ARIEnabled: true,
|
|
})
|
|
_, err := c.GetRenewalInfo(context.Background(), "not a pem")
|
|
if err == nil {
|
|
t.Fatal("expected error on invalid PEM")
|
|
}
|
|
if !strings.Contains(err.Error(), "compute ARI cert ID") {
|
|
t.Errorf("error %q should wrap 'compute ARI cert ID'", err)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// authorizeOrderWithProfile failure modes (Bundle J — C-001 rows 1-7)
|
|
// ---------------------------------------------------------------------------
|
|
//
|
|
// authorizeOrderWithProfile fast-paths to client.AuthorizeOrder when profile
|
|
// is empty. With profile set, it does Discover + GetReg + fetchNonce + JWS-
|
|
// signed POST. We test the failure paths for the JWS-POST branch and rely
|
|
// on the existing tests for the no-profile fast path.
|
|
//
|
|
// To exercise these, we need a Discover-able directory + a GetReg-cooperative
|
|
// server. Building the GetReg JWS-validate is heavy; we instead test the
|
|
// pre-GetReg failures (Discover failure modes) which exercise the early
|
|
// branches of authorizeOrderWithProfile.
|
|
|
|
// TestAuthorizeOrderWithProfile_DiscoveryFails pins the directory-fetch
|
|
// failure branch. We close the directory server before the call.
|
|
func TestAuthorizeOrderWithProfile_DiscoveryFails(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
|
url := ts.URL
|
|
ts.Close()
|
|
|
|
c := preWiredConnector(t, &Config{
|
|
DirectoryURL: url + "/directory",
|
|
Email: "test@example.com",
|
|
ChallengeType: "http-01",
|
|
Profile: "tlsserver",
|
|
})
|
|
|
|
_, err := c.authorizeOrderWithProfile(context.Background(),
|
|
[]goacme.AuthzID{{Type: "dns", Value: "example.com"}},
|
|
"tlsserver")
|
|
if err == nil {
|
|
t.Fatal("expected error when directory unreachable")
|
|
}
|
|
if !strings.Contains(err.Error(), "directory discovery failed") {
|
|
t.Errorf("error %q should wrap 'directory discovery failed'", err)
|
|
}
|
|
}
|
|
|
|
// TestAuthorizeOrderWithProfile_NoProfileFastPath confirms the fast-path
|
|
// (empty profile) delegates to client.AuthorizeOrder which fails on an
|
|
// unreachable directory with a different error wrap.
|
|
func TestAuthorizeOrderWithProfile_NoProfileFastPath(t *testing.T) {
|
|
c := preWiredConnector(t, &Config{
|
|
DirectoryURL: "http://127.0.0.1:1/directory",
|
|
Email: "test@example.com",
|
|
ChallengeType: "http-01",
|
|
})
|
|
|
|
_, err := c.authorizeOrderWithProfile(context.Background(),
|
|
[]goacme.AuthzID{{Type: "dns", Value: "example.com"}},
|
|
"") // empty profile → fast path
|
|
if err == nil {
|
|
t.Fatal("expected error when directory unreachable")
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// fetchNonce failure modes (helper used by profile flow)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestFetchNonce_NoURL(t *testing.T) {
|
|
c := preWiredConnector(t, &Config{DirectoryURL: "x", Email: "x@x.com"})
|
|
_, err := c.fetchNonce(context.Background(), "")
|
|
if err == nil || !strings.Contains(err.Error(), "no nonce URL") {
|
|
t.Fatalf("expected 'no nonce URL' error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestFetchNonce_NoReplayHeader(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Don't set Replay-Nonce
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
c := preWiredConnector(t, &Config{DirectoryURL: "x", Email: "x@x.com"})
|
|
_, err := c.fetchNonce(context.Background(), ts.URL)
|
|
if err == nil || !strings.Contains(err.Error(), "Replay-Nonce") {
|
|
t.Fatalf("expected Replay-Nonce error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestFetchNonce_NetworkError(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
|
url := ts.URL
|
|
ts.Close()
|
|
|
|
c := preWiredConnector(t, &Config{DirectoryURL: "x", Email: "x@x.com"})
|
|
_, err := c.fetchNonce(context.Background(), url)
|
|
if err == nil || !strings.Contains(err.Error(), "nonce request failed") {
|
|
t.Fatalf("expected nonce request error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestFetchNonce_HappyPath(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Replay-Nonce", "test-nonce-abc")
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
c := preWiredConnector(t, &Config{DirectoryURL: "x", Email: "x@x.com"})
|
|
nonce, err := c.fetchNonce(context.Background(), ts.URL)
|
|
if err != nil {
|
|
t.Fatalf("expected success, got: %v", err)
|
|
}
|
|
if nonce != "test-nonce-abc" {
|
|
t.Errorf("nonce = %q; want 'test-nonce-abc'", nonce)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// RevokeCertificate / GetCACertPEM / GenerateCRL / SignOCSPResponse —
|
|
// fallback / always-error paths
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// TestRevokeCertificate_UnwiredCertLookupFallback exercises the
|
|
// backward-compat branch in RevokeCertificate that fires when
|
|
// SetCertificateLookup was never called. Audit fix #7 replaced the
|
|
// historical "ACME revocation by serial not supported in V1" error
|
|
// with a more actionable one pointing at the wiring requirement; the
|
|
// production path always wires the lookup, so this branch only fires
|
|
// in tests / old wiring paths.
|
|
func TestRevokeCertificate_UnwiredCertLookupFallback(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = io.WriteString(w, `{"newOrder":"","newAccount":"","newNonce":""}`)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
c := preWiredConnector(t, &Config{
|
|
DirectoryURL: ts.URL,
|
|
Email: "test@example.com",
|
|
ChallengeType: "http-01",
|
|
})
|
|
// Intentionally do NOT call SetCertificateLookup — that's the
|
|
// behaviour under test.
|
|
|
|
reason := "keyCompromise"
|
|
err := c.RevokeCertificate(context.Background(), issuer.RevocationRequest{
|
|
Serial: "ABC123",
|
|
Reason: &reason,
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected error when CertificateLookup is unwired")
|
|
}
|
|
if !strings.Contains(err.Error(), "CertificateLookup") {
|
|
t.Errorf("error %q should reference CertificateLookup wiring", err)
|
|
}
|
|
}
|
|
|
|
// TestGetOrderStatus_EnsureClientFails confirms client-init failures
|
|
// propagate through GetOrderStatus.
|
|
func TestGetOrderStatus_EnsureClientFails(t *testing.T) {
|
|
c := New(&Config{
|
|
DirectoryURL: "https://acme.example.com/directory",
|
|
Email: "test@example.com",
|
|
EABKid: "bad",
|
|
EABHmac: "!!!not-base64!!!",
|
|
}, testLogger())
|
|
|
|
_, err := c.GetOrderStatus(context.Background(), "order-id")
|
|
if err == nil {
|
|
t.Fatal("expected error when EAB decode fails during ensureClient")
|
|
}
|
|
if !strings.Contains(err.Error(), "ACME client init") {
|
|
t.Errorf("error %q should wrap 'ACME client init'", err)
|
|
}
|
|
}
|
|
|
|
// TestRenewCertificate_DelegatesToIssue confirms RenewCertificate goes
|
|
// through IssueCertificate and inherits its early-failure path
|
|
// (ensureClient fails → propagated). We use an EAB decode failure.
|
|
func TestRenewCertificate_DelegatesToIssue(t *testing.T) {
|
|
c := New(&Config{
|
|
DirectoryURL: "https://acme.example.com/directory",
|
|
Email: "test@example.com",
|
|
EABKid: "bad",
|
|
EABHmac: "!!!not-base64!!!",
|
|
}, testLogger())
|
|
|
|
_, err := c.RenewCertificate(context.Background(), issuer.RenewalRequest{
|
|
CommonName: "example.com",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected error to propagate from underlying IssueCertificate")
|
|
}
|
|
if !strings.Contains(err.Error(), "ACME client init") {
|
|
t.Errorf("error %q should wrap 'ACME client init'", err)
|
|
}
|
|
}
|
|
|
|
// TestIssueCertificate_EnsureClientFails confirms client-init failures
|
|
// propagate through IssueCertificate.
|
|
func TestIssueCertificate_EnsureClientFails(t *testing.T) {
|
|
c := New(&Config{
|
|
DirectoryURL: "https://acme.example.com/directory",
|
|
Email: "test@example.com",
|
|
EABKid: "bad",
|
|
EABHmac: "!!!not-base64!!!",
|
|
}, testLogger())
|
|
|
|
_, err := c.IssueCertificate(context.Background(), issuer.IssuanceRequest{
|
|
CommonName: "example.com",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected error when EAB decode fails during ensureClient")
|
|
}
|
|
if !strings.Contains(err.Error(), "ACME client init") {
|
|
t.Errorf("error %q should wrap 'ACME client init'", err)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// startChallengeServer — covers the HTTP-01 challenge server path
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestStartChallengeServer_ServesKnownToken(t *testing.T) {
|
|
c := New(&Config{
|
|
DirectoryURL: "https://acme.example.com/directory",
|
|
Email: "test@example.com",
|
|
HTTPPort: 0, // ephemeral
|
|
}, testLogger())
|
|
|
|
// Pre-load a token
|
|
c.challengeMu.Lock()
|
|
c.challengeTokens["tok-abc"] = "key-auth-xyz"
|
|
c.challengeMu.Unlock()
|
|
|
|
// Use port 0 so the OS picks a free port. The Server is bound via
|
|
// net.Listen on the formatted addr; for port 0 the listener gets a real
|
|
// port. We invoke the function and shut down immediately.
|
|
srv, err := c.startChallengeServer()
|
|
if err != nil {
|
|
t.Skipf("could not bind challenge server (env may not allow): %v", err)
|
|
}
|
|
defer func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
_ = srv.Shutdown(ctx)
|
|
}()
|
|
|
|
// The server is bound; we can't trivially address it because Addr is set
|
|
// to the formatted port string from cfg (":0"), and net.Listen returned a
|
|
// real addr we don't capture. So this test only proves the function
|
|
// returns without error and the goroutine starts. Functional verification
|
|
// of the handler is exercised below.
|
|
if srv == nil {
|
|
t.Fatal("expected non-nil server")
|
|
}
|
|
}
|
|
|
|
// TestChallengeHandler_KnownAndUnknownTokens exercises the http handler
|
|
// directly without binding a port, by replaying it through httptest.
|
|
func TestChallengeHandler_KnownAndUnknownTokens(t *testing.T) {
|
|
c := New(&Config{
|
|
DirectoryURL: "https://acme.example.com/directory",
|
|
Email: "test@example.com",
|
|
HTTPPort: 1, // unused by this test
|
|
}, testLogger())
|
|
|
|
c.challengeMu.Lock()
|
|
c.challengeTokens["good-token"] = "key-auth-data"
|
|
c.challengeMu.Unlock()
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/.well-known/acme-challenge/", func(w http.ResponseWriter, r *http.Request) {
|
|
token := r.URL.Path[len("/.well-known/acme-challenge/"):]
|
|
c.challengeMu.RLock()
|
|
keyAuth, ok := c.challengeTokens[token]
|
|
c.challengeMu.RUnlock()
|
|
if !ok {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/octet-stream")
|
|
_, _ = w.Write([]byte(keyAuth))
|
|
})
|
|
|
|
srv := httptest.NewServer(mux)
|
|
defer srv.Close()
|
|
|
|
// Known token
|
|
resp, err := http.Get(srv.URL + "/.well-known/acme-challenge/good-token")
|
|
if err != nil {
|
|
t.Fatalf("get good-token: %v", err)
|
|
}
|
|
body, _ := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
if string(body) != "key-auth-data" {
|
|
t.Errorf("body = %q; want 'key-auth-data'", string(body))
|
|
}
|
|
|
|
// Unknown token
|
|
resp, err = http.Get(srv.URL + "/.well-known/acme-challenge/missing")
|
|
if err != nil {
|
|
t.Fatalf("get missing: %v", err)
|
|
}
|
|
resp.Body.Close()
|
|
if resp.StatusCode != 404 {
|
|
t.Errorf("status = %d; want 404", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// presentPersistRecord — covers the dns-persist-01 helper
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestPresentPersistRecord_NoSolver(t *testing.T) {
|
|
c := New(&Config{
|
|
DirectoryURL: "https://acme.example.com/directory",
|
|
Email: "test@example.com",
|
|
}, testLogger())
|
|
// dnsSolver is nil
|
|
err := c.presentPersistRecord(context.Background(), "example.com", "tok", "value")
|
|
if err == nil || !strings.Contains(err.Error(), "DNS solver not configured") {
|
|
t.Fatalf("expected 'DNS solver not configured' error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// fakeDNSSolver implements DNSSolver for testing presentPersistRecord
|
|
// fallback path.
|
|
type fakeDNSSolver struct {
|
|
presentCalled bool
|
|
cleanupCalled bool
|
|
domain string
|
|
token string
|
|
keyAuth string
|
|
}
|
|
|
|
func (f *fakeDNSSolver) Present(ctx context.Context, domain, token, keyAuth string) error {
|
|
f.presentCalled = true
|
|
f.domain = domain
|
|
f.token = token
|
|
f.keyAuth = keyAuth
|
|
return nil
|
|
}
|
|
func (f *fakeDNSSolver) CleanUp(ctx context.Context, domain, token, keyAuth string) error {
|
|
f.cleanupCalled = true
|
|
return nil
|
|
}
|
|
|
|
func TestPresentPersistRecord_FallbackToPresent(t *testing.T) {
|
|
c := New(&Config{
|
|
DirectoryURL: "https://acme.example.com/directory",
|
|
Email: "test@example.com",
|
|
}, testLogger())
|
|
fake := &fakeDNSSolver{}
|
|
c.dnsSolver = fake
|
|
|
|
err := c.presentPersistRecord(context.Background(), "example.com", "tok123", "letsencrypt.org; accounturi=acct-uri")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if !fake.presentCalled {
|
|
t.Error("expected fallback Present to be called for non-ScriptDNSSolver")
|
|
}
|
|
if fake.domain != "example.com" || fake.token != "tok123" {
|
|
t.Errorf("Present args: domain=%q token=%q", fake.domain, fake.token)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// computeARICertID additional cases
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestComputeARICertID_ValidPEM(t *testing.T) {
|
|
pemStr := makeTestCertPEM(t)
|
|
id, err := computeARICertID(pemStr)
|
|
if err != nil {
|
|
t.Fatalf("expected success, got: %v", err)
|
|
}
|
|
if id == "" {
|
|
t.Error("expected non-empty cert ID")
|
|
}
|
|
// The ID should be base64url-no-padding (so no '=' or '+' or '/')
|
|
if strings.ContainsAny(id, "=+/") {
|
|
t.Errorf("cert ID %q should be base64url-no-padding", id)
|
|
}
|
|
}
|
|
|
|
// TestComputeARICertID_DeterministicForSameInput pins idempotency.
|
|
func TestComputeARICertID_DeterministicForSameInput(t *testing.T) {
|
|
pemStr := makeTestCertPEM(t)
|
|
id1, err1 := computeARICertID(pemStr)
|
|
id2, err2 := computeARICertID(pemStr)
|
|
if err1 != nil || err2 != nil {
|
|
t.Fatalf("err1=%v err2=%v", err1, err2)
|
|
}
|
|
if id1 != id2 {
|
|
t.Errorf("cert ID not deterministic: %q vs %q", id1, id2)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// fetchZeroSSLEAB additional success-shape variations
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestFetchZeroSSLEAB_SuccessFalse(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = io.WriteString(w, `{"success":false,"error":"throttled","eab_kid":"","eab_hmac_key":""}`)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
orig := zeroSSLEABEndpoint
|
|
defer func() { zeroSSLEABEndpoint = orig }()
|
|
zeroSSLEABEndpoint = ts.URL
|
|
|
|
_, _, err := fetchZeroSSLEAB(context.Background(), "x@example.com")
|
|
if err == nil || !strings.Contains(err.Error(), "EAB generation failed") {
|
|
t.Fatalf("expected 'EAB generation failed', got: %v", err)
|
|
}
|
|
if !strings.Contains(err.Error(), "throttled") {
|
|
t.Errorf("error %q should include upstream message 'throttled'", err)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// preWiredConnector smoke — confirms the fixture works as expected
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestPreWiredConnector_ShortCircuitsEnsureClient(t *testing.T) {
|
|
c := preWiredConnector(t, &Config{
|
|
DirectoryURL: "https://acme.example.com/directory",
|
|
Email: "test@example.com",
|
|
ChallengeType: "http-01",
|
|
})
|
|
// ensureClient should be a no-op
|
|
if err := c.ensureClient(context.Background()); err != nil {
|
|
t.Errorf("expected pre-wired ensureClient to no-op, got: %v", err)
|
|
}
|
|
if c.client == nil {
|
|
t.Error("client should remain set")
|
|
}
|
|
if c.accountKey == nil {
|
|
t.Error("accountKey should remain set")
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Defense-in-depth: error messages must NOT leak HMAC key bytes
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// TestErrorPaths_DoNotLeakHMACKey is a defense-in-depth grep over a sampling
|
|
// of error returns. The HMAC key is base64url-decoded into a []byte and
|
|
// attached to the account; if any wrapped error accidentally serialized the
|
|
// key, this test would catch it.
|
|
func TestErrorPaths_DoNotLeakHMACKey(t *testing.T) {
|
|
// Use a known HMAC key + capture its base64url form
|
|
rawKey := []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}
|
|
hmacB64 := "AQIDBAUGBwg" // base64url-no-padding of rawKey (8 bytes -> 11 chars)
|
|
c := New(&Config{
|
|
DirectoryURL: "https://127.0.0.1:1/directory", // unreachable
|
|
Email: "test@example.com",
|
|
EABKid: "kid-abc",
|
|
EABHmac: hmacB64,
|
|
}, testLogger())
|
|
|
|
err := c.ensureClient(context.Background())
|
|
// We don't care about the error type — only that the message doesn't
|
|
// contain any byte of the raw key (or its base64url form, since the
|
|
// b64 form is already committed to logs/errors as a kid in some places
|
|
// and may surface; we ban the raw byte sequence specifically).
|
|
if err == nil {
|
|
// If success (e.g. server reachable somehow), nothing to verify
|
|
return
|
|
}
|
|
// Convert raw key to a string and search; this is a very weak sanity
|
|
// check (random byte values may coincidentally appear), but the byte
|
|
// sequence is short and specific enough for this defense check.
|
|
for _, b := range rawKey {
|
|
// Looking for the byte verbatim would catch a fmt.Sprintf("%v", key)
|
|
if strings.ContainsRune(err.Error(), rune(b)) && b > 0 && b < 0x20 {
|
|
// Control byte in error message → suspicious. A normal error
|
|
// message shouldn't contain raw control bytes.
|
|
t.Errorf("error message contains suspicious control byte %#x; possible HMAC key leak: %q", b, err.Error())
|
|
}
|
|
}
|
|
}
|
|
|
|
// Compile-time check that the issuer.Connector interface is implemented.
|
|
var _ issuer.Connector = (*Connector)(nil)
|
|
|
|
// Suppress unused-import warning on json (we may not use it in some paths).
|
|
var _ = json.Unmarshal
|