| 1 | package edge |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "fmt" |
| 7 | "net/http" |
| 8 | "net/http/httptest" |
| 9 | "sync/atomic" |
| 10 | "testing" |
| 11 | "time" |
| 12 | |
| 13 | "golang.org/x/crypto/acme/autocert" |
| 14 | ) |
| 15 | |
| 16 | // zoneCheckingDNS is fakeDNS plus a zone check that answers from a fixed |
| 17 | // set of challenge names it can publish. |
| 18 | type zoneCheckingDNS struct { |
| 19 | fakeDNS |
| 20 | writable map[string]bool |
| 21 | checked []string |
| 22 | } |
| 23 | |
| 24 | func (z *zoneCheckingDNS) checkZone(_ context.Context, fqdn string) error { |
| 25 | z.checked = append(z.checked, fqdn) |
| 26 | if z.writable[fqdn] { |
| 27 | return nil |
| 28 | } |
| 29 | return fmt.Errorf("fake: no zone found for %s: %w", fqdn, errNoZone) |
| 30 | } |
| 31 | |
| 32 | // countingCA counts every request a manager makes to its ACME directory. |
| 33 | func countingCA(t *testing.T) (*httptest.Server, *atomic.Int32) { |
| 34 | t.Helper() |
| 35 | var n atomic.Int32 |
| 36 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 37 | n.Add(1) |
| 38 | http.NotFound(w, r) // a 4xx: the ACME client retries 5xx, and this must fail fast |
| 39 | })) |
| 40 | t.Cleanup(srv.Close) |
| 41 | return srv, &n |
| 42 | } |
| 43 | |
| 44 | // A parent whose challenge zone the provider does not hold never reaches the |
| 45 | // CA: no account, no order, and the error says why (errNoZone). |
| 46 | func TestObtainChecksTheZoneBeforeOrdering(t *testing.T) { |
| 47 | ca, requests := countingCA(t) |
| 48 | dns := &zoneCheckingDNS{} |
| 49 | w := newWildcardManager(nil, "carlos.exciting.io", func() []string { return nil }, |
| 50 | nil, "", autocert.DirCache(t.TempDir()), dns) |
| 51 | w.directory = ca.URL |
| 52 | err := w.obtain(context.Background(), "bad.carlos.exciting.io") |
| 53 | if !errors.Is(err, errNoZone) { |
| 54 | t.Fatalf("obtain = %v, want an errNoZone", err) |
| 55 | } |
| 56 | if got := requests.Load(); got != 0 { |
| 57 | t.Fatalf("the CA saw %d request(s) for a parent with no zone, want 0", got) |
| 58 | } |
| 59 | if len(dns.checked) != 1 || dns.checked[0] != "_acme-challenge.bad.carlos.exciting.io" { |
| 60 | t.Fatalf("checked %v, want the parent's own challenge name", dns.checked) |
| 61 | } |
| 62 | if len(dns.presented) != 0 { |
| 63 | t.Fatalf("presented %v, want nothing", dns.presented) |
| 64 | } |
| 65 | } |
| 66 | |
| 67 | // A writable zone passes the check and the order goes ahead (here, to a CA |
| 68 | // that refuses — what matters is that it was asked). |
| 69 | func TestObtainOrdersWhenTheZoneIsThere(t *testing.T) { |
| 70 | ca, requests := countingCA(t) |
| 71 | dns := &zoneCheckingDNS{writable: map[string]bool{"_acme-challenge.letsjelly.dev": true}} |
| 72 | w := newWildcardManager([]string{"letsjelly.dev"}, "", func() []string { return nil }, |
| 73 | nil, "", autocert.DirCache(t.TempDir()), dns) |
| 74 | w.directory = ca.URL |
| 75 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) |
| 76 | defer cancel() |
| 77 | if err := w.obtain(ctx, "letsjelly.dev"); err == nil || errors.Is(err, errNoZone) { |
| 78 | t.Fatalf("obtain = %v, want the CA's refusal", err) |
| 79 | } |
| 80 | if requests.Load() == 0 { |
| 81 | t.Fatal("the CA was never asked for a parent whose zone is writable") |
| 82 | } |
| 83 | } |
| 84 | |
| 85 | // A no-zone parent backs off for noZoneRetry rather than the hour a |
| 86 | // transient failure gets, and the pass asks to run again no sooner. |
| 87 | func TestPassBacksOffANoZoneParentLonger(t *testing.T) { |
| 88 | ca, requests := countingCA(t) |
| 89 | w := newWildcardManager(nil, "carlos.exciting.io", func() []string { return nil }, |
| 90 | nil, "", autocert.DirCache(t.TempDir()), &zoneCheckingDNS{}) |
| 91 | w.directory = ca.URL |
| 92 | w.mu.Lock() |
| 93 | w.parents = []string{"bad.carlos.exciting.io"} |
| 94 | w.mu.Unlock() |
| 95 | start := time.Now() |
| 96 | if delay := w.pass(); delay != noZoneRetry { |
| 97 | t.Fatalf("pass delay = %s, want %s", delay, noZoneRetry) |
| 98 | } |
| 99 | w.mu.Lock() |
| 100 | next := w.nextAttempt["bad.carlos.exciting.io"] |
| 101 | w.mu.Unlock() |
| 102 | if next.Before(start.Add(noZoneRetry - time.Minute)) { |
| 103 | t.Fatalf("nextAttempt = %s, want about %s from now", next.Sub(start), noZoneRetry) |
| 104 | } |
| 105 | if requests.Load() != 0 { |
| 106 | t.Fatal("the pass reached the CA for a parent with no zone") |
| 107 | } |
| 108 | } |
| 109 | |