1package acmelego
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "net/http"
8 "net/http/httptest"
9 "testing"
10
11 "github.com/stretchr/testify/assert"
12 "github.com/stretchr/testify/require"
13
14 "github.com/bnema/gordon/internal/boundaries/out"
15)
16
17func TestCloudflareZoneResolverFindsParentZone(t *testing.T) {
18 var requestCount int
19 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
20 requestCount++
21
22 // Verify auth header
23 assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
24
25 // Verify query parameters
26 name := r.URL.Query().Get("name")
27 status := r.URL.Query().Get("status")
28 assert.Equal(t, "active", status)
29
30 switch name {
31 case "api.prod.example.com":
32 // Return empty - no zone found
33 w.Header().Set("Content-Type", "application/json")
34 json.NewEncoder(w).Encode(zoneAPIResponse{
35 Success: true,
36 Result: []zoneAPIResult{},
37 })
38 case "prod.example.com":
39 // Return empty - no zone found
40 w.Header().Set("Content-Type", "application/json")
41 json.NewEncoder(w).Encode(zoneAPIResponse{
42 Success: true,
43 Result: []zoneAPIResult{},
44 })
45 case "example.com":
46 // Return active zone
47 w.Header().Set("Content-Type", "application/json")
48 json.NewEncoder(w).Encode(zoneAPIResponse{
49 Success: true,
50 Result: []zoneAPIResult{
51 {
52 ID: "zone-id-123",
53 Name: "example.com",
54 Status: "active",
55 },
56 },
57 })
58 default:
59 t.Errorf("unexpected zone name query: %s", name)
60 }
61 }))
62 defer server.Close()
63
64 resolver := NewCloudflareZoneResolver("test-token",
65 WithCloudflareBaseURL(server.URL))
66
67 zone, err := resolver.FindZone(context.Background(), "api.prod.example.com")
68 require.NoError(t, err)
69 assert.Equal(t, "zone-id-123", zone.ID)
70 assert.Equal(t, "example.com", zone.Name)
71 assert.Equal(t, 3, requestCount, "should have made 3 API calls")
72}
73
74func TestCloudflareZoneResolverDoesNotLeakTokenInError(t *testing.T) {
75 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
76 // Return a 500 error
77 w.WriteHeader(http.StatusInternalServerError)
78 w.Write([]byte(`{"success":false,"errors":[]}`))
79 }))
80 defer server.Close()
81
82 resolver := NewCloudflareZoneResolver("super-secret-token-abc123",
83 WithCloudflareBaseURL(server.URL))
84
85 _, err := resolver.FindZone(context.Background(), "example.com")
86 require.Error(t, err)
87 errStr := err.Error()
88
89 // The error must NOT contain the token
90 assert.NotContains(t, errStr, "super-secret-token-abc123")
91 assert.NotContains(t, errStr, "secret-token")
92}
93
94func TestCloudflareZoneResolverSimpleDomain(t *testing.T) {
95 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
96 name := r.URL.Query().Get("name")
97 assert.Equal(t, "example.com", name)
98
99 w.Header().Set("Content-Type", "application/json")
100 json.NewEncoder(w).Encode(zoneAPIResponse{
101 Success: true,
102 Result: []zoneAPIResult{
103 {
104 ID: "zone-1",
105 Name: "example.com",
106 Status: "active",
107 },
108 },
109 })
110 }))
111 defer server.Close()
112
113 resolver := NewCloudflareZoneResolver("test-token",
114 WithCloudflareBaseURL(server.URL))
115
116 zone, err := resolver.FindZone(context.Background(), "example.com")
117 require.NoError(t, err)
118 assert.Equal(t, "zone-1", zone.ID)
119 assert.Equal(t, "example.com", zone.Name)
120}
121
122func TestCloudflareZoneResolverNoZoneFound(t *testing.T) {
123 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
124 w.Header().Set("Content-Type", "application/json")
125 json.NewEncoder(w).Encode(zoneAPIResponse{
126 Success: true,
127 Result: []zoneAPIResult{},
128 })
129 }))
130 defer server.Close()
131
132 resolver := NewCloudflareZoneResolver("test-token",
133 WithCloudflareBaseURL(server.URL))
134
135 _, err := resolver.FindZone(context.Background(), "missing.example.com")
136 require.Error(t, err)
137 assert.Contains(t, err.Error(), "no active zone found")
138}
139
140func TestCloudflareZoneResolverCachesErrorsWithShortTTL(t *testing.T) {
141 resolver := NewCloudflareZoneResolver("token")
142 resolver.storeCache("example.com", out.CloudflareZone{}, errors.New("temporary failure"))
143
144 entry, ok := resolver.cached("example.com")
145 require.True(t, ok)
146 assert.Error(t, entry.err)
147 assert.Equal(t, defaultErrorCacheTTL, entry.ttl)
148}
149
150func TestCloudflareZoneResolverUnsuccessfulAPI(t *testing.T) {
151 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
152 w.Header().Set("Content-Type", "application/json")
153 json.NewEncoder(w).Encode(zoneAPIResponse{
154 Success: false,
155 Errors: []zoneAPIError{
156 {Code: 1000, Message: "authentication error"},
157 },
158 Result: []zoneAPIResult{},
159 })
160 }))
161 defer server.Close()
162
163 resolver := NewCloudflareZoneResolver("bad-token",
164 WithCloudflareBaseURL(server.URL))
165
166 _, err := resolver.FindZone(context.Background(), "example.com")
167 require.Error(t, err)
168 // The resolver iterates all candidates; the final error is the generic one.
169 assert.Contains(t, err.Error(), "no active zone found")
170 // Token must never leak in error messages.
171 assert.NotContains(t, err.Error(), "bad-token")
172}
173
174// Ensure the interface compliance.
175func TestCloudflareZoneResolverImplementsInterface(t *testing.T) {
176 var _ out.CloudflareZoneResolver = (*CloudflareZoneResolver)(nil)
177
178 // Test NewCloudflareZoneResolver returns non-nil
179 r := NewCloudflareZoneResolver("token")
180 require.NotNil(t, r)
181
182 // Check defaults
183 assert.Equal(t, "https://api.cloudflare.com/client/v4", r.baseURL)
184 assert.NotNil(t, r.client)
185 assert.Equal(t, defaultCloudflareTimeout, r.client.Timeout)
186}
187
188func TestWithCloudflareHTTPClientNilLeavesDefault(t *testing.T) {
189 // Passing nil must not replace the default HTTP client.
190 r := NewCloudflareZoneResolver("token", WithCloudflareHTTPClient(nil))
191 require.NotNil(t, r.client)
192 assert.Equal(t, defaultCloudflareTimeout, r.client.Timeout,
193 "default timeout should be preserved when nil is passed")
194}
195
196func TestCloudflareZoneResolverFindZoneLowercasesInput(t *testing.T) {
197 // The server expects lowercase names; uppercase input must be normalized.
198 callCount := 0
199 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
200 callCount++
201 name := r.URL.Query().Get("name")
202 assert.Equal(t, "example.com", name, "should receive lowercased domain, got %q", name)
203
204 w.Header().Set("Content-Type", "application/json")
205 json.NewEncoder(w).Encode(zoneAPIResponse{
206 Success: true,
207 Result: []zoneAPIResult{
208 {ID: "zone-1", Name: "example.com", Status: "active"},
209 },
210 })
211 }))
212 defer server.Close()
213
214 resolver := NewCloudflareZoneResolver("test-token",
215 WithCloudflareBaseURL(server.URL))
216
217 // Uppercase input — should lowercase to EXAMPLE.COM -> example.com
218 zone, err := resolver.FindZone(context.Background(), "EXAMPLE.COM")
219 require.NoError(t, err)
220 assert.Equal(t, "zone-1", zone.ID)
221 assert.Equal(t, 1, callCount)
222}
223
224func TestCloudflareZoneResolverFindZoneMixedCaseWithTrailingDot(t *testing.T) {
225 // Mixed case with trailing dot should be lowercased and dot-stripped.
226 // The search chain tries "mixed.example.com" (empty), then "example.com" (found).
227 callCount := 0
228 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
229 callCount++
230 name := r.URL.Query().Get("name")
231 switch name {
232 case "mixed.example.com":
233 // No zone found for subdomain
234 w.Header().Set("Content-Type", "application/json")
235 json.NewEncoder(w).Encode(zoneAPIResponse{
236 Success: true,
237 Result: []zoneAPIResult{},
238 })
239 case "example.com":
240 w.Header().Set("Content-Type", "application/json")
241 json.NewEncoder(w).Encode(zoneAPIResponse{
242 Success: true,
243 Result: []zoneAPIResult{
244 {ID: "zone-1", Name: "example.com", Status: "active"},
245 },
246 })
247 default:
248 t.Errorf("unexpected zone name query: %s", name)
249 }
250 }))
251 defer server.Close()
252
253 resolver := NewCloudflareZoneResolver("test-token",
254 WithCloudflareBaseURL(server.URL))
255
256 zone, err := resolver.FindZone(context.Background(), "Mixed.EXAMPLE.Com.")
257 require.NoError(t, err)
258 assert.Equal(t, "zone-1", zone.ID)
259 // First call: mixed.example.com (empty), second: example.com (found)
260 assert.Equal(t, 2, callCount, "should try mixed.example.com then find example.com")
261}