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) } }