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>
This commit is contained in:
787
internal/acme/handlers_test.go
Normal file
787
internal/acme/handlers_test.go
Normal file
@@ -0,0 +1,787 @@
|
||||
package acme
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// setupACMERouter creates a Handler with an in-memory barrier and registers all
|
||||
// ACME routes on a chi router. All handler tests route through chi so that
|
||||
// chi.URLParam works correctly.
|
||||
func setupACMERouter(t *testing.T) (*Handler, chi.Router) {
|
||||
t.Helper()
|
||||
h := testHandler(t)
|
||||
r := chi.NewRouter()
|
||||
h.RegisterRoutes(r)
|
||||
return h, r
|
||||
}
|
||||
|
||||
// doACME sends an HTTP request through the chi router and returns the recorder.
|
||||
func doACME(t *testing.T, r chi.Router, method, path string, body []byte) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var bodyReader io.Reader
|
||||
if body != nil {
|
||||
bodyReader = bytes.NewReader(body)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, bodyReader)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
// --- Directory ---
|
||||
|
||||
func TestHandleDirectory(t *testing.T) {
|
||||
_, r := setupACMERouter(t)
|
||||
w := doACME(t, r, http.MethodGet, "/directory", nil)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
if ct := w.Header().Get("Content-Type"); ct != "application/json" {
|
||||
t.Fatalf("expected Content-Type application/json, got %s", ct)
|
||||
}
|
||||
|
||||
var dir directoryResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &dir); err != nil {
|
||||
t.Fatalf("unmarshal directory: %v", err)
|
||||
}
|
||||
base := "https://acme.test/acme/test-pki"
|
||||
if dir.NewNonce != base+"/new-nonce" {
|
||||
t.Fatalf("newNonce = %s, want %s/new-nonce", dir.NewNonce, base)
|
||||
}
|
||||
if dir.NewAccount != base+"/new-account" {
|
||||
t.Fatalf("newAccount = %s, want %s/new-account", dir.NewAccount, base)
|
||||
}
|
||||
if dir.NewOrder != base+"/new-order" {
|
||||
t.Fatalf("newOrder = %s, want %s/new-order", dir.NewOrder, base)
|
||||
}
|
||||
if dir.RevokeCert != base+"/revoke-cert" {
|
||||
t.Fatalf("revokeCert = %s, want %s/revoke-cert", dir.RevokeCert, base)
|
||||
}
|
||||
if dir.Meta == nil {
|
||||
t.Fatalf("meta is nil")
|
||||
}
|
||||
if !dir.Meta.ExternalAccountRequired {
|
||||
t.Fatalf("externalAccountRequired should be true")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Nonce endpoints ---
|
||||
|
||||
func TestHandleNewNonceHEAD(t *testing.T) {
|
||||
_, r := setupACMERouter(t)
|
||||
w := doACME(t, r, http.MethodHead, "/new-nonce", nil)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
if nonce := w.Header().Get("Replay-Nonce"); nonce == "" {
|
||||
t.Fatalf("Replay-Nonce header missing")
|
||||
}
|
||||
if cc := w.Header().Get("Cache-Control"); cc != "no-store" {
|
||||
t.Fatalf("Cache-Control = %q, want no-store", cc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleNewNonceGET(t *testing.T) {
|
||||
_, r := setupACMERouter(t)
|
||||
w := doACME(t, r, http.MethodGet, "/new-nonce", nil)
|
||||
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Fatalf("expected 204, got %d", w.Code)
|
||||
}
|
||||
if nonce := w.Header().Get("Replay-Nonce"); nonce == "" {
|
||||
t.Fatalf("Replay-Nonce header missing")
|
||||
}
|
||||
}
|
||||
|
||||
// --- New Account ---
|
||||
|
||||
func TestHandleNewAccountSuccess(t *testing.T) {
|
||||
h, r := setupACMERouter(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Generate a key pair for the new account.
|
||||
key, jwk := generateES256Key(t)
|
||||
|
||||
// Create an EAB credential.
|
||||
eab, err := h.CreateEAB(ctx, "testuser")
|
||||
if err != nil {
|
||||
t.Fatalf("create EAB: %v", err)
|
||||
}
|
||||
|
||||
// Build EAB inner JWS.
|
||||
eabJWS := signEAB(t, eab.KID, eab.HMACKey, jwk)
|
||||
|
||||
// Build outer payload with EAB.
|
||||
payload, err := json.Marshal(newAccountPayload{
|
||||
TermsOfServiceAgreed: true,
|
||||
Contact: []string{"mailto:test@example.com"},
|
||||
ExternalAccountBinding: eabJWS,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal payload: %v", err)
|
||||
}
|
||||
|
||||
nonce := getNonce(t, h)
|
||||
header := JWSHeader{
|
||||
Alg: "ES256",
|
||||
Nonce: nonce,
|
||||
URL: "https://acme.test/acme/test-pki/new-account",
|
||||
JWK: jwk,
|
||||
}
|
||||
body := signJWS(t, key, "ES256", header, payload)
|
||||
|
||||
w := doACME(t, r, http.MethodPost, "/new-account", body)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201, got %d; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
loc := w.Header().Get("Location")
|
||||
if loc == "" {
|
||||
t.Fatalf("Location header missing")
|
||||
}
|
||||
if !strings.HasPrefix(loc, "https://acme.test/acme/test-pki/account/") {
|
||||
t.Fatalf("Location = %s, want prefix https://acme.test/acme/test-pki/account/", loc)
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
if resp["status"] != StatusValid {
|
||||
t.Fatalf("status = %v, want %s", resp["status"], StatusValid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleNewAccountMissingEAB(t *testing.T) {
|
||||
h, r := setupACMERouter(t)
|
||||
|
||||
key, jwk := generateES256Key(t)
|
||||
|
||||
// Payload with no externalAccountBinding.
|
||||
payload, err := json.Marshal(newAccountPayload{
|
||||
TermsOfServiceAgreed: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal payload: %v", err)
|
||||
}
|
||||
|
||||
nonce := getNonce(t, h)
|
||||
header := JWSHeader{
|
||||
Alg: "ES256",
|
||||
Nonce: nonce,
|
||||
URL: "https://acme.test/acme/test-pki/new-account",
|
||||
JWK: jwk,
|
||||
}
|
||||
body := signJWS(t, key, "ES256", header, payload)
|
||||
|
||||
w := doACME(t, r, http.MethodPost, "/new-account", body)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "externalAccountRequired") {
|
||||
t.Fatalf("response should mention externalAccountRequired, got: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleNewAccountBadNonce(t *testing.T) {
|
||||
_, r := setupACMERouter(t)
|
||||
|
||||
key, jwk := generateES256Key(t)
|
||||
|
||||
payload, err := json.Marshal(newAccountPayload{
|
||||
TermsOfServiceAgreed: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal payload: %v", err)
|
||||
}
|
||||
|
||||
// Use a random nonce that was never issued.
|
||||
header := JWSHeader{
|
||||
Alg: "ES256",
|
||||
Nonce: "never-issued-fake-nonce",
|
||||
URL: "https://acme.test/acme/test-pki/new-account",
|
||||
JWK: jwk,
|
||||
}
|
||||
body := signJWS(t, key, "ES256", header, payload)
|
||||
|
||||
w := doACME(t, r, http.MethodPost, "/new-account", body)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "badNonce") {
|
||||
t.Fatalf("response should contain badNonce, got: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// --- New Order ---
|
||||
|
||||
// buildKIDJWS creates a JWS signed with KID authentication (for all requests
|
||||
// except new-account). The KID is the full account URL.
|
||||
func buildKIDJWS(t *testing.T, h *Handler, key *ecdsa.PrivateKey, accID, url string, payload []byte) []byte {
|
||||
t.Helper()
|
||||
nonce := getNonce(t, h)
|
||||
header := JWSHeader{
|
||||
Alg: "ES256",
|
||||
Nonce: nonce,
|
||||
URL: url,
|
||||
KID: h.accountURL(accID),
|
||||
}
|
||||
return signJWS(t, key, "ES256", header, payload)
|
||||
}
|
||||
|
||||
func TestHandleNewOrderSuccess(t *testing.T) {
|
||||
h, r := setupACMERouter(t)
|
||||
acc, key, _ := createTestAccount(t, h)
|
||||
|
||||
payload, err := json.Marshal(newOrderPayload{
|
||||
Identifiers: []Identifier{{Type: IdentifierDNS, Value: "example.com"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal payload: %v", err)
|
||||
}
|
||||
|
||||
url := "https://acme.test/acme/test-pki/new-order"
|
||||
body := buildKIDJWS(t, h, key, acc.ID, url, payload)
|
||||
w := doACME(t, r, http.MethodPost, "/new-order", body)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201, got %d; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
if resp["status"] == nil {
|
||||
t.Fatalf("response missing 'status'")
|
||||
}
|
||||
if resp["identifiers"] == nil {
|
||||
t.Fatalf("response missing 'identifiers'")
|
||||
}
|
||||
authzs, ok := resp["authorizations"].([]interface{})
|
||||
if !ok || len(authzs) == 0 {
|
||||
t.Fatalf("expected at least 1 authorization URL, got %v", resp["authorizations"])
|
||||
}
|
||||
if resp["finalize"] == nil {
|
||||
t.Fatalf("response missing 'finalize'")
|
||||
}
|
||||
finalize, ok := resp["finalize"].(string)
|
||||
if !ok || !strings.HasPrefix(finalize, "https://acme.test/acme/test-pki/finalize/") {
|
||||
t.Fatalf("finalize = %v, want prefix https://acme.test/acme/test-pki/finalize/", finalize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleNewOrderMultipleIdentifiers(t *testing.T) {
|
||||
h, r := setupACMERouter(t)
|
||||
acc, key, _ := createTestAccount(t, h)
|
||||
|
||||
payload, err := json.Marshal(newOrderPayload{
|
||||
Identifiers: []Identifier{
|
||||
{Type: IdentifierDNS, Value: "example.com"},
|
||||
{Type: IdentifierDNS, Value: "www.example.com"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal payload: %v", err)
|
||||
}
|
||||
|
||||
url := "https://acme.test/acme/test-pki/new-order"
|
||||
body := buildKIDJWS(t, h, key, acc.ID, url, payload)
|
||||
w := doACME(t, r, http.MethodPost, "/new-order", body)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201, got %d; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
authzs, ok := resp["authorizations"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("authorizations is not an array: %v", resp["authorizations"])
|
||||
}
|
||||
if len(authzs) != 2 {
|
||||
t.Fatalf("expected 2 authorization URLs, got %d", len(authzs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleNewOrderEmptyIdentifiers(t *testing.T) {
|
||||
h, r := setupACMERouter(t)
|
||||
acc, key, _ := createTestAccount(t, h)
|
||||
|
||||
payload, err := json.Marshal(newOrderPayload{
|
||||
Identifiers: []Identifier{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal payload: %v", err)
|
||||
}
|
||||
|
||||
url := "https://acme.test/acme/test-pki/new-order"
|
||||
body := buildKIDJWS(t, h, key, acc.ID, url, payload)
|
||||
w := doACME(t, r, http.MethodPost, "/new-order", body)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleNewOrderUnsupportedType(t *testing.T) {
|
||||
h, r := setupACMERouter(t)
|
||||
acc, key, _ := createTestAccount(t, h)
|
||||
|
||||
payload, err := json.Marshal(newOrderPayload{
|
||||
Identifiers: []Identifier{{Type: "email", Value: "user@example.com"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal payload: %v", err)
|
||||
}
|
||||
|
||||
url := "https://acme.test/acme/test-pki/new-order"
|
||||
body := buildKIDJWS(t, h, key, acc.ID, url, payload)
|
||||
w := doACME(t, r, http.MethodPost, "/new-order", body)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "unsupportedIdentifier") {
|
||||
t.Fatalf("response should contain unsupportedIdentifier, got: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// --- Get Authorization ---
|
||||
|
||||
// createTestOrder creates an account and an order in the barrier, returning all
|
||||
// the objects needed for subsequent tests.
|
||||
func createTestOrder(t *testing.T, h *Handler, domains ...string) (*Account, *ecdsa.PrivateKey, *Order) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
|
||||
acc, key, _ := createTestAccount(t, h)
|
||||
|
||||
if len(domains) == 0 {
|
||||
domains = []string{"example.com"}
|
||||
}
|
||||
|
||||
var identifiers []Identifier
|
||||
var authzIDs []string
|
||||
for _, domain := range domains {
|
||||
authzID := newID()
|
||||
authzIDs = append(authzIDs, authzID)
|
||||
|
||||
httpChallID := newID()
|
||||
dnsChallID := newID()
|
||||
|
||||
httpChall := &Challenge{
|
||||
ID: httpChallID,
|
||||
AuthzID: authzID,
|
||||
Type: ChallengeHTTP01,
|
||||
Status: StatusPending,
|
||||
Token: newToken(),
|
||||
}
|
||||
dnsChall := &Challenge{
|
||||
ID: dnsChallID,
|
||||
AuthzID: authzID,
|
||||
Type: ChallengeDNS01,
|
||||
Status: StatusPending,
|
||||
Token: newToken(),
|
||||
}
|
||||
identifier := Identifier{Type: IdentifierDNS, Value: domain}
|
||||
identifiers = append(identifiers, identifier)
|
||||
authz := &Authorization{
|
||||
ID: authzID,
|
||||
AccountID: acc.ID,
|
||||
Status: StatusPending,
|
||||
Identifier: identifier,
|
||||
ChallengeIDs: []string{httpChallID, dnsChallID},
|
||||
ExpiresAt: time.Now().Add(7 * 24 * time.Hour),
|
||||
}
|
||||
|
||||
challPrefix := h.barrierPrefix() + "challenges/"
|
||||
authzPrefix := h.barrierPrefix() + "authz/"
|
||||
|
||||
httpData, _ := json.Marshal(httpChall)
|
||||
dnsData, _ := json.Marshal(dnsChall)
|
||||
authzData, _ := json.Marshal(authz)
|
||||
|
||||
if err := h.barrier.Put(ctx, challPrefix+httpChallID+".json", httpData); err != nil {
|
||||
t.Fatalf("store http challenge: %v", err)
|
||||
}
|
||||
if err := h.barrier.Put(ctx, challPrefix+dnsChallID+".json", dnsData); err != nil {
|
||||
t.Fatalf("store dns challenge: %v", err)
|
||||
}
|
||||
if err := h.barrier.Put(ctx, authzPrefix+authzID+".json", authzData); err != nil {
|
||||
t.Fatalf("store authz: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
orderID := newID()
|
||||
order := &Order{
|
||||
ID: orderID,
|
||||
AccountID: acc.ID,
|
||||
Status: StatusPending,
|
||||
Identifiers: identifiers,
|
||||
AuthzIDs: authzIDs,
|
||||
ExpiresAt: time.Now().Add(7 * 24 * time.Hour),
|
||||
CreatedAt: time.Now(),
|
||||
IssuerName: "test-issuer",
|
||||
}
|
||||
orderData, _ := json.Marshal(order)
|
||||
if err := h.barrier.Put(ctx, h.barrierPrefix()+"orders/"+orderID+".json", orderData); err != nil {
|
||||
t.Fatalf("store order: %v", err)
|
||||
}
|
||||
|
||||
return acc, key, order
|
||||
}
|
||||
|
||||
func TestHandleGetAuthzSuccess(t *testing.T) {
|
||||
h, r := setupACMERouter(t)
|
||||
acc, key, order := createTestOrder(t, h)
|
||||
|
||||
authzID := order.AuthzIDs[0]
|
||||
reqURL := "https://acme.test/acme/test-pki/authz/" + authzID
|
||||
// POST-as-GET: empty payload.
|
||||
body := buildKIDJWS(t, h, key, acc.ID, reqURL, nil)
|
||||
w := doACME(t, r, http.MethodPost, "/authz/"+authzID, body)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
if resp["status"] == nil {
|
||||
t.Fatalf("response missing 'status'")
|
||||
}
|
||||
if resp["identifier"] == nil {
|
||||
t.Fatalf("response missing 'identifier'")
|
||||
}
|
||||
challenges, ok := resp["challenges"].([]interface{})
|
||||
if !ok || len(challenges) == 0 {
|
||||
t.Fatalf("expected non-empty challenges array, got %v", resp["challenges"])
|
||||
}
|
||||
}
|
||||
|
||||
// --- Challenge ---
|
||||
|
||||
func TestHandleChallengeTriggersProcessing(t *testing.T) {
|
||||
h, r := setupACMERouter(t)
|
||||
acc, key, order := createTestOrder(t, h)
|
||||
ctx := context.Background()
|
||||
|
||||
// Load the first authz to get a challenge ID.
|
||||
authzID := order.AuthzIDs[0]
|
||||
authz, err := h.loadAuthz(ctx, authzID)
|
||||
if err != nil {
|
||||
t.Fatalf("load authz: %v", err)
|
||||
}
|
||||
|
||||
// Find the http-01 challenge.
|
||||
var httpChallID string
|
||||
for _, challID := range authz.ChallengeIDs {
|
||||
chall, err := h.loadChallenge(ctx, challID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if chall.Type == ChallengeHTTP01 {
|
||||
httpChallID = chall.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if httpChallID == "" {
|
||||
t.Fatalf("no http-01 challenge found")
|
||||
}
|
||||
|
||||
challPath := "/challenge/" + ChallengeHTTP01 + "/" + httpChallID
|
||||
reqURL := "https://acme.test/acme/test-pki" + challPath
|
||||
body := buildKIDJWS(t, h, key, acc.ID, reqURL, []byte("{}"))
|
||||
w := doACME(t, r, http.MethodPost, challPath, body)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
if resp["status"] != StatusProcessing {
|
||||
t.Fatalf("status = %v, want %s", resp["status"], StatusProcessing)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Finalize ---
|
||||
|
||||
func TestHandleFinalizeOrderNotReady(t *testing.T) {
|
||||
h, r := setupACMERouter(t)
|
||||
acc, key, order := createTestOrder(t, h)
|
||||
|
||||
// Order is in "pending" status, not "ready".
|
||||
csrKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
template := &x509.CertificateRequest{
|
||||
DNSNames: []string{"example.com"},
|
||||
}
|
||||
csrDER, err := x509.CreateCertificateRequest(rand.Reader, template, csrKey)
|
||||
if err != nil {
|
||||
t.Fatalf("create CSR: %v", err)
|
||||
}
|
||||
csrB64 := base64.RawURLEncoding.EncodeToString(csrDER)
|
||||
payload, err := json.Marshal(map[string]string{"csr": csrB64})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal finalize payload: %v", err)
|
||||
}
|
||||
|
||||
finalizePath := "/finalize/" + order.ID
|
||||
reqURL := "https://acme.test/acme/test-pki" + finalizePath
|
||||
body := buildKIDJWS(t, h, key, acc.ID, reqURL, payload)
|
||||
w := doACME(t, r, http.MethodPost, finalizePath, body)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "orderNotReady") {
|
||||
t.Fatalf("response should contain orderNotReady, got: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// --- Get Certificate ---
|
||||
|
||||
func TestHandleGetCertSuccess(t *testing.T) {
|
||||
h, r := setupACMERouter(t)
|
||||
acc, key, _ := createTestAccount(t, h)
|
||||
ctx := context.Background()
|
||||
|
||||
certPEM := "-----BEGIN CERTIFICATE-----\nMIIBfake\n-----END CERTIFICATE-----\n"
|
||||
cert := &IssuedCert{
|
||||
ID: "test-cert-id",
|
||||
OrderID: "test-order-id",
|
||||
AccountID: acc.ID,
|
||||
CertPEM: certPEM,
|
||||
IssuedAt: time.Now(),
|
||||
ExpiresAt: time.Now().Add(90 * 24 * time.Hour),
|
||||
Revoked: false,
|
||||
}
|
||||
certData, _ := json.Marshal(cert)
|
||||
if err := h.barrier.Put(ctx, h.barrierPrefix()+"certs/test-cert-id.json", certData); err != nil {
|
||||
t.Fatalf("store cert: %v", err)
|
||||
}
|
||||
|
||||
certPath := "/cert/test-cert-id"
|
||||
reqURL := "https://acme.test/acme/test-pki" + certPath
|
||||
body := buildKIDJWS(t, h, key, acc.ID, reqURL, nil)
|
||||
w := doACME(t, r, http.MethodPost, certPath, body)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if ct := w.Header().Get("Content-Type"); ct != "application/pem-certificate-chain" {
|
||||
t.Fatalf("Content-Type = %s, want application/pem-certificate-chain", ct)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "BEGIN CERTIFICATE") {
|
||||
t.Fatalf("response body should contain PEM certificate, got: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetCertNotFound(t *testing.T) {
|
||||
h, r := setupACMERouter(t)
|
||||
acc, key, _ := createTestAccount(t, h)
|
||||
|
||||
certPath := "/cert/nonexistent-cert-id"
|
||||
reqURL := "https://acme.test/acme/test-pki" + certPath
|
||||
body := buildKIDJWS(t, h, key, acc.ID, reqURL, nil)
|
||||
w := doACME(t, r, http.MethodPost, certPath, body)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetCertRevoked(t *testing.T) {
|
||||
h, r := setupACMERouter(t)
|
||||
acc, key, _ := createTestAccount(t, h)
|
||||
ctx := context.Background()
|
||||
|
||||
cert := &IssuedCert{
|
||||
ID: "revoked-cert-id",
|
||||
OrderID: "test-order-id",
|
||||
AccountID: acc.ID,
|
||||
CertPEM: "-----BEGIN CERTIFICATE-----\nMIIBfake\n-----END CERTIFICATE-----\n",
|
||||
IssuedAt: time.Now(),
|
||||
ExpiresAt: time.Now().Add(90 * 24 * time.Hour),
|
||||
Revoked: true,
|
||||
}
|
||||
certData, _ := json.Marshal(cert)
|
||||
if err := h.barrier.Put(ctx, h.barrierPrefix()+"certs/revoked-cert-id.json", certData); err != nil {
|
||||
t.Fatalf("store cert: %v", err)
|
||||
}
|
||||
|
||||
certPath := "/cert/revoked-cert-id"
|
||||
reqURL := "https://acme.test/acme/test-pki" + certPath
|
||||
body := buildKIDJWS(t, h, key, acc.ID, reqURL, nil)
|
||||
w := doACME(t, r, http.MethodPost, certPath, body)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "alreadyRevoked") {
|
||||
t.Fatalf("response should contain alreadyRevoked, got: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// --- CSR Validation (pure function) ---
|
||||
|
||||
func TestValidateCSRIdentifiersDNSMatch(t *testing.T) {
|
||||
h := testHandler(t)
|
||||
|
||||
csrKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate CSR key: %v", err)
|
||||
}
|
||||
template := &x509.CertificateRequest{
|
||||
DNSNames: []string{"example.com", "www.example.com"},
|
||||
}
|
||||
csrDER, err := x509.CreateCertificateRequest(rand.Reader, template, csrKey)
|
||||
if err != nil {
|
||||
t.Fatalf("create CSR: %v", err)
|
||||
}
|
||||
csr, err := x509.ParseCertificateRequest(csrDER)
|
||||
if err != nil {
|
||||
t.Fatalf("parse CSR: %v", err)
|
||||
}
|
||||
|
||||
identifiers := []Identifier{
|
||||
{Type: IdentifierDNS, Value: "example.com"},
|
||||
{Type: IdentifierDNS, Value: "www.example.com"},
|
||||
}
|
||||
|
||||
if err := h.validateCSRIdentifiers(csr, identifiers); err != nil {
|
||||
t.Fatalf("validateCSRIdentifiers() unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCSRIdentifiersDNSMismatch(t *testing.T) {
|
||||
h := testHandler(t)
|
||||
|
||||
csrKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate CSR key: %v", err)
|
||||
}
|
||||
// CSR has an extra SAN (evil.com) not in the order.
|
||||
template := &x509.CertificateRequest{
|
||||
DNSNames: []string{"example.com", "evil.com"},
|
||||
}
|
||||
csrDER, err := x509.CreateCertificateRequest(rand.Reader, template, csrKey)
|
||||
if err != nil {
|
||||
t.Fatalf("create CSR: %v", err)
|
||||
}
|
||||
csr, err := x509.ParseCertificateRequest(csrDER)
|
||||
if err != nil {
|
||||
t.Fatalf("parse CSR: %v", err)
|
||||
}
|
||||
|
||||
identifiers := []Identifier{
|
||||
{Type: IdentifierDNS, Value: "example.com"},
|
||||
}
|
||||
|
||||
if err := h.validateCSRIdentifiers(csr, identifiers); err == nil {
|
||||
t.Fatalf("expected error for CSR with extra SAN, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCSRIdentifiersMissing(t *testing.T) {
|
||||
h := testHandler(t)
|
||||
|
||||
csrKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate CSR key: %v", err)
|
||||
}
|
||||
// CSR is missing www.example.com that the order requires.
|
||||
template := &x509.CertificateRequest{
|
||||
DNSNames: []string{"example.com"},
|
||||
}
|
||||
csrDER, err := x509.CreateCertificateRequest(rand.Reader, template, csrKey)
|
||||
if err != nil {
|
||||
t.Fatalf("create CSR: %v", err)
|
||||
}
|
||||
csr, err := x509.ParseCertificateRequest(csrDER)
|
||||
if err != nil {
|
||||
t.Fatalf("parse CSR: %v", err)
|
||||
}
|
||||
|
||||
identifiers := []Identifier{
|
||||
{Type: IdentifierDNS, Value: "example.com"},
|
||||
{Type: IdentifierDNS, Value: "www.example.com"},
|
||||
}
|
||||
|
||||
if err := h.validateCSRIdentifiers(csr, identifiers); err == nil {
|
||||
t.Fatalf("expected error for CSR missing a SAN, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helper function tests ---
|
||||
|
||||
func TestExtractIDFromURL(t *testing.T) {
|
||||
url := "https://acme.test/acme/ca/account/abc123"
|
||||
id := extractIDFromURL(url, "/account/")
|
||||
if id != "abc123" {
|
||||
t.Fatalf("extractIDFromURL() = %q, want %q", id, "abc123")
|
||||
}
|
||||
|
||||
// Test with a URL that does not contain the prefix.
|
||||
id = extractIDFromURL("https://acme.test/other/path", "/account/")
|
||||
if id != "" {
|
||||
t.Fatalf("extractIDFromURL() with no match = %q, want empty", id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewIDFormat(t *testing.T) {
|
||||
id := newID()
|
||||
// 16 bytes base64url-encoded = 22 characters (no padding).
|
||||
if len(id) != 22 {
|
||||
t.Fatalf("newID() length = %d, want 22", len(id))
|
||||
}
|
||||
// Verify it is valid base64url.
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(id)
|
||||
if err != nil {
|
||||
t.Fatalf("newID() produced invalid base64url: %v", err)
|
||||
}
|
||||
if len(decoded) != 16 {
|
||||
t.Fatalf("newID() decoded to %d bytes, want 16", len(decoded))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewTokenFormat(t *testing.T) {
|
||||
tok := newToken()
|
||||
// 32 bytes base64url-encoded = 43 characters (no padding).
|
||||
if len(tok) != 43 {
|
||||
t.Fatalf("newToken() length = %d, want 43", len(tok))
|
||||
}
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(tok)
|
||||
if err != nil {
|
||||
t.Fatalf("newToken() produced invalid base64url: %v", err)
|
||||
}
|
||||
if len(decoded) != 32 {
|
||||
t.Fatalf("newToken() decoded to %d bytes, want 32", len(decoded))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user