package main import ( "context" "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 stands in for the Route53 client so the solver's wiring is tested // without reaching AWS. It records every ChangeResourceRecordSets call and // answers GetChange as immediately INSYNC. type fakeRoute53 struct { hostedZones []r53types.HostedZone changes []*route53.ChangeResourceRecordSetsInput getChangeN int } func (f *fakeRoute53) ListHostedZonesByName(_ context.Context, _ *route53.ListHostedZonesByNameInput, _ ...func(*route53.Options)) (*route53.ListHostedZonesByNameOutput, error) { return &route53.ListHostedZonesByNameOutput{HostedZones: f.hostedZones}, nil } func (f *fakeRoute53) ChangeResourceRecordSets(_ context.Context, in *route53.ChangeResourceRecordSetsInput, _ ...func(*route53.Options)) (*route53.ChangeResourceRecordSetsOutput, error) { f.changes = append(f.changes, in) return &route53.ChangeResourceRecordSetsOutput{ChangeInfo: &r53types.ChangeInfo{Id: aws.String("/change/C123")}}, nil } func (f *fakeRoute53) GetChange(_ context.Context, _ *route53.GetChangeInput, _ ...func(*route53.Options)) (*route53.GetChangeOutput, error) { f.getChangeN++ return &route53.GetChangeOutput{ChangeInfo: &r53types.ChangeInfo{Status: r53types.ChangeStatusInsync}}, nil } func TestRoute53Present(t *testing.T) { fake := &fakeRoute53{hostedZones: []r53types.HostedZone{ {Name: aws.String("11ch.at."), Id: aws.String("/hostedzone/ZONEID")}, }} r := &route53DNS{client: fake, zoneIDs: map[string]string{}} cleanup, err := r.present(context.Background(), "11ch.at", "_acme-challenge", "tok-value") if err != nil { t.Fatal(err) } if len(fake.changes) != 1 { t.Fatalf("want 1 change on present, got %d", len(fake.changes)) } up := fake.changes[0] if got := aws.ToString(up.HostedZoneId); got != "ZONEID" { t.Fatalf("hosted zone id = %q, want ZONEID (prefix stripped)", got) } ch := up.ChangeBatch.Changes[0] if ch.Action != r53types.ChangeActionUpsert { t.Fatalf("action = %v, want UPSERT", ch.Action) } rr := ch.ResourceRecordSet if got := aws.ToString(rr.Name); got != "_acme-challenge.11ch.at." { t.Fatalf("record name = %q", got) } if rr.Type != r53types.RRTypeTxt { t.Fatalf("record type = %v, want TXT", rr.Type) } if got := aws.ToInt64(rr.TTL); got != 60 { t.Fatalf("ttl = %d, want 60", got) } // TXT values must be quoted character-strings. if got := aws.ToString(rr.ResourceRecords[0].Value); got != `"tok-value"` { t.Fatalf("record value = %q, want quoted", got) } if fake.getChangeN == 0 { t.Fatal("present did not wait for the change to go INSYNC") } // cleanup DELETEs the exact same record set. if err := cleanup(context.Background()); err != nil { t.Fatal(err) } if len(fake.changes) != 2 { t.Fatalf("want 2 changes after cleanup, got %d", len(fake.changes)) } del := fake.changes[1].ChangeBatch.Changes[0] if del.Action != r53types.ChangeActionDelete { t.Fatalf("cleanup action = %v, want DELETE", del.Action) } if got := aws.ToString(del.ResourceRecordSet.Name); got != "_acme-challenge.11ch.at." { t.Fatalf("cleanup record name = %q", got) } if got := aws.ToString(del.ResourceRecordSet.ResourceRecords[0].Value); got != `"tok-value"` { t.Fatalf("cleanup record value = %q, want the created value", got) } } // upsertA (the optional relocation A-record move) UPSERTs an A record at the // host's FQDN with a short TTL and waits for INSYNC. func TestRoute53UpsertA(t *testing.T) { fake := &fakeRoute53{hostedZones: []r53types.HostedZone{ {Name: aws.String("dev.11ch.at."), Id: aws.String("/hostedzone/DEV")}, }} r := &route53DNS{client: fake, zoneIDs: map[string]string{}} if err := r.upsertA(context.Background(), "dev.11ch.at", "8oe.dev.11ch.at", "157.180.123.183"); err != nil { t.Fatal(err) } if len(fake.changes) != 1 { t.Fatalf("want 1 change, got %d", len(fake.changes)) } ch := fake.changes[0].ChangeBatch.Changes[0] if ch.Action != r53types.ChangeActionUpsert { t.Fatalf("action = %v, want UPSERT (idempotent re-run)", ch.Action) } rr := ch.ResourceRecordSet if got := aws.ToString(rr.Name); got != "8oe.dev.11ch.at." { t.Fatalf("record name = %q, want the host FQDN with trailing dot", got) } if rr.Type != r53types.RRTypeA { t.Fatalf("record type = %v, want A", rr.Type) } if got := aws.ToInt64(rr.TTL); got != 60 { t.Fatalf("ttl = %d, want 60 (short — the proxy covers the window)", got) } if got := aws.ToString(rr.ResourceRecords[0].Value); got != "157.180.123.183" { t.Fatalf("record value = %q, want the target IP (unquoted — A, not TXT)", got) } if fake.getChangeN == 0 { t.Fatal("upsertA did not wait for INSYNC") } } func TestRoute53HostedZoneMatch(t *testing.T) { // Both the parent and the delegated sub-zone exist: the record for the // sub-domain must resolve to the most specific (longest) match. fake := &fakeRoute53{hostedZones: []r53types.HostedZone{ {Name: aws.String("11ch.at."), Id: aws.String("/hostedzone/PARENT")}, {Name: aws.String("dev.11ch.at."), Id: aws.String("/hostedzone/DEV")}, }} r := &route53DNS{client: fake, zoneIDs: map[string]string{}} id, err := r.hostedZoneID(context.Background(), "dev.11ch.at") if err != nil { t.Fatal(err) } if id != "DEV" { t.Fatalf("zone id = %q, want DEV (most specific)", id) } // Cached: a second lookup reuses the id (still correct after caching). if id2, _ := r.hostedZoneID(context.Background(), "dev.11ch.at"); id2 != "DEV" { t.Fatalf("cached zone id = %q, want DEV", id2) } // No authoritative zone for the name → a surfaced error, not a silent miss. none := &route53DNS{client: &fakeRoute53{hostedZones: []r53types.HostedZone{ {Name: aws.String("other.example."), Id: aws.String("/hostedzone/X")}, }}, zoneIDs: map[string]string{}} if _, err := none.hostedZoneID(context.Background(), "11ch.at"); err == nil { t.Fatal("want error when no hosted zone is authoritative") } }