eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3import (
4 "context"
5 "testing"
6
7 "github.com/aws/aws-sdk-go-v2/aws"
8 "github.com/aws/aws-sdk-go-v2/service/route53"
9 r53types "github.com/aws/aws-sdk-go-v2/service/route53/types"
10)
11
12// fakeRoute53 stands in for the Route53 client so the solver's wiring is tested
13// without reaching AWS. It records every ChangeResourceRecordSets call and
14// answers GetChange as immediately INSYNC.
15type fakeRoute53 struct {
16 hostedZones []r53types.HostedZone
17 changes []*route53.ChangeResourceRecordSetsInput
18 getChangeN int
19}
20
21func (f *fakeRoute53) ListHostedZonesByName(_ context.Context, _ *route53.ListHostedZonesByNameInput, _ ...func(*route53.Options)) (*route53.ListHostedZonesByNameOutput, error) {
22 return &route53.ListHostedZonesByNameOutput{HostedZones: f.hostedZones}, nil
23}
24
25func (f *fakeRoute53) ChangeResourceRecordSets(_ context.Context, in *route53.ChangeResourceRecordSetsInput, _ ...func(*route53.Options)) (*route53.ChangeResourceRecordSetsOutput, error) {
26 f.changes = append(f.changes, in)
27 return &route53.ChangeResourceRecordSetsOutput{ChangeInfo: &r53types.ChangeInfo{Id: aws.String("/change/C123")}}, nil
28}
29
30func (f *fakeRoute53) GetChange(_ context.Context, _ *route53.GetChangeInput, _ ...func(*route53.Options)) (*route53.GetChangeOutput, error) {
31 f.getChangeN++
32 return &route53.GetChangeOutput{ChangeInfo: &r53types.ChangeInfo{Status: r53types.ChangeStatusInsync}}, nil
33}
34
35func TestRoute53Present(t *testing.T) {
36 fake := &fakeRoute53{hostedZones: []r53types.HostedZone{
37 {Name: aws.String("11ch.at."), Id: aws.String("/hostedzone/ZONEID")},
38 }}
39 r := &route53DNS{client: fake, zoneIDs: map[string]string{}}
40
41 cleanup, err := r.present(context.Background(), "11ch.at", "_acme-challenge", "tok-value")
42 if err != nil {
43 t.Fatal(err)
44 }
45 if len(fake.changes) != 1 {
46 t.Fatalf("want 1 change on present, got %d", len(fake.changes))
47 }
48 up := fake.changes[0]
49 if got := aws.ToString(up.HostedZoneId); got != "ZONEID" {
50 t.Fatalf("hosted zone id = %q, want ZONEID (prefix stripped)", got)
51 }
52 ch := up.ChangeBatch.Changes[0]
53 if ch.Action != r53types.ChangeActionUpsert {
54 t.Fatalf("action = %v, want UPSERT", ch.Action)
55 }
56 rr := ch.ResourceRecordSet
57 if got := aws.ToString(rr.Name); got != "_acme-challenge.11ch.at." {
58 t.Fatalf("record name = %q", got)
59 }
60 if rr.Type != r53types.RRTypeTxt {
61 t.Fatalf("record type = %v, want TXT", rr.Type)
62 }
63 if got := aws.ToInt64(rr.TTL); got != 60 {
64 t.Fatalf("ttl = %d, want 60", got)
65 }
66 // TXT values must be quoted character-strings.
67 if got := aws.ToString(rr.ResourceRecords[0].Value); got != `"tok-value"` {
68 t.Fatalf("record value = %q, want quoted", got)
69 }
70 if fake.getChangeN == 0 {
71 t.Fatal("present did not wait for the change to go INSYNC")
72 }
73
74 // cleanup DELETEs the exact same record set.
75 if err := cleanup(context.Background()); err != nil {
76 t.Fatal(err)
77 }
78 if len(fake.changes) != 2 {
79 t.Fatalf("want 2 changes after cleanup, got %d", len(fake.changes))
80 }
81 del := fake.changes[1].ChangeBatch.Changes[0]
82 if del.Action != r53types.ChangeActionDelete {
83 t.Fatalf("cleanup action = %v, want DELETE", del.Action)
84 }
85 if got := aws.ToString(del.ResourceRecordSet.Name); got != "_acme-challenge.11ch.at." {
86 t.Fatalf("cleanup record name = %q", got)
87 }
88 if got := aws.ToString(del.ResourceRecordSet.ResourceRecords[0].Value); got != `"tok-value"` {
89 t.Fatalf("cleanup record value = %q, want the created value", got)
90 }
91}
92
93// upsertA (the optional relocation A-record move) UPSERTs an A record at the
94// host's FQDN with a short TTL and waits for INSYNC.
95func TestRoute53UpsertA(t *testing.T) {
96 fake := &fakeRoute53{hostedZones: []r53types.HostedZone{
97 {Name: aws.String("dev.11ch.at."), Id: aws.String("/hostedzone/DEV")},
98 }}
99 r := &route53DNS{client: fake, zoneIDs: map[string]string{}}
100
101 if err := r.upsertA(context.Background(), "dev.11ch.at", "8oe.dev.11ch.at", "157.180.123.183"); err != nil {
102 t.Fatal(err)
103 }
104 if len(fake.changes) != 1 {
105 t.Fatalf("want 1 change, got %d", len(fake.changes))
106 }
107 ch := fake.changes[0].ChangeBatch.Changes[0]
108 if ch.Action != r53types.ChangeActionUpsert {
109 t.Fatalf("action = %v, want UPSERT (idempotent re-run)", ch.Action)
110 }
111 rr := ch.ResourceRecordSet
112 if got := aws.ToString(rr.Name); got != "8oe.dev.11ch.at." {
113 t.Fatalf("record name = %q, want the host FQDN with trailing dot", got)
114 }
115 if rr.Type != r53types.RRTypeA {
116 t.Fatalf("record type = %v, want A", rr.Type)
117 }
118 if got := aws.ToInt64(rr.TTL); got != 60 {
119 t.Fatalf("ttl = %d, want 60 (short — the proxy covers the window)", got)
120 }
121 if got := aws.ToString(rr.ResourceRecords[0].Value); got != "157.180.123.183" {
122 t.Fatalf("record value = %q, want the target IP (unquoted — A, not TXT)", got)
123 }
124 if fake.getChangeN == 0 {
125 t.Fatal("upsertA did not wait for INSYNC")
126 }
127}
128
129func TestRoute53HostedZoneMatch(t *testing.T) {
130 // Both the parent and the delegated sub-zone exist: the record for the
131 // sub-domain must resolve to the most specific (longest) match.
132 fake := &fakeRoute53{hostedZones: []r53types.HostedZone{
133 {Name: aws.String("11ch.at."), Id: aws.String("/hostedzone/PARENT")},
134 {Name: aws.String("dev.11ch.at."), Id: aws.String("/hostedzone/DEV")},
135 }}
136 r := &route53DNS{client: fake, zoneIDs: map[string]string{}}
137 id, err := r.hostedZoneID(context.Background(), "dev.11ch.at")
138 if err != nil {
139 t.Fatal(err)
140 }
141 if id != "DEV" {
142 t.Fatalf("zone id = %q, want DEV (most specific)", id)
143 }
144 // Cached: a second lookup reuses the id (still correct after caching).
145 if id2, _ := r.hostedZoneID(context.Background(), "dev.11ch.at"); id2 != "DEV" {
146 t.Fatalf("cached zone id = %q, want DEV", id2)
147 }
148
149 // No authoritative zone for the name → a surfaced error, not a silent miss.
150 none := &route53DNS{client: &fakeRoute53{hostedZones: []r53types.HostedZone{
151 {Name: aws.String("other.example."), Id: aws.String("/hostedzone/X")},
152 }}, zoneIDs: map[string]string{}}
153 if _, err := none.hostedZoneID(context.Background(), "11ch.at"); err == nil {
154 t.Fatal("want error when no hosted zone is authoritative")
155 }
156}
157