From 56b5bae1f64c883c4cc43f8aea080f53fc79561d Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Wed, 25 Mar 2026 21:50:44 -0700 Subject: [PATCH] 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) --- internal/acme/live_test.go | 851 +++++++++++++++++++++++++++++++++++++ 1 file changed, 851 insertions(+) create mode 100644 internal/acme/live_test.go diff --git a/internal/acme/live_test.go b/internal/acme/live_test.go new file mode 100644 index 0000000..16f9bfe --- /dev/null +++ b/internal/acme/live_test.go @@ -0,0 +1,851 @@ +//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, ", ")) +}