mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 15:41:41 +00:00
acme-server: foundation — directory + new-nonce + per-profile routing (Phase 1a/7)
First slice of the RFC 8555 ACME server endpoint (master plan at cowork/acme-server-endpoint-prompt.md, per-phase prompts at cowork/acme-server-prompts/). This commit lands the smallest viable end-to-end deployable slice: an ACME client running curl -sk https://certctl/acme/profile/<id>/directory curl -sk -I https://certctl/acme/profile/<id>/new-nonce successfully fetches the directory document and a Replay-Nonce. Account creation, JWS verification, orders, challenges, and revocation are all out of scope for this phase and arrive in Phases 1b–4. Closes the Rank 1 LHF from the 2026-05-03 Infisical deep-research (cowork/infisical-deep-research-results.md). Pre-fix, certctl was an ACME consumer only — no /acme/directory endpoint, no JWS verifier, no challenge validators. K8s customers running cert-manager could not point at certctl as an ACME issuer; they had to deploy a certctl agent on every node. What ships: - internal/api/acme/{directory,nonce,errors}.go (+ tests). - internal/api/handler/acme.go + acme_handler_test.go. - internal/repository/postgres/acme.go (nonce ops only — Phase 1b extends with account CRUD; Phases 2-4 extend with order / authz / challenge CRUD). - internal/service/acme.go (BuildDirectory + IssueNonce stubs; Phase 1b adds VerifyJWS / NewAccount / etc.). - migrations/000025_acme_server.{up,down}.sql ships the full 5-table ACME schema (acme_accounts / acme_orders / acme_authorizations / acme_challenges / acme_nonces) PLUS the per-profile certificate_profiles.acme_auth_mode column. Phase 1a actively uses only acme_nonces; remaining tables are empty until Phases 1b-4 plug in. - internal/config/config.go: ACMEServerConfig struct + ACMEServer field on Config. Env vars use CERTCTL_ACME_SERVER_* prefix to avoid colliding with the existing consumer-side ACMEConfig at config.go:1746 (CERTCTL_ACME_DIRECTORY_URL / PROFILE / CHALLENGE_TYPE etc.). Phase 1a wires Enabled + DefaultAuthMode + DefaultProfileID + NonceTTL + DirectoryMeta; Order/Authz TTLs + per-challenge-type concurrency caps + DNS01 resolver are reserved fields parsed in 1a so operators can set them ahead of Phases 2/3. - cmd/server/main.go: wire ACMEHandler into the HandlerRegistry literal alongside the existing certificate / EST / SCEP / etc. handlers. - internal/api/router/router.go: HandlerRegistry.ACME field + 6 Register calls (3 per-profile + 3 shorthand). - internal/api/router/openapi_parity_test.go: 6 new entries in SpecParityExceptions. ACME is a wire-protocol surface (JWS-signed JSON over HTTPS per RFC 7515) whose semantics are dictated by RFC 8555 + RFC 9773 rather than by an OpenAPI document, same precedent as SCEP/EST. The canonical reference is docs/acme-server.md. - docs/acme-server.md: Phase-1a-shaped reference. Configuration table for every CERTCTL_ACME_SERVER_* env var. Per-profile auth-mode decision tree skeleton. TLS trust bootstrap section flagging cert-manager's ClusterIssuer.spec.acme.caBundle requirement (the single biggest first-time-deploy footgun; the full cert-manager walkthrough lands in Phase 6 but the requirement is documented up front). Architecture decisions baked in: - URL family is /acme/profile/<id>/* (per-profile, canonical) with /acme/* shorthand active when CERTCTL_ACME_SERVER_DEFAULT_PROFILE_ID is set. Path matches existing per-profile precedent in EST + SCEP. - Auth mode is per-profile (acme_auth_mode column on certificate_profiles), NOT server-wide. One certctl-server can serve trust_authenticated for an internal-PKI profile and challenge for a public-trust-style profile simultaneously. The column is read at request time, not cached at server start — operators flipping a profile's mode via SQL take effect on the next order without restart. - Nonces are DB-backed (acme_nonces table). Survive server restart. The RFC 8555 §6.5 replay defense requires the store to outlast the client's nonce caching window; an in-memory-only nonce store would lose every in-flight order on restart. - Per-op atomic counters on service.ACMEService.Metrics() — certctl_acme_directory_total, certctl_acme_directory_failures_total, certctl_acme_new_nonce_total, certctl_acme_new_nonce_failures_total. Naming follows certctl frozen decision 0.10 cardinality discipline. Phase 1b will extend with new_account counters; Phase 2 with order / finalize / cert; Phase 3 with per-challenge-type counters. Audit fixes #11 + #12 (cowork/acme-server-prompts/audit-additions.md) applied: - #11: CERTCTL_ACME_SERVER_* prefix avoids the consumer-side CERTCTL_ACME_* namespace collision. - #12: prior-attempt WIP from two failed Phase-1 dispatches was discarded at phase start; this commit starts from a clean tree. Tests: - 14 unit tests in internal/api/acme/ (directory, nonce, errors). - 7 handler-level tests via httptest.NewServer + mockACMEService (mirrors the mockSCEPService pattern at scep_handler_test.go). - 7 service-layer tests with mocked repo + injected profileLookup. - All pass under -race -count=1 -short. Deferred to Phase 1b: - JWS verification (go-jose v4 — see master-prompt §8a for the API surface and audit doc for the speculation pitfalls). - new-account / account/<id> endpoints + AccountService. - Nonce *consumption* path (issue path is in this commit; consume is only invoked by JWS-verified POSTs which Phase 1b adds). Engineering history: cowork/WORKSPACE-CHANGELOG.md "ACME-Server-1a". Per-phase implementation plan: cowork/acme-server-prompts/. Master plan + audit fixes: cowork/acme-server-endpoint-prompt.md + cowork/acme-server-prompt-audit.md + cowork/acme-server-prompts/audit-additions.md.
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
// Copyright (c) certctl
|
||||
// SPDX-License-Identifier: BSL-1.1
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/api/acme"
|
||||
"github.com/shankar0123/certctl/internal/service"
|
||||
)
|
||||
|
||||
// ACMEService is the handler-facing surface for the ACME server. The
|
||||
// service-layer concrete type is *service.ACMEService; the interface
|
||||
// definition lives here to keep the handler import-direction
|
||||
// canonical (handler imports service, not the reverse). Phase 1a
|
||||
// pins two methods; Phase 1b extends with VerifyJWS, NewAccount,
|
||||
// LookupAccount, UpdateAccount, DeactivateAccount.
|
||||
type ACMEService interface {
|
||||
BuildDirectory(ctx context.Context, profileID, baseURL string) (*acme.Directory, error)
|
||||
IssueNonce(ctx context.Context) (string, error)
|
||||
}
|
||||
|
||||
// ACMEHandler exposes the ACME server's RFC 8555 endpoints under the
|
||||
// per-profile path /acme/profile/<id>/* and (optionally) the
|
||||
// /acme/* shorthand when CERTCTL_ACME_SERVER_DEFAULT_PROFILE_ID is
|
||||
// set. Phase 1a wires:
|
||||
//
|
||||
// - GET /acme/profile/{id}/directory
|
||||
// - HEAD /acme/profile/{id}/new-nonce
|
||||
// - GET /acme/profile/{id}/new-nonce
|
||||
// - GET /acme/directory (shorthand)
|
||||
// - HEAD /acme/new-nonce (shorthand)
|
||||
// - GET /acme/new-nonce (shorthand)
|
||||
//
|
||||
// Phase 1b adds new-account + account/<id>; Phase 2 adds new-order +
|
||||
// order/<id>(/finalize) + authz/<id> + cert/<id>; Phase 3 adds
|
||||
// challenge/<id>; Phase 4 adds key-change + revoke-cert + renewal-info.
|
||||
//
|
||||
// Handler shape mirrors internal/api/handler/scep.go:73-91 (struct
|
||||
// holding the service interface, factory function returning the
|
||||
// struct value).
|
||||
type ACMEHandler struct {
|
||||
svc ACMEService
|
||||
}
|
||||
|
||||
// NewACMEHandler constructs an ACMEHandler. Returns the value (not a
|
||||
// pointer) — same convention as NewSCEPHandler at scep.go:89.
|
||||
func NewACMEHandler(svc ACMEService) ACMEHandler {
|
||||
return ACMEHandler{svc: svc}
|
||||
}
|
||||
|
||||
// Directory handles GET requests to the directory URL. The Go 1.22+
|
||||
// stdlib router parses the {id} path parameter via r.PathValue("id").
|
||||
// When the path is /acme/directory (no profile in URL), PathValue
|
||||
// returns ""; the service layer applies the
|
||||
// CERTCTL_ACME_SERVER_DEFAULT_PROFILE_ID fallback (or returns
|
||||
// userActionRequired if unset).
|
||||
func (h ACMEHandler) Directory(w http.ResponseWriter, r *http.Request) {
|
||||
profileID := r.PathValue("id")
|
||||
baseURL := h.directoryBaseURL(r, profileID)
|
||||
|
||||
dir, err := h.svc.BuildDirectory(r.Context(), profileID, baseURL)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
// RFC 8555 §6.5: every successful response carries Replay-Nonce.
|
||||
// The directory endpoint is not JWS-authenticated but ACME clients
|
||||
// expect the header so they can use it on the very next POST.
|
||||
if nonce, err := h.svc.IssueNonce(r.Context()); err == nil {
|
||||
w.Header().Set("Replay-Nonce", nonce)
|
||||
}
|
||||
w.Header().Set("Cache-Control", "public, max-age=0, no-cache")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(dir)
|
||||
}
|
||||
|
||||
// NewNonce handles HEAD and GET on the new-nonce URL.
|
||||
//
|
||||
// RFC 8555 §7.2:
|
||||
// - HEAD MUST return 200 with Replay-Nonce + zero-length body.
|
||||
// - GET MUST return 204 No Content with Replay-Nonce + zero-length body.
|
||||
//
|
||||
// Both verbs MUST set Cache-Control: no-store so middleboxes don't
|
||||
// inadvertently re-serve a stale nonce.
|
||||
//
|
||||
// We resolve the profile here (rather than passing it through the
|
||||
// service) only to validate it exists — the nonce itself is global
|
||||
// to the server (one acme_nonces table), but if the operator hits
|
||||
// /acme/profile/<bogus>/new-nonce we return 404 so the path-shape
|
||||
// failure is operator-visible.
|
||||
func (h ACMEHandler) NewNonce(w http.ResponseWriter, r *http.Request) {
|
||||
profileID := r.PathValue("id")
|
||||
// Same profile-resolution path as Directory — go through
|
||||
// BuildDirectory only to leverage its profile-not-found / user-
|
||||
// action-required mapping. The directory document is not used.
|
||||
baseURL := h.directoryBaseURL(r, profileID)
|
||||
if _, err := h.svc.BuildDirectory(r.Context(), profileID, baseURL); err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
nonce, err := h.svc.IssueNonce(r.Context())
|
||||
if err != nil {
|
||||
acme.WriteProblem(w, acme.ServerInternal("nonce issuance failed"))
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Replay-Nonce", nonce)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if r.Method == http.MethodHead {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// directoryBaseURL composes the per-profile base URL the directory's
|
||||
// inner URLs are built against. The composition lives in the handler
|
||||
// (NOT the service) because it depends on the inbound request's
|
||||
// scheme + host + observed path; the service layer would need to
|
||||
// import net/http to do this.
|
||||
//
|
||||
// For requests on /acme/profile/<id>/* we strip the trailing path
|
||||
// element to produce the base. For shorthand /acme/* requests we
|
||||
// strip the trailing element from /acme — the result is just the
|
||||
// scheme://host/acme prefix, which the service then uses to build
|
||||
// /acme/new-nonce, /acme/new-account, etc.
|
||||
func (h ACMEHandler) directoryBaseURL(r *http.Request, profileID string) string {
|
||||
scheme := "https"
|
||||
if r.TLS == nil {
|
||||
// HTTPS-only architecture decision (CLAUDE.md): the listener
|
||||
// is TLS 1.3 pinned. r.TLS == nil only happens in tests with
|
||||
// httptest.NewServer (non-TLS); honor http: for those.
|
||||
scheme = "http"
|
||||
}
|
||||
if profileID != "" {
|
||||
return scheme + "://" + r.Host + "/acme/profile/" + profileID
|
||||
}
|
||||
return scheme + "://" + r.Host + "/acme"
|
||||
}
|
||||
|
||||
// writeServiceError maps service-layer sentinels to RFC 7807 + RFC
|
||||
// 8555 §6.7 problem responses. Centralized so every handler method
|
||||
// gets identical mapping; future Phase 1b/2/3/4 sentinels extend
|
||||
// the switch.
|
||||
func writeServiceError(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrACMEUserActionRequired):
|
||||
acme.WriteProblem(w, acme.UserActionRequired(
|
||||
"this server requires the per-profile path /acme/profile/<id>/* — "+
|
||||
"set CERTCTL_ACME_SERVER_DEFAULT_PROFILE_ID for /acme/* shorthand"))
|
||||
case errors.Is(err, service.ErrACMEProfileNotFound):
|
||||
acme.WriteProblem(w, acme.Problem{
|
||||
Type: "urn:ietf:params:acme:error:userActionRequired",
|
||||
Detail: "profile not found",
|
||||
Status: http.StatusNotFound,
|
||||
})
|
||||
default:
|
||||
// Avoid leaking internal error text per master-prompt
|
||||
// criterion #10 (operator-actionable errors with no info
|
||||
// leak). The detail is operator-facing but generic.
|
||||
acme.WriteProblem(w, acme.ServerInternal("ACME server error"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
// Copyright (c) certctl
|
||||
// SPDX-License-Identifier: BSL-1.1
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/api/acme"
|
||||
"github.com/shankar0123/certctl/internal/service"
|
||||
)
|
||||
|
||||
// mockACMEService implements ACMEService for handler-level tests.
|
||||
// Mirrors the mockSCEPService pattern at scep_handler_test.go (struct
|
||||
// holding canned responses + an err field per method).
|
||||
type mockACMEService struct {
|
||||
BuildDirectoryFn func(ctx context.Context, profileID, baseURL string) (*acme.Directory, error)
|
||||
IssueNonceFn func(ctx context.Context) (string, error)
|
||||
}
|
||||
|
||||
func (m *mockACMEService) BuildDirectory(ctx context.Context, profileID, baseURL string) (*acme.Directory, error) {
|
||||
if m.BuildDirectoryFn != nil {
|
||||
return m.BuildDirectoryFn(ctx, profileID, baseURL)
|
||||
}
|
||||
return acme.BuildDirectory(baseURL, "", "", nil, false, false), nil
|
||||
}
|
||||
|
||||
func (m *mockACMEService) IssueNonce(ctx context.Context) (string, error) {
|
||||
if m.IssueNonceFn != nil {
|
||||
return m.IssueNonceFn(ctx)
|
||||
}
|
||||
return "test-nonce-12345", nil
|
||||
}
|
||||
|
||||
// newACMETestServer wires the ACMEHandler against the mock + a stdlib
|
||||
// ServeMux configured exactly the way internal/api/router/router.go
|
||||
// does it in production. Routes:
|
||||
//
|
||||
// GET /acme/profile/{id}/directory
|
||||
// HEAD /acme/profile/{id}/new-nonce
|
||||
// GET /acme/profile/{id}/new-nonce
|
||||
// GET /acme/directory (shorthand)
|
||||
// HEAD /acme/new-nonce (shorthand)
|
||||
// GET /acme/new-nonce (shorthand)
|
||||
func newACMETestServer(t *testing.T, mock *mockACMEService) *httptest.Server {
|
||||
t.Helper()
|
||||
h := NewACMEHandler(mock)
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /acme/profile/{id}/directory", h.Directory)
|
||||
mux.HandleFunc("HEAD /acme/profile/{id}/new-nonce", h.NewNonce)
|
||||
mux.HandleFunc("GET /acme/profile/{id}/new-nonce", h.NewNonce)
|
||||
mux.HandleFunc("GET /acme/directory", h.Directory)
|
||||
mux.HandleFunc("HEAD /acme/new-nonce", h.NewNonce)
|
||||
mux.HandleFunc("GET /acme/new-nonce", h.NewNonce)
|
||||
return httptest.NewServer(mux)
|
||||
}
|
||||
|
||||
func TestACMEHandler_Directory_HappyPath(t *testing.T) {
|
||||
mock := &mockACMEService{}
|
||||
srv := newACMETestServer(t, mock)
|
||||
defer srv.Close()
|
||||
|
||||
resp, err := http.Get(srv.URL + "/acme/profile/prof-corp/directory")
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if got := resp.Header.Get("Content-Type"); got != "application/json" {
|
||||
t.Errorf("content-type = %q", got)
|
||||
}
|
||||
if got := resp.Header.Get("Replay-Nonce"); got == "" {
|
||||
t.Error("Replay-Nonce header missing on directory response")
|
||||
}
|
||||
|
||||
var dir acme.Directory
|
||||
if err := json.NewDecoder(resp.Body).Decode(&dir); err != nil {
|
||||
t.Fatalf("Decode: %v", err)
|
||||
}
|
||||
if !strings.Contains(dir.NewNonce, "/acme/profile/prof-corp/new-nonce") {
|
||||
t.Errorf("NewNonce = %q", dir.NewNonce)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACMEHandler_Directory_UnknownProfile(t *testing.T) {
|
||||
mock := &mockACMEService{
|
||||
BuildDirectoryFn: func(ctx context.Context, profileID, baseURL string) (*acme.Directory, error) {
|
||||
return nil, service.ErrACMEProfileNotFound
|
||||
},
|
||||
}
|
||||
srv := newACMETestServer(t, mock)
|
||||
defer srv.Close()
|
||||
|
||||
resp, err := http.Get(srv.URL + "/acme/profile/missing/directory")
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("status = %d, want 404", resp.StatusCode)
|
||||
}
|
||||
if got := resp.Header.Get("Content-Type"); got != acme.ProblemContentType {
|
||||
t.Errorf("content-type = %q, want %q", got, acme.ProblemContentType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACMEHandler_NewNonce_HEAD(t *testing.T) {
|
||||
mock := &mockACMEService{}
|
||||
srv := newACMETestServer(t, mock)
|
||||
defer srv.Close()
|
||||
|
||||
req, _ := http.NewRequest(http.MethodHead, srv.URL+"/acme/profile/prof-corp/new-nonce", nil)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("HEAD: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200 (HEAD)", resp.StatusCode)
|
||||
}
|
||||
if got := resp.Header.Get("Replay-Nonce"); got != "test-nonce-12345" {
|
||||
t.Errorf("Replay-Nonce = %q", got)
|
||||
}
|
||||
if got := resp.Header.Get("Cache-Control"); got != "no-store" {
|
||||
t.Errorf("Cache-Control = %q, want no-store", got)
|
||||
}
|
||||
if resp.ContentLength > 0 {
|
||||
t.Errorf("HEAD body should be zero-length; got Content-Length=%d", resp.ContentLength)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACMEHandler_NewNonce_GET(t *testing.T) {
|
||||
mock := &mockACMEService{}
|
||||
srv := newACMETestServer(t, mock)
|
||||
defer srv.Close()
|
||||
|
||||
resp, err := http.Get(srv.URL + "/acme/profile/prof-corp/new-nonce")
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Errorf("status = %d, want 204 (GET)", resp.StatusCode)
|
||||
}
|
||||
if got := resp.Header.Get("Replay-Nonce"); got != "test-nonce-12345" {
|
||||
t.Errorf("Replay-Nonce = %q", got)
|
||||
}
|
||||
if got := resp.Header.Get("Cache-Control"); got != "no-store" {
|
||||
t.Errorf("Cache-Control = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACMEHandler_Shorthand_DefaultProfileSet(t *testing.T) {
|
||||
// Service-layer mock returns a directory; handler test asserts the
|
||||
// /acme/directory shorthand reaches the same handler path as the
|
||||
// per-profile directory.
|
||||
mock := &mockACMEService{}
|
||||
srv := newACMETestServer(t, mock)
|
||||
defer srv.Close()
|
||||
|
||||
resp, err := http.Get(srv.URL + "/acme/directory")
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
var dir acme.Directory
|
||||
if err := json.NewDecoder(resp.Body).Decode(&dir); err != nil {
|
||||
t.Fatalf("Decode: %v", err)
|
||||
}
|
||||
if !strings.HasSuffix(dir.NewNonce, "/acme/new-nonce") {
|
||||
t.Errorf("NewNonce = %q (shorthand path expected)", dir.NewNonce)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACMEHandler_Shorthand_DefaultProfileUnset(t *testing.T) {
|
||||
mock := &mockACMEService{
|
||||
BuildDirectoryFn: func(ctx context.Context, profileID, baseURL string) (*acme.Directory, error) {
|
||||
return nil, service.ErrACMEUserActionRequired
|
||||
},
|
||||
}
|
||||
srv := newACMETestServer(t, mock)
|
||||
defer srv.Close()
|
||||
|
||||
resp, err := http.Get(srv.URL + "/acme/directory")
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("status = %d, want 403", resp.StatusCode)
|
||||
}
|
||||
if got := resp.Header.Get("Content-Type"); got != acme.ProblemContentType {
|
||||
t.Errorf("content-type = %q, want %q", got, acme.ProblemContentType)
|
||||
}
|
||||
var p acme.Problem
|
||||
if err := json.NewDecoder(resp.Body).Decode(&p); err != nil {
|
||||
t.Fatalf("Decode: %v", err)
|
||||
}
|
||||
if p.Type != "urn:ietf:params:acme:error:userActionRequired" {
|
||||
t.Errorf("Problem.Type = %q", p.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACMEHandler_NewNonce_ServiceError(t *testing.T) {
|
||||
mock := &mockACMEService{
|
||||
IssueNonceFn: func(ctx context.Context) (string, error) {
|
||||
return "", errors.New("disk full")
|
||||
},
|
||||
}
|
||||
srv := newACMETestServer(t, mock)
|
||||
defer srv.Close()
|
||||
|
||||
resp, err := http.Get(srv.URL + "/acme/profile/prof-corp/new-nonce")
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Errorf("status = %d, want 500", resp.StatusCode)
|
||||
}
|
||||
if got := resp.Header.Get("Content-Type"); got != acme.ProblemContentType {
|
||||
t.Errorf("content-type = %q", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user