mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 15:01:32 +00:00
7cb453a336
Mechanical reformat. The new 'gofmt drift' CI step (added in
ci-pipeline-cleanup Phase 4, commit 0f205a8) surfaced 111 files
with accumulated gofmt drift across cmd/, internal/, and deploy/test/.
Each file's diff is gofmt-standard: whitespace adjustments, intra-
group import sorting (alphabetical by import path within blank-line-
separated groups), and struct-tag column alignment. No semantic
changes — verified via 'git diff --ignore-all-space' which shows only
the line-position deltas from import reordering.
The gate stays in place after this commit. Going forward it catches
gofmt drift at PR time.
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")
|
|
}
|
|
}
|