mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-12 15:28:52 +00:00
5dc698307b
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 bc6039a (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.
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/certctl-io/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")
|
|
}
|
|
}
|