| 1 | package edge |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "sort" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | |
| 9 | "github.com/aws/aws-sdk-go-v2/aws" |
| 10 | "github.com/aws/aws-sdk-go-v2/service/route53" |
| 11 | r53types "github.com/aws/aws-sdk-go-v2/service/route53/types" |
| 12 | ) |
| 13 | |
| 14 | // fakeRoute53 satisfies route53API and records the record-set changes. |
| 15 | type fakeRoute53 struct { |
| 16 | zones map[string]string // zone name (with trailing dot) → id |
| 17 | changes []string // "ACTION|name|value" |
| 18 | } |
| 19 | |
| 20 | // route53SortKey mirrors the real ListHostedZonesByName ordering: zone names |
| 21 | // are compared with their dot-separated labels reversed (so "tito.io." |
| 22 | // becomes "io.tito"), which groups zones under shared parents together. |
| 23 | func route53SortKey(name string) string { |
| 24 | labels := strings.Split(strings.ToLower(strings.TrimSuffix(name, ".")), ".") |
| 25 | rev := make([]string, len(labels)) |
| 26 | for i, l := range labels { |
| 27 | rev[len(labels)-1-i] = l |
| 28 | } |
| 29 | return strings.Join(rev, ".") |
| 30 | } |
| 31 | |
| 32 | // ListHostedZonesByName models the real API's contract: DNSName is not a |
| 33 | // filter, it is the start point of a listing sorted by route53SortKey. Only |
| 34 | // zones whose sort key is >= the start key are returned — a real, and |
| 35 | // initially unnoticed, source of the Critical bug this fake now exists to |
| 36 | // catch: a parent zone of the query fqdn sorts BEFORE that fqdn's own key |
| 37 | // and is silently absent from a listing started at the fqdn itself. |
| 38 | func (f *fakeRoute53) ListHostedZonesByName(_ context.Context, in *route53.ListHostedZonesByNameInput, _ ...func(*route53.Options)) (*route53.ListHostedZonesByNameOutput, error) { |
| 39 | start := route53SortKey(aws.ToString(in.DNSName)) |
| 40 | type entry struct{ name, id, key string } |
| 41 | var entries []entry |
| 42 | for name, id := range f.zones { |
| 43 | entries = append(entries, entry{name: name, id: id, key: route53SortKey(name)}) |
| 44 | } |
| 45 | sort.Slice(entries, func(i, j int) bool { return entries[i].key < entries[j].key }) |
| 46 | out := &route53.ListHostedZonesByNameOutput{} |
| 47 | for _, e := range entries { |
| 48 | if e.key >= start { |
| 49 | out.HostedZones = append(out.HostedZones, r53types.HostedZone{Name: aws.String(e.name), Id: aws.String("/hostedzone/" + e.id)}) |
| 50 | } |
| 51 | } |
| 52 | return out, nil |
| 53 | } |
| 54 | |
| 55 | func (f *fakeRoute53) ChangeResourceRecordSets(_ context.Context, in *route53.ChangeResourceRecordSetsInput, _ ...func(*route53.Options)) (*route53.ChangeResourceRecordSetsOutput, error) { |
| 56 | for _, c := range in.ChangeBatch.Changes { |
| 57 | f.changes = append(f.changes, string(c.Action)+"|"+aws.ToString(c.ResourceRecordSet.Name)+"|"+aws.ToString(c.ResourceRecordSet.ResourceRecords[0].Value)) |
| 58 | } |
| 59 | return &route53.ChangeResourceRecordSetsOutput{ChangeInfo: &r53types.ChangeInfo{Id: aws.String("c1"), Status: r53types.ChangeStatusInsync}}, nil |
| 60 | } |
| 61 | |
| 62 | func (f *fakeRoute53) GetChange(_ context.Context, _ *route53.GetChangeInput, _ ...func(*route53.Options)) (*route53.GetChangeOutput, error) { |
| 63 | return &route53.GetChangeOutput{ChangeInfo: &r53types.ChangeInfo{Status: r53types.ChangeStatusInsync}}, nil |
| 64 | } |
| 65 | |
| 66 | // The fqdn seam: the backend must find the zone that is a parent of the |
| 67 | // record fqdn — tito.io owns _acme-challenge.go.tito.io when go.tito.io has |
| 68 | // no zone of its own, and the value must be quoted (Route53 stores TXT |
| 69 | // values as quoted character-strings). "example.com." is an unrelated |
| 70 | // second zone in the account, present to prove the fake's DNSName |
| 71 | // start-point filtering (and the real code's exact-match walk) don't get |
| 72 | // tripped up by an unrelated zone sharing the account. |
| 73 | // |
| 74 | // This fixture is also the regression case for the Critical bug: tito.io.'s |
| 75 | // route53SortKey ("io.tito") sorts BEFORE _acme-challenge.go.tito.io.'s own |
| 76 | // key ("io.tito.go._acme-challenge"), so the old implementation — which |
| 77 | // called ListHostedZonesByName with the full fqdn as DNSName and then |
| 78 | // suffix-matched the results — never saw tito.io. in the listing and always |
| 79 | // returned "no hosted zone" here. |
| 80 | func TestRoute53PresentUpsertsAndCleansUp(t *testing.T) { |
| 81 | fake := &fakeRoute53{zones: map[string]string{"tito.io.": "Z1", "example.com.": "Z9"}} |
| 82 | r := &route53DNS{client: fake, zoneIDs: map[string]string{}} |
| 83 | |
| 84 | cleanup, err := r.present(context.Background(), "_acme-challenge.go.tito.io", "txtvalue") |
| 85 | if err != nil { |
| 86 | t.Fatalf("present: %v", err) |
| 87 | } |
| 88 | if len(fake.changes) != 1 || fake.changes[0] != `UPSERT|_acme-challenge.go.tito.io.|"txtvalue"` { |
| 89 | t.Fatalf("changes after present = %v", fake.changes) |
| 90 | } |
| 91 | if err := cleanup(context.Background()); err != nil { |
| 92 | t.Fatalf("cleanup: %v", err) |
| 93 | } |
| 94 | if len(fake.changes) != 2 || !strings.HasPrefix(fake.changes[1], "DELETE|_acme-challenge.go.tito.io.|") { |
| 95 | t.Fatalf("changes after cleanup = %v", fake.changes) |
| 96 | } |
| 97 | if got := r.zoneIDs["_acme-challenge.go.tito.io"]; got != "Z1" { |
| 98 | t.Fatalf("cached zone id = %q, want Z1 (parent zone tito.io.)", got) |
| 99 | } |
| 100 | } |
| 101 | |
| 102 | // TestRoute53PrefersMostSpecificZone covers the case where the challenge |
| 103 | // fqdn's immediate parent (go.tito.io.) has its own hosted zone alongside |
| 104 | // the grandparent (tito.io.) — the more specific zone must win, because the |
| 105 | // candidate walk tries go.tito.io. before tito.io. and returns on first hit. |
| 106 | func TestRoute53PrefersMostSpecificZone(t *testing.T) { |
| 107 | fake := &fakeRoute53{zones: map[string]string{"tito.io.": "Z1", "go.tito.io.": "Z2"}} |
| 108 | r := &route53DNS{client: fake, zoneIDs: map[string]string{}} |
| 109 | |
| 110 | if _, err := r.present(context.Background(), "_acme-challenge.go.tito.io", "txtvalue"); err != nil { |
| 111 | t.Fatalf("present: %v", err) |
| 112 | } |
| 113 | if got := r.zoneIDs["_acme-challenge.go.tito.io"]; got != "Z2" { |
| 114 | t.Fatalf("cached zone id = %q, want Z2 (most specific parent zone go.tito.io.)", got) |
| 115 | } |
| 116 | } |
| 117 | |
| 118 | func TestRoute53NoZone(t *testing.T) { |
| 119 | fake := &fakeRoute53{zones: map[string]string{"example.org.": "Z9"}} |
| 120 | r := &route53DNS{client: fake, zoneIDs: map[string]string{}} |
| 121 | if _, err := r.present(context.Background(), "_acme-challenge.go.tito.io", "v"); err == nil { |
| 122 | t.Fatal("present with no owning zone: want error, got nil") |
| 123 | } |
| 124 | } |
| 125 | |