mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-13 20:10:32 +00:00
0ffcdedc8e
Closes the deferred >=85% gate on internal/connector/issuer/acme that
Bundle J left at 55.6% (failure-mode batch only). The remaining gap
was IssueCertificate + solveAuthorizations* + authorizeOrderWithProfile's
JWS-POST branch — all uncoverable without a Pebble-style ACME server
that handles the full RFC 8555 flow.
What shipped
============
internal/connector/issuer/acme/pebble_mock_test.go (~900 LoC):
- RFC 8555 state machine: newAccount (with onlyReturnExisting=true
short-circuit returning HTTP 200 for stdlib's GetReg(ctx, '') vs
201 for fresh registration) + newOrder + authz + challenge +
finalize + cert + order-poll + account-self
- JWS envelope parsing (no signature verification — stdlib client
signs correctly; test exercises connector code, not stdlib JWS)
- Nonce ring with badNonce errors on replays
- In-process self-signed ECDSA P-256 CA fixture
- Mock DNSSolver with Present / CleanUp / PresentPersist
13 new tests
============
- IssueCertificate_HappyPath / MultiSAN / WithProfile
- RenewCertificate_DelegatesToIssue
- GetOrderStatus_HappyPath
- NewAccountFailure_ReturnsError
- FinalizeProcessingStuck_RecoversToValid
- FinalizeReturnsInvalid_FailsClean
- ContextCancel_DuringIssuance
- BadCSR_RejectedByMock
- IssueCertificate_HTTP01ChallengeFlow (exercises
solveAuthorizationsHTTP01 + startChallengeServer)
- IssueCertificate_DNS01ChallengeFlow + DNS01_PresentFails +
DNS01_NoSolver
- IssueCertificate_DNSPersist01ChallengeFlow +
DNSPersist01_FallbackToDNS01 + DNSPersist01_NoSolver
Coverage trajectory
============
Pre-Bundle-J: 41.8%
Post-Bundle-J: 55.6% (+13.8pp; failure-mode batch)
Post-Bundle-J-extended: 85.4% (+29.8pp; Pebble-mock issuance)
Total delta: +43.6pp; +0.4 above 85% gate
Per-function deltas (vs Pre-Bundle-J baseline):
IssueCertificate: 0.0% -> 100.0%
solveAuthorizations: 0.0% -> 100.0%
solveAuthorizationsHTTP01: 0.0% -> 88.4%
solveAuthorizationsDNS01: 0.0% -> 91.4%
solveAuthorizationsDNSPersist01: 0.0% -> 87.0%
authorizeOrderWithProfile: 0.0% -> 92.5%
GetOrderStatus: 0.0% -> 100.0%
startChallengeServer: 0.0% -> 100.0%
Verification
============
- go test -count=1 -timeout=20s ./internal/connector/issuer/acme/...:
PASS in 1.4s
- go test -short -count=1 -cover ./internal/connector/issuer/acme/...:
85.4%
- go vet ./internal/connector/issuer/acme/...: clean
Audit deliverables
============
- findings.yaml C-001: partial_closed -> closed with full closure
note enumerating all 13 tests + per-function deltas
- gap-backlog.md C-001: full strikethrough with closure note
- coverage-audit-2026-04-27/extension-progress.md: J-extended DONE
Closes: C-001 (ACME Existential coverage)
Bundle: J-extended (Coverage Audit Extension)
1268 lines
43 KiB
Go
1268 lines
43 KiB
Go
package acme
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"math/big"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/shankar0123/certctl/internal/connector/issuer"
|
|
)
|
|
|
|
// Bundle J-extended (C-001 closure): Pebble-style hermetic ACME mock.
|
|
//
|
|
// Lifts internal/connector/issuer/acme coverage 55.6% → ~85% by exercising
|
|
// the previously-uncovered IssueCertificate / authorizeOrderWithProfile /
|
|
// solveAuthorizations* / GetOrderStatus happy paths against a full RFC 8555
|
|
// state machine.
|
|
//
|
|
// Design:
|
|
// - Plain HTTP (httptest.NewServer) — RFC 8555 mandates HTTPS in production
|
|
// but the stdlib `*acme.Client` accepts http:// directory URLs. The
|
|
// connector's Insecure flag is irrelevant for plain-HTTP mocks.
|
|
// - Single mux dispatching: /directory, /new-nonce, /new-account, /new-order,
|
|
// /authz/<id>, /chall/<id>, /finalize/<id>, /order/<id>, /cert/<id>.
|
|
// - JWS parsing only (no signature verification): the stdlib client signs
|
|
// correctly; the test's value is exercising connector code, not fuzzing
|
|
// stdlib JWS. Pebble does proper verification — we skip it for budget.
|
|
// - Authzs auto-flip to "valid" on creation. This bypasses the HTTP-01
|
|
// challenge-server port-binding problem (challenge server tries to bind
|
|
// port 80 by default) without requiring a production-code change. The
|
|
// connector's solve-and-poll loop sees `status: valid` immediately and
|
|
// short-circuits.
|
|
// - CA fixture: in-process self-signed CA; finalize endpoint signs the CSR
|
|
// against this CA and returns DER chain.
|
|
// - Nonce ring: every response carries `Replay-Nonce`. Server tracks
|
|
// issued/consumed; replays return badNonce + fresh nonce.
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Pebble-mock state machine
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
type pebbleAccount struct {
|
|
URL string
|
|
Status string
|
|
}
|
|
|
|
type pebbleAuthz struct {
|
|
ID string
|
|
URL string
|
|
Status string // "pending" | "valid" | "invalid"
|
|
Identifier wireAuthzID
|
|
Challenges []*pebbleChallenge
|
|
}
|
|
|
|
type pebbleChallenge struct {
|
|
ID string
|
|
URL string
|
|
Type string
|
|
Token string
|
|
Status string
|
|
}
|
|
|
|
type pebbleOrder struct {
|
|
ID string
|
|
URL string
|
|
Status string // "pending" | "ready" | "processing" | "valid" | "invalid"
|
|
Identifiers []wireAuthzID
|
|
AuthzURLs []string
|
|
FinalizeURL string
|
|
CertURL string
|
|
NotBefore string
|
|
NotAfter string
|
|
Profile string
|
|
finalizeCount int // increments on each finalize POST
|
|
}
|
|
|
|
type pebbleMockServer struct {
|
|
t *testing.T
|
|
server *httptest.Server
|
|
mu sync.Mutex
|
|
caCert *x509.Certificate
|
|
caKey *ecdsa.PrivateKey
|
|
caPEM []byte
|
|
accounts map[string]*pebbleAccount // accountURL → account
|
|
authzs map[string]*pebbleAuthz // authzID → authz
|
|
chals map[string]*pebbleChallenge // chalID → chal
|
|
orders map[string]*pebbleOrder // orderID → order
|
|
certs map[string][]byte // certID → PEM chain
|
|
nonces map[string]bool // nonceID → consumed?
|
|
idSeq int64
|
|
// Behavior toggles for failure-mode tests.
|
|
failNewAccount bool
|
|
rateLimitedOrder int32 // atomic counter; non-zero ⇒ first N orders return 429
|
|
finalizeReturns string // "" (default), "processing-stuck", "invalid"
|
|
authzPending bool // when true, new authzs start as "pending" and only flip to "valid" after the challenge endpoint is POSTed
|
|
challengeType string // when set, the per-authz challenge type emitted (default "http-01")
|
|
}
|
|
|
|
// startPebbleMock builds the in-process CA fixture + state-machine maps + HTTP
|
|
// mux, returns a ready-to-use mock. t.Cleanup closes the server.
|
|
func startPebbleMock(t *testing.T) *pebbleMockServer {
|
|
t.Helper()
|
|
|
|
// Build self-signed CA fixture.
|
|
caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
if err != nil {
|
|
t.Fatalf("CA key gen: %v", err)
|
|
}
|
|
template := &x509.Certificate{
|
|
SerialNumber: big.NewInt(1),
|
|
Subject: pkix.Name{CommonName: "Pebble Mock Root CA"},
|
|
NotBefore: time.Now().Add(-1 * time.Hour),
|
|
NotAfter: time.Now().Add(365 * 24 * time.Hour),
|
|
IsCA: true,
|
|
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature,
|
|
BasicConstraintsValid: true,
|
|
}
|
|
caDER, err := x509.CreateCertificate(rand.Reader, template, template, &caKey.PublicKey, caKey)
|
|
if err != nil {
|
|
t.Fatalf("CA cert: %v", err)
|
|
}
|
|
caCert, _ := x509.ParseCertificate(caDER)
|
|
caPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caDER})
|
|
|
|
srv := &pebbleMockServer{
|
|
t: t,
|
|
caCert: caCert,
|
|
caKey: caKey,
|
|
caPEM: caPEM,
|
|
accounts: make(map[string]*pebbleAccount),
|
|
authzs: make(map[string]*pebbleAuthz),
|
|
chals: make(map[string]*pebbleChallenge),
|
|
orders: make(map[string]*pebbleOrder),
|
|
certs: make(map[string][]byte),
|
|
nonces: make(map[string]bool),
|
|
}
|
|
|
|
mux := http.NewServeMux()
|
|
logged := func(name string, h http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if testing.Verbose() {
|
|
t.Logf("MOCK %s %s %s", name, r.Method, r.URL.Path)
|
|
}
|
|
h(w, r)
|
|
}
|
|
}
|
|
mux.HandleFunc("/directory", logged("directory", srv.handleDirectory))
|
|
mux.HandleFunc("/new-nonce", logged("new-nonce", srv.handleNewNonce))
|
|
mux.HandleFunc("/new-account", logged("new-account", srv.handleNewAccount))
|
|
mux.HandleFunc("/new-order", logged("new-order", srv.handleNewOrder))
|
|
mux.HandleFunc("/authz/", logged("authz", srv.handleAuthz))
|
|
mux.HandleFunc("/chall/", logged("chall", srv.handleChallenge))
|
|
mux.HandleFunc("/finalize/", logged("finalize", srv.handleFinalize))
|
|
mux.HandleFunc("/order/", logged("order", srv.handleOrder))
|
|
mux.HandleFunc("/cert/", logged("cert", srv.handleCert))
|
|
mux.HandleFunc("/account/", logged("account", srv.handleAccount))
|
|
|
|
srv.server = httptest.NewServer(mux)
|
|
t.Cleanup(srv.server.Close)
|
|
return srv
|
|
}
|
|
|
|
func (p *pebbleMockServer) URL() string { return p.server.URL }
|
|
|
|
// nextID returns a fresh deterministic-ish ID like "id-1", "id-2", ...
|
|
func (p *pebbleMockServer) nextID(prefix string) string {
|
|
n := atomic.AddInt64(&p.idSeq, 1)
|
|
return fmt.Sprintf("%s-%d", prefix, n)
|
|
}
|
|
|
|
// freshNonce mints a new nonce, marks it issued, returns its base64url id.
|
|
func (p *pebbleMockServer) freshNonce() string {
|
|
buf := make([]byte, 16)
|
|
_, _ = rand.Read(buf)
|
|
id := base64.RawURLEncoding.EncodeToString(buf)
|
|
p.mu.Lock()
|
|
p.nonces[id] = false // false = issued, not yet consumed
|
|
p.mu.Unlock()
|
|
return id
|
|
}
|
|
|
|
// consumeNonce marks a nonce as consumed; returns false if unknown or replay.
|
|
func (p *pebbleMockServer) consumeNonce(id string) bool {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
consumed, exists := p.nonces[id]
|
|
if !exists || consumed {
|
|
return false
|
|
}
|
|
p.nonces[id] = true
|
|
return true
|
|
}
|
|
|
|
// writeWithNonce wraps response writes to attach a fresh Replay-Nonce header.
|
|
func (p *pebbleMockServer) writeWithNonce(w http.ResponseWriter, status int, body []byte, locationURL string) {
|
|
w.Header().Set("Replay-Nonce", p.freshNonce())
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if locationURL != "" {
|
|
w.Header().Set("Location", locationURL)
|
|
}
|
|
w.WriteHeader(status)
|
|
if body != nil {
|
|
_, _ = w.Write(body)
|
|
}
|
|
}
|
|
|
|
// jwsBody represents the flattened JWS JSON shape the stdlib client posts.
|
|
type jwsBody struct {
|
|
Protected string `json:"protected"`
|
|
Payload string `json:"payload"`
|
|
Signature string `json:"signature"`
|
|
}
|
|
|
|
// jwsHeader is the parsed protected header (only fields we need).
|
|
type jwsHeader struct {
|
|
Alg string `json:"alg"`
|
|
Kid string `json:"kid,omitempty"`
|
|
Jwk json.RawMessage `json:"jwk,omitempty"`
|
|
Nonce string `json:"nonce"`
|
|
URL string `json:"url"`
|
|
}
|
|
|
|
// parseJWS reads the request body, decodes the JWS, returns header + payload bytes.
|
|
// Does NOT verify the signature — the stdlib client signs correctly; this mock
|
|
// only tracks state.
|
|
func (p *pebbleMockServer) parseJWS(r *http.Request) (*jwsHeader, []byte, error) {
|
|
bodyBytes, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("read body: %w", err)
|
|
}
|
|
var jws jwsBody
|
|
if err := json.Unmarshal(bodyBytes, &jws); err != nil {
|
|
return nil, nil, fmt.Errorf("parse JWS envelope: %w", err)
|
|
}
|
|
headerBytes, err := base64.RawURLEncoding.DecodeString(jws.Protected)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("decode protected header: %w", err)
|
|
}
|
|
var header jwsHeader
|
|
if err := json.Unmarshal(headerBytes, &header); err != nil {
|
|
return nil, nil, fmt.Errorf("parse protected header: %w", err)
|
|
}
|
|
var payload []byte
|
|
if jws.Payload != "" {
|
|
payload, err = base64.RawURLEncoding.DecodeString(jws.Payload)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("decode payload: %w", err)
|
|
}
|
|
}
|
|
return &header, payload, nil
|
|
}
|
|
|
|
// writeError emits a `urn:ietf:params:acme:error:*` JSON problem with a fresh
|
|
// Replay-Nonce header. The stdlib client parses these problems via
|
|
// the `acme.Error` type.
|
|
func (p *pebbleMockServer) writeError(w http.ResponseWriter, status int, errorType, detail string) {
|
|
body, _ := json.Marshal(map[string]interface{}{
|
|
"type": "urn:ietf:params:acme:error:" + errorType,
|
|
"detail": detail,
|
|
"status": status,
|
|
})
|
|
p.writeWithNonce(w, status, body, "")
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Endpoint handlers
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
func (p *pebbleMockServer) handleDirectory(w http.ResponseWriter, r *http.Request) {
|
|
dir := map[string]interface{}{
|
|
"newNonce": p.URL() + "/new-nonce",
|
|
"newAccount": p.URL() + "/new-account",
|
|
"newOrder": p.URL() + "/new-order",
|
|
"newAuthz": p.URL() + "/new-authz",
|
|
"revokeCert": p.URL() + "/revoke-cert",
|
|
"keyChange": p.URL() + "/key-change",
|
|
"meta": map[string]interface{}{
|
|
"termsOfService": p.URL() + "/tos",
|
|
},
|
|
}
|
|
body, _ := json.Marshal(dir)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Replay-Nonce", p.freshNonce())
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write(body)
|
|
}
|
|
|
|
func (p *pebbleMockServer) handleNewNonce(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Replay-Nonce", p.freshNonce())
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
if r.Method == http.MethodHead {
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (p *pebbleMockServer) handleNewAccount(w http.ResponseWriter, r *http.Request) {
|
|
if p.failNewAccount {
|
|
// Return 400 (badRequest) instead of 500 to avoid stdlib retry-backoff
|
|
// loop which can hang the test for >15s. The connector's error-handling
|
|
// is what we care about exercising, and 400 surfaces just as cleanly.
|
|
p.writeError(w, http.StatusBadRequest, "malformed", "test fixture: forced newAccount failure")
|
|
return
|
|
}
|
|
header, payload, err := p.parseJWS(r)
|
|
if err != nil {
|
|
p.writeError(w, http.StatusBadRequest, "malformed", err.Error())
|
|
return
|
|
}
|
|
if !p.consumeNonce(header.Nonce) {
|
|
p.writeError(w, http.StatusBadRequest, "badNonce", "nonce unknown or replayed")
|
|
return
|
|
}
|
|
|
|
// RFC 8555 §7.3.1: clients can POST `{"onlyReturnExisting": true}` to
|
|
// look up an existing account by key. Stdlib's Client.GetReg(ctx, "")
|
|
// uses this exact shape and expects HTTP 200 (not 201). The HappyPath
|
|
// flow (Register-only) hits the new-account branch and expects 201.
|
|
var req struct {
|
|
OnlyReturnExisting bool `json:"onlyReturnExisting"`
|
|
}
|
|
_ = json.Unmarshal(payload, &req)
|
|
|
|
if req.OnlyReturnExisting {
|
|
// Return the most-recently-created account (sufficient for tests
|
|
// that only register once before calling GetReg).
|
|
p.mu.Lock()
|
|
var existing *pebbleAccount
|
|
for _, a := range p.accounts {
|
|
existing = a
|
|
break
|
|
}
|
|
p.mu.Unlock()
|
|
if existing == nil {
|
|
p.writeError(w, http.StatusBadRequest, "accountDoesNotExist", "no account registered")
|
|
return
|
|
}
|
|
body, _ := json.Marshal(map[string]interface{}{
|
|
"status": existing.Status,
|
|
"contact": []string{"mailto:test@example.com"},
|
|
})
|
|
p.writeWithNonce(w, http.StatusOK, body, existing.URL)
|
|
return
|
|
}
|
|
|
|
id := p.nextID("acct")
|
|
acctURL := p.URL() + "/account/" + id
|
|
p.mu.Lock()
|
|
p.accounts[acctURL] = &pebbleAccount{URL: acctURL, Status: "valid"}
|
|
p.mu.Unlock()
|
|
body, _ := json.Marshal(map[string]interface{}{
|
|
"status": "valid",
|
|
"contact": []string{"mailto:test@example.com"},
|
|
})
|
|
p.writeWithNonce(w, http.StatusCreated, body, acctURL)
|
|
}
|
|
|
|
func (p *pebbleMockServer) handleNewOrder(w http.ResponseWriter, r *http.Request) {
|
|
// Optional rate-limit gate for badNonce/Retry-After tests.
|
|
if n := atomic.LoadInt32(&p.rateLimitedOrder); n > 0 {
|
|
atomic.AddInt32(&p.rateLimitedOrder, -1)
|
|
w.Header().Set("Retry-After", "1")
|
|
p.writeError(w, http.StatusTooManyRequests, "rateLimited", "test fixture: rate-limited")
|
|
return
|
|
}
|
|
header, payload, err := p.parseJWS(r)
|
|
if err != nil {
|
|
p.writeError(w, http.StatusBadRequest, "malformed", err.Error())
|
|
return
|
|
}
|
|
if !p.consumeNonce(header.Nonce) {
|
|
p.writeError(w, http.StatusBadRequest, "badNonce", "nonce unknown or replayed")
|
|
return
|
|
}
|
|
var req profileOrderRequest
|
|
if err := json.Unmarshal(payload, &req); err != nil {
|
|
p.writeError(w, http.StatusBadRequest, "malformed", "parse order: "+err.Error())
|
|
return
|
|
}
|
|
|
|
id := p.nextID("order")
|
|
orderURL := p.URL() + "/order/" + id
|
|
finalizeURL := p.URL() + "/finalize/" + id
|
|
|
|
// For each identifier, build an authz. If authzPending mode is active,
|
|
// authzs start "pending" and require a POST to the challenge endpoint
|
|
// to flip to "valid" — this exercises solveAuthorizations*. Default is
|
|
// pre-flipped to "valid".
|
|
chalType := p.challengeType
|
|
if chalType == "" {
|
|
chalType = "http-01"
|
|
}
|
|
authzStatus := "valid"
|
|
chalStatus := "valid"
|
|
orderStatus := "ready"
|
|
if p.authzPending {
|
|
authzStatus = "pending"
|
|
chalStatus = "pending"
|
|
orderStatus = "pending"
|
|
}
|
|
|
|
var authzURLs []string
|
|
for _, ident := range req.Identifiers {
|
|
aid := p.nextID("authz")
|
|
authzURL := p.URL() + "/authz/" + aid
|
|
chalID := p.nextID("chall")
|
|
chal := &pebbleChallenge{
|
|
ID: chalID,
|
|
URL: p.URL() + "/chall/" + chalID,
|
|
Type: chalType,
|
|
Token: base64.RawURLEncoding.EncodeToString([]byte(chalID)),
|
|
Status: chalStatus,
|
|
}
|
|
authz := &pebbleAuthz{
|
|
ID: aid,
|
|
URL: authzURL,
|
|
Status: authzStatus,
|
|
Identifier: ident,
|
|
Challenges: []*pebbleChallenge{chal},
|
|
}
|
|
p.mu.Lock()
|
|
p.authzs[aid] = authz
|
|
p.chals[chalID] = chal
|
|
p.mu.Unlock()
|
|
authzURLs = append(authzURLs, authzURL)
|
|
}
|
|
|
|
order := &pebbleOrder{
|
|
ID: id,
|
|
URL: orderURL,
|
|
Status: orderStatus,
|
|
Identifiers: req.Identifiers,
|
|
AuthzURLs: authzURLs,
|
|
FinalizeURL: finalizeURL,
|
|
Profile: req.Profile,
|
|
}
|
|
p.mu.Lock()
|
|
p.orders[id] = order
|
|
p.mu.Unlock()
|
|
|
|
body, _ := json.Marshal(map[string]interface{}{
|
|
"status": orderStatus,
|
|
"identifiers": req.Identifiers,
|
|
"authorizations": authzURLs,
|
|
"finalize": finalizeURL,
|
|
"expires": time.Now().Add(1 * time.Hour).Format(time.RFC3339),
|
|
})
|
|
p.writeWithNonce(w, http.StatusCreated, body, orderURL)
|
|
}
|
|
|
|
func (p *pebbleMockServer) handleAuthz(w http.ResponseWriter, r *http.Request) {
|
|
header, _, err := p.parseJWS(r)
|
|
if err != nil {
|
|
p.writeError(w, http.StatusBadRequest, "malformed", err.Error())
|
|
return
|
|
}
|
|
if !p.consumeNonce(header.Nonce) {
|
|
p.writeError(w, http.StatusBadRequest, "badNonce", "nonce unknown or replayed")
|
|
return
|
|
}
|
|
id := strings.TrimPrefix(r.URL.Path, "/authz/")
|
|
p.mu.Lock()
|
|
authz, ok := p.authzs[id]
|
|
p.mu.Unlock()
|
|
if !ok {
|
|
p.writeError(w, http.StatusNotFound, "malformed", "authz not found")
|
|
return
|
|
}
|
|
chals := make([]map[string]interface{}, 0, len(authz.Challenges))
|
|
for _, ch := range authz.Challenges {
|
|
chals = append(chals, map[string]interface{}{
|
|
"type": ch.Type,
|
|
"url": ch.URL,
|
|
"token": ch.Token,
|
|
"status": ch.Status,
|
|
})
|
|
}
|
|
body, _ := json.Marshal(map[string]interface{}{
|
|
"status": authz.Status,
|
|
"identifier": authz.Identifier,
|
|
"challenges": chals,
|
|
})
|
|
p.writeWithNonce(w, http.StatusOK, body, "")
|
|
}
|
|
|
|
func (p *pebbleMockServer) handleChallenge(w http.ResponseWriter, r *http.Request) {
|
|
header, _, err := p.parseJWS(r)
|
|
if err != nil {
|
|
p.writeError(w, http.StatusBadRequest, "malformed", err.Error())
|
|
return
|
|
}
|
|
if !p.consumeNonce(header.Nonce) {
|
|
p.writeError(w, http.StatusBadRequest, "badNonce", "nonce unknown or replayed")
|
|
return
|
|
}
|
|
id := strings.TrimPrefix(r.URL.Path, "/chall/")
|
|
p.mu.Lock()
|
|
chal, ok := p.chals[id]
|
|
if !ok {
|
|
p.mu.Unlock()
|
|
p.writeError(w, http.StatusNotFound, "malformed", "challenge not found")
|
|
return
|
|
}
|
|
// Flip the challenge AND its parent authz to valid; if all sibling
|
|
// authzs in any matching order are now valid, also flip the order to
|
|
// ready. This is what enables the solveAuthorizations*-loop tests:
|
|
// the connector POSTs to the challenge URL and then polls authz/order
|
|
// until status="valid"/"ready".
|
|
chal.Status = "valid"
|
|
for _, authz := range p.authzs {
|
|
for _, c := range authz.Challenges {
|
|
if c.ID == id {
|
|
authz.Status = "valid"
|
|
}
|
|
}
|
|
}
|
|
// Re-evaluate orders: if all authzs of an order are valid, flip ready.
|
|
for _, order := range p.orders {
|
|
allValid := true
|
|
for _, authzURL := range order.AuthzURLs {
|
|
parts := strings.Split(authzURL, "/")
|
|
authzID := parts[len(parts)-1]
|
|
if a := p.authzs[authzID]; a == nil || a.Status != "valid" {
|
|
allValid = false
|
|
break
|
|
}
|
|
}
|
|
if allValid && order.Status == "pending" {
|
|
order.Status = "ready"
|
|
}
|
|
}
|
|
p.mu.Unlock()
|
|
body, _ := json.Marshal(map[string]interface{}{
|
|
"type": chal.Type,
|
|
"url": chal.URL,
|
|
"token": chal.Token,
|
|
"status": "valid",
|
|
})
|
|
p.writeWithNonce(w, http.StatusOK, body, "")
|
|
}
|
|
|
|
func (p *pebbleMockServer) handleFinalize(w http.ResponseWriter, r *http.Request) {
|
|
header, payload, err := p.parseJWS(r)
|
|
if err != nil {
|
|
p.writeError(w, http.StatusBadRequest, "malformed", err.Error())
|
|
return
|
|
}
|
|
if !p.consumeNonce(header.Nonce) {
|
|
p.writeError(w, http.StatusBadRequest, "badNonce", "nonce unknown or replayed")
|
|
return
|
|
}
|
|
id := strings.TrimPrefix(r.URL.Path, "/finalize/")
|
|
p.mu.Lock()
|
|
order, ok := p.orders[id]
|
|
if !ok {
|
|
p.mu.Unlock()
|
|
p.writeError(w, http.StatusNotFound, "malformed", "order not found")
|
|
return
|
|
}
|
|
order.finalizeCount++
|
|
p.mu.Unlock()
|
|
|
|
// Failure-mode gate: if configured, return invalid order.
|
|
if p.finalizeReturns == "invalid" {
|
|
body, _ := json.Marshal(map[string]interface{}{
|
|
"status": "invalid",
|
|
"identifiers": order.Identifiers,
|
|
"finalize": order.FinalizeURL,
|
|
})
|
|
p.writeWithNonce(w, http.StatusOK, body, "")
|
|
return
|
|
}
|
|
|
|
// Parse {csr: <base64url DER>}
|
|
var finReq struct {
|
|
CSR string `json:"csr"`
|
|
}
|
|
if err := json.Unmarshal(payload, &finReq); err != nil {
|
|
p.writeError(w, http.StatusBadRequest, "malformed", "parse finalize: "+err.Error())
|
|
return
|
|
}
|
|
csrDER, err := base64.RawURLEncoding.DecodeString(finReq.CSR)
|
|
if err != nil {
|
|
p.writeError(w, http.StatusBadRequest, "badCSR", "decode csr: "+err.Error())
|
|
return
|
|
}
|
|
csr, err := x509.ParseCertificateRequest(csrDER)
|
|
if err != nil {
|
|
p.writeError(w, http.StatusBadRequest, "badCSR", "parse csr: "+err.Error())
|
|
return
|
|
}
|
|
|
|
// Sign the cert against the fixture CA.
|
|
leaf := &x509.Certificate{
|
|
SerialNumber: big.NewInt(time.Now().UnixNano()),
|
|
Subject: csr.Subject,
|
|
NotBefore: time.Now().Add(-1 * time.Hour),
|
|
NotAfter: time.Now().Add(90 * 24 * time.Hour),
|
|
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},
|
|
DNSNames: csr.DNSNames,
|
|
}
|
|
leafDER, err := x509.CreateCertificate(rand.Reader, leaf, p.caCert, csr.PublicKey, p.caKey)
|
|
if err != nil {
|
|
p.writeError(w, http.StatusInternalServerError, "serverInternal", "sign cert: "+err.Error())
|
|
return
|
|
}
|
|
leafPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: leafDER})
|
|
chainPEM := append(leafPEM, p.caPEM...)
|
|
|
|
certID := p.nextID("cert")
|
|
certURL := p.URL() + "/cert/" + certID
|
|
p.mu.Lock()
|
|
p.certs[certID] = chainPEM
|
|
order.Status = "valid"
|
|
order.CertURL = certURL
|
|
p.mu.Unlock()
|
|
|
|
if p.finalizeReturns == "processing-stuck" {
|
|
// Return processing on first finalize; subsequent order-poll will
|
|
// see "valid" because we already set it above. The test exercises
|
|
// the processing→valid transition path.
|
|
body, _ := json.Marshal(map[string]interface{}{
|
|
"status": "processing",
|
|
"identifiers": order.Identifiers,
|
|
"finalize": order.FinalizeURL,
|
|
})
|
|
p.writeWithNonce(w, http.StatusOK, body, "")
|
|
return
|
|
}
|
|
|
|
body, _ := json.Marshal(map[string]interface{}{
|
|
"status": "valid",
|
|
"identifiers": order.Identifiers,
|
|
"authorizations": order.AuthzURLs,
|
|
"finalize": order.FinalizeURL,
|
|
"certificate": certURL,
|
|
})
|
|
p.writeWithNonce(w, http.StatusOK, body, "")
|
|
}
|
|
|
|
func (p *pebbleMockServer) handleOrder(w http.ResponseWriter, r *http.Request) {
|
|
header, _, err := p.parseJWS(r)
|
|
if err != nil {
|
|
p.writeError(w, http.StatusBadRequest, "malformed", err.Error())
|
|
return
|
|
}
|
|
if !p.consumeNonce(header.Nonce) {
|
|
p.writeError(w, http.StatusBadRequest, "badNonce", "nonce unknown or replayed")
|
|
return
|
|
}
|
|
id := strings.TrimPrefix(r.URL.Path, "/order/")
|
|
p.mu.Lock()
|
|
order, ok := p.orders[id]
|
|
p.mu.Unlock()
|
|
if !ok {
|
|
p.writeError(w, http.StatusNotFound, "malformed", "order not found")
|
|
return
|
|
}
|
|
resp := map[string]interface{}{
|
|
"status": order.Status,
|
|
"identifiers": order.Identifiers,
|
|
"authorizations": order.AuthzURLs,
|
|
"finalize": order.FinalizeURL,
|
|
}
|
|
if order.CertURL != "" {
|
|
resp["certificate"] = order.CertURL
|
|
}
|
|
body, _ := json.Marshal(resp)
|
|
p.writeWithNonce(w, http.StatusOK, body, "")
|
|
}
|
|
|
|
// handleAccount serves POST-as-GET /account/<id> for the stdlib's GetReg call.
|
|
// Returns the account object so authorizeOrderWithProfile can extract the URI
|
|
// for use as kid in the profile-based newOrder JWS.
|
|
func (p *pebbleMockServer) handleAccount(w http.ResponseWriter, r *http.Request) {
|
|
header, _, err := p.parseJWS(r)
|
|
if err != nil {
|
|
p.writeError(w, http.StatusBadRequest, "malformed", err.Error())
|
|
return
|
|
}
|
|
if !p.consumeNonce(header.Nonce) {
|
|
p.writeError(w, http.StatusBadRequest, "badNonce", "nonce unknown or replayed")
|
|
return
|
|
}
|
|
// kid in the JWS protected header IS the account URL.
|
|
acctURL := header.Kid
|
|
if acctURL == "" {
|
|
// GetReg with empty URL — server resolves "self" via the kid header.
|
|
acctURL = p.URL() + r.URL.Path
|
|
}
|
|
p.mu.Lock()
|
|
acct, ok := p.accounts[acctURL]
|
|
p.mu.Unlock()
|
|
if !ok {
|
|
// If the kid isn't a known account, return notFound. The stdlib
|
|
// surfaces this as the documented error and the connector branches.
|
|
p.writeError(w, http.StatusUnauthorized, "accountDoesNotExist", "account not registered: "+acctURL)
|
|
return
|
|
}
|
|
body, _ := json.Marshal(map[string]interface{}{
|
|
"status": acct.Status,
|
|
"contact": []string{"mailto:test@example.com"},
|
|
})
|
|
// Set Location so GetReg can populate URI.
|
|
p.writeWithNonce(w, http.StatusOK, body, acctURL)
|
|
}
|
|
|
|
func (p *pebbleMockServer) handleCert(w http.ResponseWriter, r *http.Request) {
|
|
header, _, err := p.parseJWS(r)
|
|
if err != nil {
|
|
p.writeError(w, http.StatusBadRequest, "malformed", err.Error())
|
|
return
|
|
}
|
|
if !p.consumeNonce(header.Nonce) {
|
|
p.writeError(w, http.StatusBadRequest, "badNonce", "nonce unknown or replayed")
|
|
return
|
|
}
|
|
id := strings.TrimPrefix(r.URL.Path, "/cert/")
|
|
p.mu.Lock()
|
|
chain, ok := p.certs[id]
|
|
p.mu.Unlock()
|
|
if !ok {
|
|
p.writeError(w, http.StatusNotFound, "malformed", "cert not found")
|
|
return
|
|
}
|
|
w.Header().Set("Replay-Nonce", p.freshNonce())
|
|
w.Header().Set("Content-Type", "application/pem-certificate-chain")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write(chain)
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Helpers for tests: build a Connector pointing at the mock + a CSR for the cert
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
func newPebbleConnector(t *testing.T, mockURL string) *Connector {
|
|
t.Helper()
|
|
cfg := &Config{
|
|
DirectoryURL: mockURL + "/directory",
|
|
Email: "test@example.com",
|
|
ChallengeType: "http-01",
|
|
HTTPPort: 8765, // arbitrary high port; we don't actually use http-01 since authzs auto-flip
|
|
}
|
|
c := New(cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
|
return c
|
|
}
|
|
|
|
func newCSRPEM(t *testing.T, cn string, sans ...string) string {
|
|
t.Helper()
|
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
if err != nil {
|
|
t.Fatalf("CSR key: %v", err)
|
|
}
|
|
tmpl := &x509.CertificateRequest{
|
|
Subject: pkix.Name{CommonName: cn},
|
|
DNSNames: sans,
|
|
}
|
|
der, err := x509.CreateCertificateRequest(rand.Reader, tmpl, key)
|
|
if err != nil {
|
|
t.Fatalf("CSR build: %v", err)
|
|
}
|
|
return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: der}))
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Tests
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
func TestPebbleMock_IssueCertificate_HappyPath(t *testing.T) {
|
|
mock := startPebbleMock(t)
|
|
c := newPebbleConnector(t, mock.URL())
|
|
|
|
csr := newCSRPEM(t, "happy.example.com", "happy.example.com")
|
|
res, err := c.IssueCertificate(context.Background(), issuer.IssuanceRequest{
|
|
CommonName: "happy.example.com",
|
|
SANs: []string{"happy.example.com"},
|
|
CSRPEM: csr,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("IssueCertificate: %v", err)
|
|
}
|
|
if res == nil || res.CertPEM == "" {
|
|
t.Fatalf("expected non-empty CertPEM, got %+v", res)
|
|
}
|
|
// Sanity: cert PEM must parse + CN must match.
|
|
block, _ := pem.Decode([]byte(res.CertPEM))
|
|
if block == nil {
|
|
t.Fatalf("CertPEM didn't decode: %q", res.CertPEM)
|
|
}
|
|
cert, err := x509.ParseCertificate(block.Bytes)
|
|
if err != nil {
|
|
t.Fatalf("ParseCertificate: %v", err)
|
|
}
|
|
if cert.Subject.CommonName != "happy.example.com" {
|
|
t.Errorf("expected CN=happy.example.com, got %q", cert.Subject.CommonName)
|
|
}
|
|
if res.Serial == "" || res.NotAfter.IsZero() {
|
|
t.Errorf("expected populated Serial + NotAfter, got %+v", res)
|
|
}
|
|
}
|
|
|
|
func TestPebbleMock_IssueCertificate_MultiSAN(t *testing.T) {
|
|
mock := startPebbleMock(t)
|
|
c := newPebbleConnector(t, mock.URL())
|
|
|
|
sans := []string{"primary.example.com", "alt1.example.com", "alt2.example.com"}
|
|
csr := newCSRPEM(t, "primary.example.com", sans...)
|
|
res, err := c.IssueCertificate(context.Background(), issuer.IssuanceRequest{
|
|
CommonName: "primary.example.com",
|
|
SANs: sans,
|
|
CSRPEM: csr,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("IssueCertificate: %v", err)
|
|
}
|
|
block, _ := pem.Decode([]byte(res.CertPEM))
|
|
cert, _ := x509.ParseCertificate(block.Bytes)
|
|
if len(cert.DNSNames) != 3 {
|
|
t.Errorf("expected 3 DNS SANs, got %d (%v)", len(cert.DNSNames), cert.DNSNames)
|
|
}
|
|
}
|
|
|
|
func TestPebbleMock_IssueCertificate_WithProfile(t *testing.T) {
|
|
mock := startPebbleMock(t)
|
|
c := newPebbleConnector(t, mock.URL())
|
|
c.config.Profile = "tlsserver" // exercises authorizeOrderWithProfile branch
|
|
|
|
csr := newCSRPEM(t, "profiled.example.com", "profiled.example.com")
|
|
res, err := c.IssueCertificate(context.Background(), issuer.IssuanceRequest{
|
|
CommonName: "profiled.example.com",
|
|
SANs: []string{"profiled.example.com"},
|
|
CSRPEM: csr,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("IssueCertificate (profile): %v", err)
|
|
}
|
|
if res.CertPEM == "" {
|
|
t.Errorf("expected non-empty cert with profile branch")
|
|
}
|
|
// Confirm the mock saw the profile field.
|
|
mock.mu.Lock()
|
|
defer mock.mu.Unlock()
|
|
foundProfile := false
|
|
for _, o := range mock.orders {
|
|
if o.Profile == "tlsserver" {
|
|
foundProfile = true
|
|
break
|
|
}
|
|
}
|
|
if !foundProfile {
|
|
t.Errorf("expected mock to receive profile=tlsserver in newOrder")
|
|
}
|
|
}
|
|
|
|
func TestPebbleMock_RenewCertificate_DelegatesToIssue(t *testing.T) {
|
|
mock := startPebbleMock(t)
|
|
c := newPebbleConnector(t, mock.URL())
|
|
|
|
csr := newCSRPEM(t, "renew.example.com", "renew.example.com")
|
|
res, err := c.RenewCertificate(context.Background(), issuer.RenewalRequest{
|
|
CommonName: "renew.example.com",
|
|
SANs: []string{"renew.example.com"},
|
|
CSRPEM: csr,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("RenewCertificate: %v", err)
|
|
}
|
|
if res.CertPEM == "" {
|
|
t.Errorf("expected non-empty cert from renewal path")
|
|
}
|
|
}
|
|
|
|
func TestPebbleMock_GetOrderStatus_HappyPath(t *testing.T) {
|
|
mock := startPebbleMock(t)
|
|
c := newPebbleConnector(t, mock.URL())
|
|
|
|
csr := newCSRPEM(t, "status.example.com", "status.example.com")
|
|
res, err := c.IssueCertificate(context.Background(), issuer.IssuanceRequest{
|
|
CommonName: "status.example.com",
|
|
SANs: []string{"status.example.com"},
|
|
CSRPEM: csr,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("IssueCertificate: %v", err)
|
|
}
|
|
// Use the order URI from the issuance result.
|
|
st, err := c.GetOrderStatus(context.Background(), res.OrderID)
|
|
if err != nil {
|
|
t.Fatalf("GetOrderStatus: %v", err)
|
|
}
|
|
if st.Status != "valid" {
|
|
t.Errorf("expected status=valid after issuance, got %q", st.Status)
|
|
}
|
|
}
|
|
|
|
func TestPebbleMock_NewAccountFailure_ReturnsError(t *testing.T) {
|
|
mock := startPebbleMock(t)
|
|
mock.failNewAccount = true
|
|
c := newPebbleConnector(t, mock.URL())
|
|
|
|
csr := newCSRPEM(t, "fail-acct.example.com", "fail-acct.example.com")
|
|
_, err := c.IssueCertificate(context.Background(), issuer.IssuanceRequest{
|
|
CommonName: "fail-acct.example.com",
|
|
SANs: []string{"fail-acct.example.com"},
|
|
CSRPEM: csr,
|
|
})
|
|
if err == nil {
|
|
t.Fatalf("expected IssueCertificate to fail when newAccount returns 500")
|
|
}
|
|
if !strings.Contains(err.Error(), "ACME") {
|
|
t.Errorf("expected error to mention ACME, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPebbleMock_FinalizeProcessingStuck_RecoversToValid(t *testing.T) {
|
|
// Force finalize to return processing — the connector's WaitOrder fallback
|
|
// path then polls /order/<id>, which sees valid (we set it during finalize).
|
|
mock := startPebbleMock(t)
|
|
mock.finalizeReturns = "processing-stuck"
|
|
c := newPebbleConnector(t, mock.URL())
|
|
|
|
csr := newCSRPEM(t, "stuck.example.com", "stuck.example.com")
|
|
res, err := c.IssueCertificate(context.Background(), issuer.IssuanceRequest{
|
|
CommonName: "stuck.example.com",
|
|
SANs: []string{"stuck.example.com"},
|
|
CSRPEM: csr,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("IssueCertificate (processing-stuck): %v", err)
|
|
}
|
|
if res.CertPEM == "" {
|
|
t.Errorf("expected cert via order-poll fallback")
|
|
}
|
|
}
|
|
|
|
func TestPebbleMock_FinalizeReturnsInvalid_FailsClean(t *testing.T) {
|
|
mock := startPebbleMock(t)
|
|
mock.finalizeReturns = "invalid"
|
|
c := newPebbleConnector(t, mock.URL())
|
|
|
|
csr := newCSRPEM(t, "invalid-final.example.com", "invalid-final.example.com")
|
|
_, err := c.IssueCertificate(context.Background(), issuer.IssuanceRequest{
|
|
CommonName: "invalid-final.example.com",
|
|
SANs: []string{"invalid-final.example.com"},
|
|
CSRPEM: csr,
|
|
})
|
|
if err == nil {
|
|
t.Fatalf("expected error when finalize returns invalid order")
|
|
}
|
|
}
|
|
|
|
func TestPebbleMock_ContextCancel_DuringIssuance(t *testing.T) {
|
|
mock := startPebbleMock(t)
|
|
c := newPebbleConnector(t, mock.URL())
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel() // cancel immediately
|
|
|
|
csr := newCSRPEM(t, "cancel.example.com", "cancel.example.com")
|
|
_, err := c.IssueCertificate(ctx, issuer.IssuanceRequest{
|
|
CommonName: "cancel.example.com",
|
|
SANs: []string{"cancel.example.com"},
|
|
CSRPEM: csr,
|
|
})
|
|
if err == nil {
|
|
t.Errorf("expected context.Canceled to propagate")
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Mock DNSSolver: in-memory Present/CleanUp/PresentPersist for DNS-01 tests
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
type mockDNSSolver struct {
|
|
mu sync.Mutex
|
|
presented map[string]string // domain → keyAuth (or recordValue)
|
|
cleanedUp map[string]bool
|
|
presentErr error
|
|
cleanErr error
|
|
presentDelay time.Duration
|
|
}
|
|
|
|
func newMockDNSSolver() *mockDNSSolver {
|
|
return &mockDNSSolver{
|
|
presented: make(map[string]string),
|
|
cleanedUp: make(map[string]bool),
|
|
}
|
|
}
|
|
|
|
func (m *mockDNSSolver) Present(ctx context.Context, domain, token, keyAuth string) error {
|
|
if m.presentDelay > 0 {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-time.After(m.presentDelay):
|
|
}
|
|
}
|
|
if m.presentErr != nil {
|
|
return m.presentErr
|
|
}
|
|
m.mu.Lock()
|
|
m.presented[domain] = keyAuth
|
|
m.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
func (m *mockDNSSolver) CleanUp(ctx context.Context, domain, token, keyAuth string) error {
|
|
if m.cleanErr != nil {
|
|
return m.cleanErr
|
|
}
|
|
m.mu.Lock()
|
|
m.cleanedUp[domain] = true
|
|
m.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
// PresentPersist mirrors the script-solver method for dns-persist-01 tests.
|
|
// (Optional method — only DNS-PERSIST-01 path uses it.)
|
|
func (m *mockDNSSolver) PresentPersist(ctx context.Context, domain, token, recordValue string) error {
|
|
return m.Present(ctx, domain, token, recordValue)
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// HTTP-01 challenge solver path
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
func TestPebbleMock_IssueCertificate_HTTP01ChallengeFlow(t *testing.T) {
|
|
mock := startPebbleMock(t)
|
|
mock.authzPending = true // forces connector through solveAuthorizationsHTTP01
|
|
|
|
c := newPebbleConnector(t, mock.URL())
|
|
c.config.HTTPPort = 0 // bind to a free port — connector starts the challenge server
|
|
// (The mock auto-validates challenges; real CA never connects to the
|
|
// challenge server, so the listener address doesn't matter.)
|
|
|
|
csr := newCSRPEM(t, "http01.example.com", "http01.example.com")
|
|
res, err := c.IssueCertificate(context.Background(), issuer.IssuanceRequest{
|
|
CommonName: "http01.example.com",
|
|
SANs: []string{"http01.example.com"},
|
|
CSRPEM: csr,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("IssueCertificate (HTTP-01): %v", err)
|
|
}
|
|
if res.CertPEM == "" {
|
|
t.Fatal("expected non-empty cert via HTTP-01 path")
|
|
}
|
|
// Sanity: confirm the connector wrote a token to the in-memory store
|
|
// during solveAuthorizationsHTTP01 (and cleaned up after).
|
|
c.challengeMu.RLock()
|
|
tokens := len(c.challengeTokens)
|
|
c.challengeMu.RUnlock()
|
|
if tokens != 0 {
|
|
t.Errorf("expected challenge tokens cleaned up after solve, got %d", tokens)
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// DNS-01 challenge solver path
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
func TestPebbleMock_IssueCertificate_DNS01ChallengeFlow(t *testing.T) {
|
|
mock := startPebbleMock(t)
|
|
mock.authzPending = true
|
|
mock.challengeType = "dns-01"
|
|
|
|
c := newPebbleConnector(t, mock.URL())
|
|
c.config.ChallengeType = "dns-01"
|
|
c.config.DNSPropagationWait = 0 // no propagation wait in tests
|
|
solver := newMockDNSSolver()
|
|
c.dnsSolver = solver
|
|
|
|
csr := newCSRPEM(t, "dns01.example.com", "dns01.example.com")
|
|
res, err := c.IssueCertificate(context.Background(), issuer.IssuanceRequest{
|
|
CommonName: "dns01.example.com",
|
|
SANs: []string{"dns01.example.com"},
|
|
CSRPEM: csr,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("IssueCertificate (DNS-01): %v", err)
|
|
}
|
|
if res.CertPEM == "" {
|
|
t.Fatal("expected non-empty cert via DNS-01 path")
|
|
}
|
|
// Sanity: solver was called for Present and CleanUp.
|
|
solver.mu.Lock()
|
|
defer solver.mu.Unlock()
|
|
if _, ok := solver.presented["dns01.example.com"]; !ok {
|
|
t.Errorf("expected DNSSolver.Present to be called")
|
|
}
|
|
if !solver.cleanedUp["dns01.example.com"] {
|
|
t.Errorf("expected DNSSolver.CleanUp to be called")
|
|
}
|
|
}
|
|
|
|
func TestPebbleMock_DNS01_PresentFails_PropagatesError(t *testing.T) {
|
|
mock := startPebbleMock(t)
|
|
mock.authzPending = true
|
|
mock.challengeType = "dns-01"
|
|
|
|
c := newPebbleConnector(t, mock.URL())
|
|
c.config.ChallengeType = "dns-01"
|
|
c.config.DNSPropagationWait = 0
|
|
solver := newMockDNSSolver()
|
|
solver.presentErr = fmt.Errorf("DNS provider down (test fixture)")
|
|
c.dnsSolver = solver
|
|
|
|
csr := newCSRPEM(t, "dns01-fail.example.com", "dns01-fail.example.com")
|
|
_, err := c.IssueCertificate(context.Background(), issuer.IssuanceRequest{
|
|
CommonName: "dns01-fail.example.com",
|
|
SANs: []string{"dns01-fail.example.com"},
|
|
CSRPEM: csr,
|
|
})
|
|
if err == nil {
|
|
t.Fatalf("expected DNS Present failure to propagate")
|
|
}
|
|
if !strings.Contains(err.Error(), "DNS provider down") {
|
|
t.Errorf("expected error to mention DNS Present failure, got %v", err)
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// DNS-PERSIST-01 challenge solver path
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
func TestPebbleMock_IssueCertificate_DNSPersist01ChallengeFlow(t *testing.T) {
|
|
mock := startPebbleMock(t)
|
|
mock.authzPending = true
|
|
mock.challengeType = "dns-persist-01"
|
|
|
|
c := newPebbleConnector(t, mock.URL())
|
|
c.config.ChallengeType = "dns-persist-01"
|
|
c.config.DNSPropagationWait = 0
|
|
c.config.DNSPersistIssuerDomain = "letsencrypt.org"
|
|
solver := newMockDNSSolver()
|
|
c.dnsSolver = solver
|
|
|
|
csr := newCSRPEM(t, "persist.example.com", "persist.example.com")
|
|
res, err := c.IssueCertificate(context.Background(), issuer.IssuanceRequest{
|
|
CommonName: "persist.example.com",
|
|
SANs: []string{"persist.example.com"},
|
|
CSRPEM: csr,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("IssueCertificate (DNS-PERSIST-01): %v", err)
|
|
}
|
|
if res.CertPEM == "" {
|
|
t.Fatal("expected non-empty cert via DNS-PERSIST-01 path")
|
|
}
|
|
// The persistent record value should embed both the issuer domain and the account URI.
|
|
solver.mu.Lock()
|
|
defer solver.mu.Unlock()
|
|
val, ok := solver.presented["persist.example.com"]
|
|
if !ok {
|
|
t.Errorf("expected DNSSolver.Present called for persist.example.com")
|
|
}
|
|
if !strings.Contains(val, "letsencrypt.org") || !strings.Contains(val, "accounturi=") {
|
|
t.Errorf("expected persistent record value to embed issuer-domain + accounturi, got %q", val)
|
|
}
|
|
}
|
|
|
|
func TestPebbleMock_DNSPersist01_FallbackToDNS01_WhenChallengeNotOffered(t *testing.T) {
|
|
// CA only offers dns-01, not dns-persist-01. The connector logs a warning
|
|
// and recursively calls solveAuthorizationsDNS01 — covers the fallback arm.
|
|
mock := startPebbleMock(t)
|
|
mock.authzPending = true
|
|
mock.challengeType = "dns-01" // not dns-persist-01
|
|
|
|
c := newPebbleConnector(t, mock.URL())
|
|
c.config.ChallengeType = "dns-persist-01"
|
|
c.config.DNSPropagationWait = 0
|
|
c.config.DNSPersistIssuerDomain = "letsencrypt.org"
|
|
solver := newMockDNSSolver()
|
|
c.dnsSolver = solver
|
|
|
|
csr := newCSRPEM(t, "fallback.example.com", "fallback.example.com")
|
|
res, err := c.IssueCertificate(context.Background(), issuer.IssuanceRequest{
|
|
CommonName: "fallback.example.com",
|
|
SANs: []string{"fallback.example.com"},
|
|
CSRPEM: csr,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("IssueCertificate (dns-persist-01 → dns-01 fallback): %v", err)
|
|
}
|
|
if res.CertPEM == "" {
|
|
t.Fatal("expected non-empty cert via DNS-01 fallback path")
|
|
}
|
|
}
|
|
|
|
func TestPebbleMock_DNSPersist01_NoSolver_FailsClean(t *testing.T) {
|
|
mock := startPebbleMock(t)
|
|
mock.authzPending = true
|
|
mock.challengeType = "dns-persist-01"
|
|
|
|
c := newPebbleConnector(t, mock.URL())
|
|
c.config.ChallengeType = "dns-persist-01"
|
|
|
|
csr := newCSRPEM(t, "no-persist-solver.example.com", "no-persist-solver.example.com")
|
|
_, err := c.IssueCertificate(context.Background(), issuer.IssuanceRequest{
|
|
CommonName: "no-persist-solver.example.com",
|
|
SANs: []string{"no-persist-solver.example.com"},
|
|
CSRPEM: csr,
|
|
})
|
|
if err == nil {
|
|
t.Fatalf("expected error when dns-persist-01 configured without a solver")
|
|
}
|
|
if !strings.Contains(err.Error(), "dns-persist-01") || !strings.Contains(err.Error(), "no DNS solver") {
|
|
t.Errorf("expected 'no DNS solver' error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPebbleMock_DNS01_NoSolver_FailsClean(t *testing.T) {
|
|
mock := startPebbleMock(t)
|
|
mock.authzPending = true
|
|
mock.challengeType = "dns-01"
|
|
|
|
c := newPebbleConnector(t, mock.URL())
|
|
c.config.ChallengeType = "dns-01"
|
|
// Don't set c.dnsSolver — should fail with "no DNS solver available"
|
|
|
|
csr := newCSRPEM(t, "no-solver.example.com", "no-solver.example.com")
|
|
_, err := c.IssueCertificate(context.Background(), issuer.IssuanceRequest{
|
|
CommonName: "no-solver.example.com",
|
|
SANs: []string{"no-solver.example.com"},
|
|
CSRPEM: csr,
|
|
})
|
|
if err == nil {
|
|
t.Fatalf("expected error when DNS-01 configured without a solver")
|
|
}
|
|
if !strings.Contains(err.Error(), "DNS-01") || !strings.Contains(err.Error(), "no DNS solver") {
|
|
t.Errorf("expected 'no DNS solver' error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPebbleMock_BadCSR_RejectedByMock(t *testing.T) {
|
|
mock := startPebbleMock(t)
|
|
c := newPebbleConnector(t, mock.URL())
|
|
|
|
// CSR PEM with truncated body — base64 decode will fail at the connector
|
|
// before even hitting the mock.
|
|
_, err := c.IssueCertificate(context.Background(), issuer.IssuanceRequest{
|
|
CommonName: "bad-csr.example.com",
|
|
SANs: []string{"bad-csr.example.com"},
|
|
CSRPEM: "-----BEGIN CERTIFICATE REQUEST-----\nNOTBASE64==\n-----END CERTIFICATE REQUEST-----\n",
|
|
})
|
|
if err == nil {
|
|
t.Fatalf("expected malformed-CSR to fail issuance")
|
|
}
|
|
}
|