package edge import ( "context" "errors" "fmt" "net/http" "net/http/httptest" "sync/atomic" "testing" "time" "golang.org/x/crypto/acme/autocert" ) // zoneCheckingDNS is fakeDNS plus a zone check that answers from a fixed // set of challenge names it can publish. type zoneCheckingDNS struct { fakeDNS writable map[string]bool checked []string } func (z *zoneCheckingDNS) checkZone(_ context.Context, fqdn string) error { z.checked = append(z.checked, fqdn) if z.writable[fqdn] { return nil } return fmt.Errorf("fake: no zone found for %s: %w", fqdn, errNoZone) } // countingCA counts every request a manager makes to its ACME directory. func countingCA(t *testing.T) (*httptest.Server, *atomic.Int32) { t.Helper() var n atomic.Int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { n.Add(1) http.NotFound(w, r) // a 4xx: the ACME client retries 5xx, and this must fail fast })) t.Cleanup(srv.Close) return srv, &n } // A parent whose challenge zone the provider does not hold never reaches the // CA: no account, no order, and the error says why (errNoZone). func TestObtainChecksTheZoneBeforeOrdering(t *testing.T) { ca, requests := countingCA(t) dns := &zoneCheckingDNS{} w := newWildcardManager(nil, "carlos.exciting.io", func() []string { return nil }, nil, "", autocert.DirCache(t.TempDir()), dns) w.directory = ca.URL err := w.obtain(context.Background(), "bad.carlos.exciting.io") if !errors.Is(err, errNoZone) { t.Fatalf("obtain = %v, want an errNoZone", err) } if got := requests.Load(); got != 0 { t.Fatalf("the CA saw %d request(s) for a parent with no zone, want 0", got) } if len(dns.checked) != 1 || dns.checked[0] != "_acme-challenge.bad.carlos.exciting.io" { t.Fatalf("checked %v, want the parent's own challenge name", dns.checked) } if len(dns.presented) != 0 { t.Fatalf("presented %v, want nothing", dns.presented) } } // A writable zone passes the check and the order goes ahead (here, to a CA // that refuses — what matters is that it was asked). func TestObtainOrdersWhenTheZoneIsThere(t *testing.T) { ca, requests := countingCA(t) dns := &zoneCheckingDNS{writable: map[string]bool{"_acme-challenge.letsjelly.dev": true}} w := newWildcardManager([]string{"letsjelly.dev"}, "", func() []string { return nil }, nil, "", autocert.DirCache(t.TempDir()), dns) w.directory = ca.URL ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := w.obtain(ctx, "letsjelly.dev"); err == nil || errors.Is(err, errNoZone) { t.Fatalf("obtain = %v, want the CA's refusal", err) } if requests.Load() == 0 { t.Fatal("the CA was never asked for a parent whose zone is writable") } } // A no-zone parent backs off for noZoneRetry rather than the hour a // transient failure gets, and the pass asks to run again no sooner. func TestPassBacksOffANoZoneParentLonger(t *testing.T) { ca, requests := countingCA(t) w := newWildcardManager(nil, "carlos.exciting.io", func() []string { return nil }, nil, "", autocert.DirCache(t.TempDir()), &zoneCheckingDNS{}) w.directory = ca.URL w.mu.Lock() w.parents = []string{"bad.carlos.exciting.io"} w.mu.Unlock() start := time.Now() if delay := w.pass(); delay != noZoneRetry { t.Fatalf("pass delay = %s, want %s", delay, noZoneRetry) } w.mu.Lock() next := w.nextAttempt["bad.carlos.exciting.io"] w.mu.Unlock() if next.Before(start.Add(noZoneRetry - time.Minute)) { t.Fatalf("nextAttempt = %s, want about %s from now", next.Sub(start), noZoneRetry) } if requests.Load() != 0 { t.Fatal("the pass reached the CA for a parent with no zone") } }