v3.3.0 b164fe9

cloudflare_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close