Files
metacrypt/internal/acme/validate_test.go
Kyle Isom 7749c035ae Add comprehensive ACME test suite (60 tests, 2100 lines)
Test coverage for the entire ACME server implementation:

- helpers_test.go: memBarrier, key generation, JWS/EAB signing, test fixtures
- nonce_test.go: issue/consume lifecycle, reuse rejection, concurrency
- jws_test.go: JWS parsing/verification (ES256, ES384, RS256), JWK parsing,
  RFC 7638 thumbprints, EAB HMAC verification, key authorization
- eab_test.go: EAB credential CRUD, account/order listing
- validate_test.go: HTTP-01 challenge validation with httptest servers,
  authorization/order state machine transitions
- handlers_test.go: full ACME protocol flow via chi router — directory,
  nonce, account creation with EAB, order creation, authorization retrieval,
  challenge triggering, finalize (order-not-ready), cert retrieval/revocation,
  CSR identifier validation

One production change: extract dnsResolver variable in validate.go for
DNS-01 test injection (no behavior change).

All 60 tests pass with -race. Full project vet and test clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 21:01:23 -07:00

396 lines
11 KiB
Go

package acme
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// ---------- HTTP-01 validation tests ----------
func TestValidateHTTP01Success(t *testing.T) {
_, jwk := generateES256Key(t)
token := "test-token-http01"
keyAuth, err := KeyAuthorization(token, jwk)
if err != nil {
t.Fatalf("KeyAuthorization() error: %v", err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/.well-known/acme-challenge/"+token {
http.NotFound(w, r)
return
}
fmt.Fprint(w, keyAuth)
}))
defer srv.Close()
// Strip "http://" prefix to get host:port.
domain := strings.TrimPrefix(srv.URL, "http://")
ctx := context.WithValue(context.Background(), ctxKeyDomain, domain)
chall := &Challenge{
ID: "chall-http01-ok",
AuthzID: "authz-1",
Type: ChallengeHTTP01,
Status: StatusPending,
Token: token,
}
if err := validateHTTP01(ctx, chall, jwk); err != nil {
t.Fatalf("validateHTTP01() error: %v", err)
}
}
func TestValidateHTTP01WrongResponse(t *testing.T) {
token := "test-token-wrong"
_, jwk := generateES256Key(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
fmt.Fprint(w, "completely-wrong-response")
}))
defer srv.Close()
domain := strings.TrimPrefix(srv.URL, "http://")
ctx := context.WithValue(context.Background(), ctxKeyDomain, domain)
chall := &Challenge{
ID: "chall-http01-wrong",
AuthzID: "authz-1",
Type: ChallengeHTTP01,
Status: StatusPending,
Token: token,
}
if err := validateHTTP01(ctx, chall, jwk); err == nil {
t.Fatalf("expected error for wrong response, got nil")
}
}
func TestValidateHTTP01NotFound(t *testing.T) {
_, jwk := generateES256Key(t)
token := "test-token-404"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "not found", http.StatusNotFound)
}))
defer srv.Close()
domain := strings.TrimPrefix(srv.URL, "http://")
ctx := context.WithValue(context.Background(), ctxKeyDomain, domain)
chall := &Challenge{
ID: "chall-http01-404",
AuthzID: "authz-1",
Type: ChallengeHTTP01,
Status: StatusPending,
Token: token,
}
if err := validateHTTP01(ctx, chall, jwk); err == nil {
t.Fatalf("expected error for 404 response, got nil")
}
}
func TestValidateHTTP01WhitespaceTrimming(t *testing.T) {
_, jwk := generateES256Key(t)
token := "test-token-ws"
keyAuth, err := KeyAuthorization(token, jwk)
if err != nil {
t.Fatalf("KeyAuthorization() error: %v", err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/.well-known/acme-challenge/"+token {
http.NotFound(w, r)
return
}
// Return keyAuth with trailing whitespace (CRLF).
fmt.Fprint(w, keyAuth+"\r\n")
}))
defer srv.Close()
domain := strings.TrimPrefix(srv.URL, "http://")
ctx := context.WithValue(context.Background(), ctxKeyDomain, domain)
chall := &Challenge{
ID: "chall-http01-ws",
AuthzID: "authz-1",
Type: ChallengeHTTP01,
Status: StatusPending,
Token: token,
}
if err := validateHTTP01(ctx, chall, jwk); err != nil {
t.Fatalf("validateHTTP01() should trim whitespace, got error: %v", err)
}
}
// ---------- DNS-01 validation tests ----------
// TODO: Add DNS-01 unit tests. Testing validateDNS01 requires either a mock
// DNS server or replacing dnsResolver with a custom resolver whose Dial
// function points to a local UDP server. This is left for integration tests.
// ---------- State machine transition tests ----------
func TestUpdateAuthzStatusValid(t *testing.T) {
h := testHandler(t)
ctx := context.Background()
// Create two challenges: one valid, one pending.
chall1 := &Challenge{
ID: "chall-valid-1",
AuthzID: "authz-sm-1",
Type: ChallengeHTTP01,
Status: StatusValid,
Token: "tok1",
}
chall2 := &Challenge{
ID: "chall-pending-1",
AuthzID: "authz-sm-1",
Type: ChallengeDNS01,
Status: StatusPending,
Token: "tok2",
}
storeChallenge(t, h, ctx, chall1)
storeChallenge(t, h, ctx, chall2)
// Create authorization referencing both challenges.
authz := &Authorization{
ID: "authz-sm-1",
AccountID: "test-account",
Status: StatusPending,
Identifier: Identifier{Type: IdentifierDNS, Value: "example.com"},
ChallengeIDs: []string{"chall-valid-1", "chall-pending-1"},
ExpiresAt: time.Now().Add(24 * time.Hour),
}
storeAuthz(t, h, ctx, authz)
h.updateAuthzStatus(ctx, "authz-sm-1")
updated, err := h.loadAuthz(ctx, "authz-sm-1")
if err != nil {
t.Fatalf("loadAuthz() error: %v", err)
}
if updated.Status != StatusValid {
t.Fatalf("expected authz status %s, got %s", StatusValid, updated.Status)
}
}
func TestUpdateAuthzStatusAllInvalid(t *testing.T) {
h := testHandler(t)
ctx := context.Background()
chall1 := &Challenge{
ID: "chall-inv-1",
AuthzID: "authz-sm-2",
Type: ChallengeHTTP01,
Status: StatusInvalid,
Token: "tok1",
}
chall2 := &Challenge{
ID: "chall-inv-2",
AuthzID: "authz-sm-2",
Type: ChallengeDNS01,
Status: StatusInvalid,
Token: "tok2",
}
storeChallenge(t, h, ctx, chall1)
storeChallenge(t, h, ctx, chall2)
authz := &Authorization{
ID: "authz-sm-2",
AccountID: "test-account",
Status: StatusPending,
Identifier: Identifier{Type: IdentifierDNS, Value: "example.com"},
ChallengeIDs: []string{"chall-inv-1", "chall-inv-2"},
ExpiresAt: time.Now().Add(24 * time.Hour),
}
storeAuthz(t, h, ctx, authz)
h.updateAuthzStatus(ctx, "authz-sm-2")
updated, err := h.loadAuthz(ctx, "authz-sm-2")
if err != nil {
t.Fatalf("loadAuthz() error: %v", err)
}
if updated.Status != StatusInvalid {
t.Fatalf("expected authz status %s, got %s", StatusInvalid, updated.Status)
}
}
func TestUpdateAuthzStatusStillPending(t *testing.T) {
h := testHandler(t)
ctx := context.Background()
chall1 := &Challenge{
ID: "chall-pend-1",
AuthzID: "authz-sm-3",
Type: ChallengeHTTP01,
Status: StatusPending,
Token: "tok1",
}
chall2 := &Challenge{
ID: "chall-pend-2",
AuthzID: "authz-sm-3",
Type: ChallengeDNS01,
Status: StatusPending,
Token: "tok2",
}
storeChallenge(t, h, ctx, chall1)
storeChallenge(t, h, ctx, chall2)
authz := &Authorization{
ID: "authz-sm-3",
AccountID: "test-account",
Status: StatusPending,
Identifier: Identifier{Type: IdentifierDNS, Value: "example.com"},
ChallengeIDs: []string{"chall-pend-1", "chall-pend-2"},
ExpiresAt: time.Now().Add(24 * time.Hour),
}
storeAuthz(t, h, ctx, authz)
h.updateAuthzStatus(ctx, "authz-sm-3")
updated, err := h.loadAuthz(ctx, "authz-sm-3")
if err != nil {
t.Fatalf("loadAuthz() error: %v", err)
}
if updated.Status != StatusPending {
t.Fatalf("expected authz status %s, got %s", StatusPending, updated.Status)
}
}
func TestMaybeAdvanceOrderReady(t *testing.T) {
h := testHandler(t)
ctx := context.Background()
// Create two valid authorizations.
for _, id := range []string{"authz-ord-1", "authz-ord-2"} {
authz := &Authorization{
ID: id,
AccountID: "test-account",
Status: StatusValid,
Identifier: Identifier{Type: IdentifierDNS, Value: id + ".example.com"},
ChallengeIDs: []string{},
ExpiresAt: time.Now().Add(24 * time.Hour),
}
storeAuthz(t, h, ctx, authz)
}
// Create an order referencing both authorizations.
order := &Order{
ID: "order-advance-1",
AccountID: "test-account",
Status: StatusPending,
Identifiers: []Identifier{{Type: IdentifierDNS, Value: "example.com"}},
AuthzIDs: []string{"authz-ord-1", "authz-ord-2"},
ExpiresAt: time.Now().Add(24 * time.Hour),
CreatedAt: time.Now(),
IssuerName: "test-issuer",
}
storeOrder(t, h, ctx, order)
h.maybeAdvanceOrder(ctx, order)
// Reload the order from the barrier to verify it was persisted.
updated, err := h.loadOrder(ctx, "order-advance-1")
if err != nil {
t.Fatalf("loadOrder() error: %v", err)
}
if updated.Status != StatusReady {
t.Fatalf("expected order status %s, got %s", StatusReady, updated.Status)
}
}
func TestMaybeAdvanceOrderNotReady(t *testing.T) {
h := testHandler(t)
ctx := context.Background()
// One valid, one pending authorization.
authzValid := &Authorization{
ID: "authz-nr-1",
AccountID: "test-account",
Status: StatusValid,
Identifier: Identifier{Type: IdentifierDNS, Value: "a.example.com"},
ChallengeIDs: []string{},
ExpiresAt: time.Now().Add(24 * time.Hour),
}
authzPending := &Authorization{
ID: "authz-nr-2",
AccountID: "test-account",
Status: StatusPending,
Identifier: Identifier{Type: IdentifierDNS, Value: "b.example.com"},
ChallengeIDs: []string{},
ExpiresAt: time.Now().Add(24 * time.Hour),
}
storeAuthz(t, h, ctx, authzValid)
storeAuthz(t, h, ctx, authzPending)
order := &Order{
ID: "order-nr-1",
AccountID: "test-account",
Status: StatusPending,
Identifiers: []Identifier{{Type: IdentifierDNS, Value: "example.com"}},
AuthzIDs: []string{"authz-nr-1", "authz-nr-2"},
ExpiresAt: time.Now().Add(24 * time.Hour),
CreatedAt: time.Now(),
IssuerName: "test-issuer",
}
storeOrder(t, h, ctx, order)
h.maybeAdvanceOrder(ctx, order)
updated, err := h.loadOrder(ctx, "order-nr-1")
if err != nil {
t.Fatalf("loadOrder() error: %v", err)
}
if updated.Status != StatusPending {
t.Fatalf("expected order status %s, got %s", StatusPending, updated.Status)
}
}
// ---------- Test helpers ----------
func storeChallenge(t *testing.T, h *Handler, ctx context.Context, chall *Challenge) {
t.Helper()
data, err := json.Marshal(chall)
if err != nil {
t.Fatalf("marshal challenge %s: %v", chall.ID, err)
}
path := h.barrierPrefix() + "challenges/" + chall.ID + ".json"
if err := h.barrier.Put(ctx, path, data); err != nil {
t.Fatalf("store challenge %s: %v", chall.ID, err)
}
}
func storeAuthz(t *testing.T, h *Handler, ctx context.Context, authz *Authorization) {
t.Helper()
data, err := json.Marshal(authz)
if err != nil {
t.Fatalf("marshal authz %s: %v", authz.ID, err)
}
path := h.barrierPrefix() + "authz/" + authz.ID + ".json"
if err := h.barrier.Put(ctx, path, data); err != nil {
t.Fatalf("store authz %s: %v", authz.ID, err)
}
}
func storeOrder(t *testing.T, h *Handler, ctx context.Context, order *Order) {
t.Helper()
data, err := json.Marshal(order)
if err != nil {
t.Fatalf("marshal order %s: %v", order.ID, err)
}
path := h.barrierPrefix() + "orders/" + order.ID + ".json"
if err := h.barrier.Put(ctx, path, data); err != nil {
t.Fatalf("store order %s: %v", order.ID, err)
}
}