Files
metacrypt/internal/acme/live_test.go
Kyle Isom 56b5bae1f6 Add live integration tests for ACME server (5 tests)
Tests run against a real Metacrypt instance, guarded by
//go:build integration tag and METACRYPT_LIVE_TEST=1 env var.

Covers: directory discovery, nonce issuance, full account creation
with EAB (including reuse rejection), order creation with DNS
identifiers, and authorization retrieval with HTTP-01/DNS-01
challenges.

Handles server/client URL mismatch when ExternalURL is not
configured (JWS URL fields use server's base URL, HTTP requests
use the configured address).

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

852 lines
24 KiB
Go

//go:build integration
package acme_test // external test package — tests the public surface
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/asn1"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"math/big"
"net/http"
"os"
"strings"
"testing"
)
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
func skipIfNotLive(t *testing.T) {
t.Helper()
if os.Getenv("METACRYPT_LIVE_TEST") != "1" {
t.Skip("set METACRYPT_LIVE_TEST=1 to run live integration tests")
}
}
func liveAddr() string { return envOr("METACRYPT_ADDR", "https://127.0.0.1:18443") }
func liveMount() string { return envOr("METACRYPT_ACME_MOUNT", "pki") }
func liveToken() string { return os.Getenv("METACRYPT_TOKEN") }
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
// liveHTTPClient returns an *http.Client configured with the CA cert from
// METACRYPT_CA_CERT, falling back to InsecureSkipVerify if unset.
func liveHTTPClient(t *testing.T) *http.Client {
t.Helper()
tlsCfg := &tls.Config{
MinVersion: tls.VersionTLS12,
}
caPath := os.Getenv("METACRYPT_CA_CERT")
if caPath != "" {
caPEM, err := os.ReadFile(caPath)
if err != nil {
t.Fatalf("read CA cert %s: %v", caPath, err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(caPEM) {
t.Fatalf("failed to parse CA cert from %s", caPath)
}
tlsCfg.RootCAs = pool
} else {
tlsCfg.InsecureSkipVerify = true //nolint:gosec
}
return &http.Client{
Transport: &http.Transport{TLSClientConfig: tlsCfg},
}
}
// ---------------------------------------------------------------------------
// liveTestEnv — shared per-test state with lazy account creation
// ---------------------------------------------------------------------------
type liveTestEnv struct {
t *testing.T
client *http.Client
addr string
mount string
token string
// directory is fetched once to discover the server's base URL.
directory map[string]interface{}
dirFetched bool
acmeBaseURL string // our reachable base URL for HTTP requests
serverBaseURL string // the server's own base URL, for JWS URL fields
// lazily initialized
accountURL string // full URL returned in Location header
accountID string
key *ecdsa.PrivateKey
jwk json.RawMessage
}
func newLiveEnv(t *testing.T) *liveTestEnv {
t.Helper()
return &liveTestEnv{
t: t,
client: liveHTTPClient(t),
addr: liveAddr(),
mount: liveMount(),
token: liveToken(),
}
}
// acmeURL returns the full URL for an ACME path (e.g. "/directory").
// It uses the server's own base URL discovered from the directory.
func (e *liveTestEnv) acmeURL(path string) string {
e.t.Helper()
e.ensureDirectory()
return e.acmeBaseURL + path
}
// rawACMEURL returns a URL without consulting the directory — for the
// initial directory fetch itself.
func (e *liveTestEnv) rawACMEURL(path string) string {
return e.addr + "/acme/" + e.mount + path
}
// jwsURL returns the URL the server expects in JWS URL fields. This may
// differ from acmeURL when the server's ExternalURL is not configured.
func (e *liveTestEnv) jwsURL(path string) string {
e.t.Helper()
e.ensureDirectory()
return e.serverBaseURL + path
}
// mgmtURL returns the full URL for a management API path.
func (e *liveTestEnv) mgmtURL(path string) string {
return e.addr + "/v1/acme/" + e.mount + path
}
// ensureDirectory fetches and caches the ACME directory.
func (e *liveTestEnv) ensureDirectory() {
e.t.Helper()
if e.dirFetched {
return
}
resp, err := e.client.Get(e.rawACMEURL("/directory"))
if err != nil {
e.t.Fatalf("GET directory: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
e.t.Fatalf("GET directory: status %d: %s", resp.StatusCode, body)
}
var dir map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&dir); err != nil {
e.t.Fatalf("decode directory: %v", err)
}
e.directory = dir
e.dirFetched = true
// Our reachable base URL for HTTP requests.
e.acmeBaseURL = e.addr + "/acme/" + e.mount
// The server's own base URL, used for JWS URL fields which must match
// what the server expects. May differ from our reachable URL when the
// server's ExternalURL is misconfigured.
newNonce, ok := dir["newNonce"].(string)
if !ok || newNonce == "" {
e.t.Fatalf("directory missing newNonce")
}
e.serverBaseURL = strings.TrimSuffix(newNonce, "/new-nonce")
}
// getNonce fetches a fresh replay nonce from the server.
func (e *liveTestEnv) getNonce() string {
e.t.Helper()
e.ensureDirectory()
req, err := http.NewRequest(http.MethodHead, e.acmeURL("/new-nonce"), nil)
if err != nil {
e.t.Fatalf("build nonce request: %v", err)
}
resp, err := e.client.Do(req)
if err != nil {
e.t.Fatalf("HEAD new-nonce: %v", err)
}
defer resp.Body.Close()
nonce := resp.Header.Get("Replay-Nonce")
if nonce == "" {
e.t.Fatal("Replay-Nonce header is empty")
}
return nonce
}
// createEAB calls the management API to create an EAB credential.
// Returns kid and hmacKey (raw bytes).
func (e *liveTestEnv) createEAB() (kid string, hmacKey []byte) {
e.t.Helper()
if e.token == "" {
e.t.Fatal("METACRYPT_TOKEN is required for EAB creation")
}
req, err := http.NewRequest(http.MethodPost, e.mgmtURL("/eab"), nil)
if err != nil {
e.t.Fatalf("build EAB request: %v", err)
}
req.Header.Set("Authorization", "Bearer "+e.token)
resp, err := e.client.Do(req)
if err != nil {
e.t.Fatalf("POST eab: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusCreated {
e.t.Fatalf("POST eab: status %d: %s", resp.StatusCode, body)
}
var eabResp struct {
KID string `json:"kid"`
HMACKey []byte `json:"hmac_key"` // Go json.Marshal of []byte produces base64
}
if err := json.Unmarshal(body, &eabResp); err != nil {
e.t.Fatalf("decode EAB response: %v (body: %s)", err, body)
}
if eabResp.KID == "" {
e.t.Fatalf("EAB response missing kid (body: %s)", body)
}
if len(eabResp.HMACKey) == 0 {
e.t.Fatalf("EAB response missing hmac_key (body: %s)", body)
}
return eabResp.KID, eabResp.HMACKey
}
// generateKey creates an ECDSA P-256 key pair and a JWK representation.
func (e *liveTestEnv) generateKey() (*ecdsa.PrivateKey, json.RawMessage) {
e.t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
e.t.Fatalf("generate ES256 key: %v", err)
}
byteLen := (key.Curve.Params().BitSize + 7) / 8
xBytes := key.PublicKey.X.Bytes()
yBytes := key.PublicKey.Y.Bytes()
for len(xBytes) < byteLen {
xBytes = append([]byte{0}, xBytes...)
}
for len(yBytes) < byteLen {
yBytes = append([]byte{0}, yBytes...)
}
jwk, err := json.Marshal(map[string]string{
"kty": "EC",
"crv": "P-256",
"x": base64.RawURLEncoding.EncodeToString(xBytes),
"y": base64.RawURLEncoding.EncodeToString(yBytes),
})
if err != nil {
e.t.Fatalf("marshal JWK: %v", err)
}
return key, json.RawMessage(jwk)
}
// ensureAccount creates an ACME account if not already created.
func (e *liveTestEnv) ensureAccount() {
e.t.Helper()
if e.accountURL != "" {
return
}
key, jwk := e.generateKey()
e.key = key
e.jwk = jwk
kid, hmacKey := e.createEAB()
nonce := e.getNonce()
// JWS URL field must match the server's own base URL.
jwsAccountURL := e.jwsURL("/new-account")
eabJWS := liveSignEAB(e.t, kid, hmacKey, jwk, jwsAccountURL)
// Build the new-account payload.
payload, err := json.Marshal(map[string]interface{}{
"termsOfServiceAgreed": true,
"contact": []string{"mailto:live-test@metacircular.net"},
"externalAccountBinding": json.RawMessage(eabJWS),
})
if err != nil {
e.t.Fatalf("marshal new-account payload: %v", err)
}
// Build the outer JWS with embedded JWK (new-account uses JWK, not KID).
header := map[string]interface{}{
"alg": "ES256",
"nonce": nonce,
"url": jwsAccountURL,
"jwk": json.RawMessage(jwk),
}
body := liveSignJWS(e.t, key, "ES256", header, payload)
// POST to our reachable URL (may differ from JWS URL).
resp, err := e.client.Post(e.acmeURL("/new-account"), "application/jose+json", strings.NewReader(string(body)))
if err != nil {
e.t.Fatalf("POST new-account: %v", err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
e.t.Fatalf("POST new-account: status %d: %s", resp.StatusCode, respBody)
}
location := resp.Header.Get("Location")
if location == "" {
e.t.Fatal("new-account response missing Location header")
}
e.accountURL = location
// Extract account ID from the Location URL (last path segment after /account/).
if idx := strings.LastIndex(location, "/account/"); idx >= 0 {
e.accountID = location[idx+len("/account/"):]
} else {
// Use the full URL as the ID / KID.
e.accountID = location
}
}
// postAsGet sends a POST-as-GET request (empty payload, KID auth).
// httpURL is where to send the request; jwsURL is the URL in the JWS header.
func (e *liveTestEnv) postAsGet(httpURL, jwsURL string) (*http.Response, []byte) {
e.t.Helper()
e.ensureAccount()
nonce := e.getNonce()
header := map[string]interface{}{
"alg": "ES256",
"nonce": nonce,
"url": jwsURL,
"kid": e.accountURL,
}
body := liveSignJWS(e.t, e.key, "ES256", header, nil) // nil payload = POST-as-GET
resp, err := e.client.Post(httpURL, "application/jose+json", strings.NewReader(string(body)))
if err != nil {
e.t.Fatalf("POST-as-GET %s: %v", httpURL, err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
return resp, respBody
}
// postSigned sends a signed POST with a payload (KID auth).
// httpURL is where to send the request; jwsURL is the URL in the JWS header.
func (e *liveTestEnv) postSigned(httpURL, jwsURL string, payload []byte) (*http.Response, []byte) {
e.t.Helper()
e.ensureAccount()
nonce := e.getNonce()
header := map[string]interface{}{
"alg": "ES256",
"nonce": nonce,
"url": jwsURL,
"kid": e.accountURL,
}
body := liveSignJWS(e.t, e.key, "ES256", header, payload)
resp, err := e.client.Post(httpURL, "application/jose+json", strings.NewReader(string(body)))
if err != nil {
e.t.Fatalf("POST %s: %v", httpURL, err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
return resp, respBody
}
// ---------------------------------------------------------------------------
// JWS signing helpers (local implementations for external test package)
// ---------------------------------------------------------------------------
// ecdsaSigASN1 is used to decode an ASN.1 ECDSA signature into R and S.
type ecdsaSigASN1 struct {
R *big.Int
S *big.Int
}
// liveSignJWS creates a flattened JWS. header is marshaled as the protected
// header. If payload is nil, the payload field is empty (POST-as-GET).
func liveSignJWS(t *testing.T, key *ecdsa.PrivateKey, alg string, header map[string]interface{}, payload []byte) []byte {
t.Helper()
headerJSON, err := json.Marshal(header)
if err != nil {
t.Fatalf("marshal JWS header: %v", err)
}
protected := base64.RawURLEncoding.EncodeToString(headerJSON)
var encodedPayload string
if payload != nil {
encodedPayload = base64.RawURLEncoding.EncodeToString(payload)
}
signingInput := []byte(protected + "." + encodedPayload)
var sig []byte
switch alg {
case "ES256":
digest := sha256.Sum256(signingInput)
derSig, err := ecdsa.SignASN1(rand.Reader, key, digest[:])
if err != nil {
t.Fatalf("sign ES256: %v", err)
}
var parsed ecdsaSigASN1
if _, err := asn1.Unmarshal(derSig, &parsed); err != nil {
t.Fatalf("unmarshal ECDSA ASN.1 sig: %v", err)
}
byteLen := (key.Curve.Params().BitSize + 7) / 8
rBytes := parsed.R.Bytes()
sBytes := parsed.S.Bytes()
for len(rBytes) < byteLen {
rBytes = append([]byte{0}, rBytes...)
}
for len(sBytes) < byteLen {
sBytes = append([]byte{0}, sBytes...)
}
sig = append(rBytes, sBytes...)
default:
t.Fatalf("liveSignJWS: unsupported algorithm %s", alg)
}
flat := struct {
Protected string `json:"protected"`
Payload string `json:"payload"`
Signature string `json:"signature"`
}{
Protected: protected,
Payload: encodedPayload,
Signature: base64.RawURLEncoding.EncodeToString(sig),
}
out, err := json.Marshal(flat)
if err != nil {
t.Fatalf("marshal JWS flat: %v", err)
}
return out
}
// liveSignEAB creates a valid EAB inner JWS (RFC 8555 section 7.3.4).
// The EAB binds the account JWK to the EAB kid using HMAC-SHA256.
func liveSignEAB(t *testing.T, kid string, hmacKey []byte, accountJWK json.RawMessage, url string) []byte {
t.Helper()
header := map[string]string{
"alg": "HS256",
"kid": kid,
"url": url,
}
headerJSON, err := json.Marshal(header)
if err != nil {
t.Fatalf("marshal EAB header: %v", err)
}
protected := base64.RawURLEncoding.EncodeToString(headerJSON)
encodedPayload := base64.RawURLEncoding.EncodeToString(accountJWK)
signingInput := []byte(protected + "." + encodedPayload)
mac := hmac.New(sha256.New, hmacKey)
mac.Write(signingInput)
sig := mac.Sum(nil)
flat := struct {
Protected string `json:"protected"`
Payload string `json:"payload"`
Signature string `json:"signature"`
}{
Protected: protected,
Payload: encodedPayload,
Signature: base64.RawURLEncoding.EncodeToString(sig),
}
out, err := json.Marshal(flat)
if err != nil {
t.Fatalf("marshal EAB flat: %v", err)
}
return out
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
func TestLiveDirectory(t *testing.T) {
skipIfNotLive(t)
env := newLiveEnv(t)
resp, err := env.client.Get(env.rawACMEURL("/directory"))
if err != nil {
t.Fatalf("GET directory: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, body)
}
var dir map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&dir); err != nil {
t.Fatalf("decode directory: %v", err)
}
for _, field := range []string{"newNonce", "newAccount", "newOrder"} {
v, ok := dir[field]
if !ok {
t.Errorf("directory missing field %q", field)
continue
}
s, ok := v.(string)
if !ok || s == "" {
t.Errorf("directory field %q is empty or not a string: %v", field, v)
}
}
meta, ok := dir["meta"].(map[string]interface{})
if !ok {
t.Fatal("directory missing meta object")
}
ear, ok := meta["externalAccountRequired"].(bool)
if !ok || !ear {
t.Errorf("expected meta.externalAccountRequired=true, got %v", meta["externalAccountRequired"])
}
}
func TestLiveNonce(t *testing.T) {
skipIfNotLive(t)
env := newLiveEnv(t)
env.ensureDirectory()
req, err := http.NewRequest(http.MethodHead, env.acmeURL("/new-nonce"), nil)
if err != nil {
t.Fatalf("build request: %v", err)
}
resp, err := env.client.Do(req)
if err != nil {
t.Fatalf("HEAD new-nonce: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
nonce := resp.Header.Get("Replay-Nonce")
if nonce == "" {
t.Fatal("Replay-Nonce header is empty")
}
// Verify a second request returns a different nonce.
req2, _ := http.NewRequest(http.MethodHead, env.acmeURL("/new-nonce"), nil)
resp2, err := env.client.Do(req2)
if err != nil {
t.Fatalf("second HEAD new-nonce: %v", err)
}
defer resp2.Body.Close()
nonce2 := resp2.Header.Get("Replay-Nonce")
if nonce2 == "" {
t.Fatal("second Replay-Nonce header is empty")
}
if nonce == nonce2 {
t.Error("two nonces should not be identical")
}
}
func TestLiveCreateEABAndAccount(t *testing.T) {
skipIfNotLive(t)
env := newLiveEnv(t)
// a) Create EAB via management API.
kid, hmacKey := env.createEAB()
if kid == "" {
t.Fatal("EAB kid is empty")
}
if len(hmacKey) == 0 {
t.Fatal("EAB hmac_key is empty")
}
// b) Get a nonce.
nonce := env.getNonce()
if nonce == "" {
t.Fatal("nonce is empty")
}
// c) Generate an ES256 key pair.
key, jwk := env.generateKey()
// d) Construct EAB inner JWS.
jwsAccountURL := env.jwsURL("/new-account")
eabJWS := liveSignEAB(t, kid, hmacKey, jwk, jwsAccountURL)
// e) Construct the new-account JWS with EAB.
payload, err := json.Marshal(map[string]interface{}{
"termsOfServiceAgreed": true,
"contact": []string{"mailto:live-test@metacircular.net"},
"externalAccountBinding": json.RawMessage(eabJWS),
})
if err != nil {
t.Fatalf("marshal payload: %v", err)
}
header := map[string]interface{}{
"alg": "ES256",
"nonce": nonce,
"url": jwsAccountURL,
"jwk": json.RawMessage(jwk),
}
body := liveSignJWS(t, key, "ES256", header, payload)
// f) POST to new-account (use our reachable URL, not the JWS URL).
resp, err := env.client.Post(env.acmeURL("/new-account"), "application/jose+json", strings.NewReader(string(body)))
if err != nil {
t.Fatalf("POST new-account: %v", err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
// g) Verify response.
if resp.StatusCode != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", resp.StatusCode, respBody)
}
location := resp.Header.Get("Location")
if location == "" {
t.Fatal("Location header missing from new-account response")
}
t.Logf("account created: %s", location)
var acctResp map[string]interface{}
if err := json.Unmarshal(respBody, &acctResp); err != nil {
t.Fatalf("decode account response: %v", err)
}
status, _ := acctResp["status"].(string)
if status != "valid" {
t.Errorf("expected account status 'valid', got %q", status)
}
// Verify the EAB is now consumed — re-using it should fail.
nonce2 := env.getNonce()
eabJWS2 := liveSignEAB(t, kid, hmacKey, jwk, jwsAccountURL)
key2, jwk2 := env.generateKey()
payload2, _ := json.Marshal(map[string]interface{}{
"termsOfServiceAgreed": true,
"contact": []string{"mailto:live-test-2@metacircular.net"},
"externalAccountBinding": json.RawMessage(eabJWS2),
})
header2 := map[string]interface{}{
"alg": "ES256",
"nonce": nonce2,
"url": jwsAccountURL,
"jwk": json.RawMessage(jwk2),
}
body2 := liveSignJWS(t, key2, "ES256", header2, payload2)
resp2, err := env.client.Post(env.acmeURL("/new-account"), "application/jose+json", strings.NewReader(string(body2)))
if err != nil {
t.Fatalf("POST new-account (reuse EAB): %v", err)
}
defer resp2.Body.Close()
if resp2.StatusCode == http.StatusCreated {
t.Error("expected EAB reuse to be rejected, but got 201")
}
}
func TestLiveNewOrder(t *testing.T) {
skipIfNotLive(t)
env := newLiveEnv(t)
env.ensureAccount()
orderPayload, err := json.Marshal(map[string]interface{}{
"identifiers": []map[string]string{
{"type": "dns", "value": "live-test.svc.mcp.metacircular.net"},
},
})
if err != nil {
t.Fatalf("marshal order payload: %v", err)
}
resp, respBody := env.postSigned(env.acmeURL("/new-order"), env.jwsURL("/new-order"), orderPayload)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", resp.StatusCode, respBody)
}
var order map[string]interface{}
if err := json.Unmarshal(respBody, &order); err != nil {
t.Fatalf("decode order: %v", err)
}
status, _ := order["status"].(string)
if status != "pending" {
t.Errorf("expected order status 'pending', got %q", status)
}
identifiers, ok := order["identifiers"].([]interface{})
if !ok || len(identifiers) == 0 {
t.Fatal("order missing identifiers")
}
firstID, _ := identifiers[0].(map[string]interface{})
if idType, _ := firstID["type"].(string); idType != "dns" {
t.Errorf("expected identifier type 'dns', got %q", idType)
}
if idVal, _ := firstID["value"].(string); idVal != "live-test.svc.mcp.metacircular.net" {
t.Errorf("expected identifier value 'live-test.svc.mcp.metacircular.net', got %q", idVal)
}
authzURLs, ok := order["authorizations"].([]interface{})
if !ok || len(authzURLs) == 0 {
t.Fatal("order missing authorizations")
}
t.Logf("order created with %d authorization(s)", len(authzURLs))
location := resp.Header.Get("Location")
if location == "" {
t.Error("order response missing Location header")
}
}
func TestLiveGetAuthz(t *testing.T) {
skipIfNotLive(t)
env := newLiveEnv(t)
env.ensureAccount()
// Create an order to get an authorization URL.
orderPayload, _ := json.Marshal(map[string]interface{}{
"identifiers": []map[string]string{
{"type": "dns", "value": "live-authz-test.svc.mcp.metacircular.net"},
},
})
resp, respBody := env.postSigned(env.acmeURL("/new-order"), env.jwsURL("/new-order"), orderPayload)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("create order: status %d: %s", resp.StatusCode, respBody)
}
var order map[string]interface{}
if err := json.Unmarshal(respBody, &order); err != nil {
t.Fatalf("decode order: %v", err)
}
authzURLs, ok := order["authorizations"].([]interface{})
if !ok || len(authzURLs) == 0 {
t.Fatal("order has no authorizations")
}
authzURL, _ := authzURLs[0].(string)
if authzURL == "" {
t.Fatal("authorization URL is empty")
}
// POST-as-GET to the authorization URL.
// authzURL uses the server's base URL; translate for HTTP request.
reachableAuthzURL := strings.Replace(authzURL, env.serverBaseURL, env.acmeBaseURL, 1)
authzResp, authzBody := env.postAsGet(reachableAuthzURL, authzURL)
if authzResp.StatusCode != http.StatusOK {
t.Fatalf("GET authz: status %d: %s", authzResp.StatusCode, authzBody)
}
var authz map[string]interface{}
if err := json.Unmarshal(authzBody, &authz); err != nil {
t.Fatalf("decode authz: %v", err)
}
authzStatus, _ := authz["status"].(string)
if authzStatus != "pending" {
t.Errorf("expected authz status 'pending', got %q", authzStatus)
}
identifier, _ := authz["identifier"].(map[string]interface{})
if idType, _ := identifier["type"].(string); idType != "dns" {
t.Errorf("expected identifier type 'dns', got %q", idType)
}
if idVal, _ := identifier["value"].(string); idVal != "live-authz-test.svc.mcp.metacircular.net" {
t.Errorf("expected identifier value 'live-authz-test.svc.mcp.metacircular.net', got %q", idVal)
}
challenges, ok := authz["challenges"].([]interface{})
if !ok || len(challenges) == 0 {
t.Fatal("authz has no challenges")
}
// Verify we have http-01 and dns-01 challenge types.
challengeTypes := make(map[string]bool)
for _, c := range challenges {
ch, _ := c.(map[string]interface{})
cType, _ := ch["type"].(string)
challengeTypes[cType] = true
// Each challenge should have a token and status.
token, _ := ch["token"].(string)
if token == "" {
t.Errorf("challenge %s missing token", cType)
}
cStatus, _ := ch["status"].(string)
if cStatus == "" {
t.Errorf("challenge %s missing status", cType)
}
cURL, _ := ch["url"].(string)
if cURL == "" {
t.Errorf("challenge %s missing url", cType)
}
}
if !challengeTypes["http-01"] {
t.Error("expected http-01 challenge type, not found")
}
if !challengeTypes["dns-01"] {
t.Error("expected dns-01 challenge type, not found")
}
t.Logf("authorization has %d challenge(s): %v", len(challenges), challengeTypesList(challengeTypes))
}
func TestLiveFullFlow(t *testing.T) {
skipIfNotLive(t)
if os.Getenv("METACRYPT_LIVE_DNS_TEST") != "1" {
t.Skip("set METACRYPT_LIVE_DNS_TEST=1 to run full ACME flow " +
"(requires programmatic DNS updates to CoreDNS zones)")
}
// TODO: Implement full ACME flow once CoreDNS zone update automation
// is in place. The flow would be:
// 1. Create account
// 2. Create order
// 3. Get authorization + challenge
// 4. Provision DNS-01 TXT record via CoreDNS API
// 5. Respond to challenge
// 6. Poll authorization until valid
// 7. Finalize order with CSR
// 8. Download certificate
// 9. Verify certificate chain
t.Fatal("not implemented")
}
// ---------------------------------------------------------------------------
// Utility
// ---------------------------------------------------------------------------
// challengeTypesList extracts the keys from a map for logging.
func challengeTypesList(m map[string]bool) string {
var types []string
for k := range m {
types = append(types, k)
}
return fmt.Sprintf("[%s]", strings.Join(types, ", "))
}