package edge import ( "context" "sort" "strings" "testing" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/route53" r53types "github.com/aws/aws-sdk-go-v2/service/route53/types" ) // fakeRoute53 satisfies route53API and records the record-set changes. type fakeRoute53 struct { zones map[string]string // zone name (with trailing dot) → id changes []string // "ACTION|name|value" } // route53SortKey mirrors the real ListHostedZonesByName ordering: zone names // are compared with their dot-separated labels reversed (so "tito.io." // becomes "io.tito"), which groups zones under shared parents together. func route53SortKey(name string) string { labels := strings.Split(strings.ToLower(strings.TrimSuffix(name, ".")), ".") rev := make([]string, len(labels)) for i, l := range labels { rev[len(labels)-1-i] = l } return strings.Join(rev, ".") } // ListHostedZonesByName models the real API's contract: DNSName is not a // filter, it is the start point of a listing sorted by route53SortKey. Only // zones whose sort key is >= the start key are returned — a real, and // initially unnoticed, source of the Critical bug this fake now exists to // catch: a parent zone of the query fqdn sorts BEFORE that fqdn's own key // and is silently absent from a listing started at the fqdn itself. func (f *fakeRoute53) ListHostedZonesByName(_ context.Context, in *route53.ListHostedZonesByNameInput, _ ...func(*route53.Options)) (*route53.ListHostedZonesByNameOutput, error) { start := route53SortKey(aws.ToString(in.DNSName)) type entry struct{ name, id, key string } var entries []entry for name, id := range f.zones { entries = append(entries, entry{name: name, id: id, key: route53SortKey(name)}) } sort.Slice(entries, func(i, j int) bool { return entries[i].key < entries[j].key }) out := &route53.ListHostedZonesByNameOutput{} for _, e := range entries { if e.key >= start { out.HostedZones = append(out.HostedZones, r53types.HostedZone{Name: aws.String(e.name), Id: aws.String("/hostedzone/" + e.id)}) } } return out, nil } func (f *fakeRoute53) ChangeResourceRecordSets(_ context.Context, in *route53.ChangeResourceRecordSetsInput, _ ...func(*route53.Options)) (*route53.ChangeResourceRecordSetsOutput, error) { for _, c := range in.ChangeBatch.Changes { f.changes = append(f.changes, string(c.Action)+"|"+aws.ToString(c.ResourceRecordSet.Name)+"|"+aws.ToString(c.ResourceRecordSet.ResourceRecords[0].Value)) } return &route53.ChangeResourceRecordSetsOutput{ChangeInfo: &r53types.ChangeInfo{Id: aws.String("c1"), Status: r53types.ChangeStatusInsync}}, nil } func (f *fakeRoute53) GetChange(_ context.Context, _ *route53.GetChangeInput, _ ...func(*route53.Options)) (*route53.GetChangeOutput, error) { return &route53.GetChangeOutput{ChangeInfo: &r53types.ChangeInfo{Status: r53types.ChangeStatusInsync}}, nil } // The fqdn seam: the backend must find the zone that is a parent of the // record fqdn — tito.io owns _acme-challenge.go.tito.io when go.tito.io has // no zone of its own, and the value must be quoted (Route53 stores TXT // values as quoted character-strings). "example.com." is an unrelated // second zone in the account, present to prove the fake's DNSName // start-point filtering (and the real code's exact-match walk) don't get // tripped up by an unrelated zone sharing the account. // // This fixture is also the regression case for the Critical bug: tito.io.'s // route53SortKey ("io.tito") sorts BEFORE _acme-challenge.go.tito.io.'s own // key ("io.tito.go._acme-challenge"), so the old implementation — which // called ListHostedZonesByName with the full fqdn as DNSName and then // suffix-matched the results — never saw tito.io. in the listing and always // returned "no hosted zone" here. func TestRoute53PresentUpsertsAndCleansUp(t *testing.T) { fake := &fakeRoute53{zones: map[string]string{"tito.io.": "Z1", "example.com.": "Z9"}} r := &route53DNS{client: fake, zoneIDs: map[string]string{}} cleanup, err := r.present(context.Background(), "_acme-challenge.go.tito.io", "txtvalue") if err != nil { t.Fatalf("present: %v", err) } if len(fake.changes) != 1 || fake.changes[0] != `UPSERT|_acme-challenge.go.tito.io.|"txtvalue"` { t.Fatalf("changes after present = %v", fake.changes) } if err := cleanup(context.Background()); err != nil { t.Fatalf("cleanup: %v", err) } if len(fake.changes) != 2 || !strings.HasPrefix(fake.changes[1], "DELETE|_acme-challenge.go.tito.io.|") { t.Fatalf("changes after cleanup = %v", fake.changes) } if got := r.zoneIDs["_acme-challenge.go.tito.io"]; got != "Z1" { t.Fatalf("cached zone id = %q, want Z1 (parent zone tito.io.)", got) } } // TestRoute53PrefersMostSpecificZone covers the case where the challenge // fqdn's immediate parent (go.tito.io.) has its own hosted zone alongside // the grandparent (tito.io.) — the more specific zone must win, because the // candidate walk tries go.tito.io. before tito.io. and returns on first hit. func TestRoute53PrefersMostSpecificZone(t *testing.T) { fake := &fakeRoute53{zones: map[string]string{"tito.io.": "Z1", "go.tito.io.": "Z2"}} r := &route53DNS{client: fake, zoneIDs: map[string]string{}} if _, err := r.present(context.Background(), "_acme-challenge.go.tito.io", "txtvalue"); err != nil { t.Fatalf("present: %v", err) } if got := r.zoneIDs["_acme-challenge.go.tito.io"]; got != "Z2" { t.Fatalf("cached zone id = %q, want Z2 (most specific parent zone go.tito.io.)", got) } } func TestRoute53NoZone(t *testing.T) { fake := &fakeRoute53{zones: map[string]string{"example.org.": "Z9"}} r := &route53DNS{client: fake, zoneIDs: map[string]string{}} if _, err := r.present(context.Background(), "_acme-challenge.go.tito.io", "v"); err == nil { t.Fatal("present with no owning zone: want error, got nil") } }