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:
2026-03-25 21:01:23 -07:00
parent 7f9e7f433f
commit 7749c035ae
8 changed files with 2101 additions and 3 deletions

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