Skip to content

Commit 71f5131

Browse files
committed
Add tests for dynamic upstream tracking, admin endpoint, health checks
1 parent 24ae3ba commit 71f5131

File tree

3 files changed

+1011
-0
lines changed

3 files changed

+1011
-0
lines changed
Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
// Copyright 2015 Matthew Holt and The Caddy Authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package reverseproxy
16+
17+
import (
18+
"encoding/json"
19+
"net/http"
20+
"net/http/httptest"
21+
"testing"
22+
"time"
23+
)
24+
25+
// adminHandlerFixture sets up the global host state for an admin endpoint test
26+
// and returns a cleanup function that must be deferred by the caller.
27+
//
28+
// staticAddrs are inserted into the UsagePool (as a static upstream would be).
29+
// dynamicAddrs are inserted into the dynamicHosts map (as a dynamic upstream would be).
30+
func adminHandlerFixture(t *testing.T, staticAddrs, dynamicAddrs []string) func() {
31+
t.Helper()
32+
33+
for _, addr := range staticAddrs {
34+
u := &Upstream{Dial: addr}
35+
u.fillHost()
36+
}
37+
38+
dynamicHostsMu.Lock()
39+
for _, addr := range dynamicAddrs {
40+
dynamicHosts[addr] = dynamicHostEntry{host: new(Host), lastSeen: time.Now()}
41+
}
42+
dynamicHostsMu.Unlock()
43+
44+
return func() {
45+
// Remove static entries from the UsagePool.
46+
for _, addr := range staticAddrs {
47+
_, _ = hosts.Delete(addr)
48+
}
49+
// Remove dynamic entries.
50+
dynamicHostsMu.Lock()
51+
for _, addr := range dynamicAddrs {
52+
delete(dynamicHosts, addr)
53+
}
54+
dynamicHostsMu.Unlock()
55+
}
56+
}
57+
58+
// callAdminUpstreams fires a GET against handleUpstreams and returns the
59+
// decoded response body.
60+
func callAdminUpstreams(t *testing.T) []upstreamStatus {
61+
t.Helper()
62+
req := httptest.NewRequest(http.MethodGet, "/reverse_proxy/upstreams", nil)
63+
w := httptest.NewRecorder()
64+
65+
handler := adminUpstreams{}
66+
if err := handler.handleUpstreams(w, req); err != nil {
67+
t.Fatalf("handleUpstreams returned unexpected error: %v", err)
68+
}
69+
if w.Code != http.StatusOK {
70+
t.Fatalf("expected 200, got %d", w.Code)
71+
}
72+
if ct := w.Header().Get("Content-Type"); ct != "application/json" {
73+
t.Fatalf("expected Content-Type application/json, got %q", ct)
74+
}
75+
76+
var results []upstreamStatus
77+
if err := json.NewDecoder(w.Body).Decode(&results); err != nil {
78+
t.Fatalf("failed to decode response: %v", err)
79+
}
80+
return results
81+
}
82+
83+
// resultsByAddress indexes a slice of upstreamStatus by address for easier
84+
// lookup in assertions.
85+
func resultsByAddress(statuses []upstreamStatus) map[string]upstreamStatus {
86+
m := make(map[string]upstreamStatus, len(statuses))
87+
for _, s := range statuses {
88+
m[s.Address] = s
89+
}
90+
return m
91+
}
92+
93+
// TestAdminUpstreamsMethodNotAllowed verifies that non-GET methods are rejected.
94+
func TestAdminUpstreamsMethodNotAllowed(t *testing.T) {
95+
for _, method := range []string{http.MethodPost, http.MethodPut, http.MethodDelete} {
96+
req := httptest.NewRequest(method, "/reverse_proxy/upstreams", nil)
97+
w := httptest.NewRecorder()
98+
err := (adminUpstreams{}).handleUpstreams(w, req)
99+
if err == nil {
100+
t.Errorf("method %s: expected an error, got nil", method)
101+
continue
102+
}
103+
apiErr, ok := err.(interface{ HTTPStatus() int })
104+
if !ok {
105+
// caddy.APIError stores the code in HTTPStatus field, access via the
106+
// exported interface it satisfies indirectly; just check non-nil.
107+
continue
108+
}
109+
if code := apiErr.HTTPStatus(); code != http.StatusMethodNotAllowed {
110+
t.Errorf("method %s: expected 405, got %d", method, code)
111+
}
112+
}
113+
}
114+
115+
// TestAdminUpstreamsEmpty verifies that an empty response is valid JSON when
116+
// no upstreams are registered.
117+
func TestAdminUpstreamsEmpty(t *testing.T) {
118+
resetDynamicHosts()
119+
120+
results := callAdminUpstreams(t)
121+
if results == nil {
122+
t.Error("expected non-nil (empty) slice, got nil")
123+
}
124+
if len(results) != 0 {
125+
t.Errorf("expected 0 results with empty pools, got %d", len(results))
126+
}
127+
}
128+
129+
// TestAdminUpstreamsStaticOnly verifies that static upstreams (from the
130+
// UsagePool) appear in the response with correct addresses.
131+
func TestAdminUpstreamsStaticOnly(t *testing.T) {
132+
resetDynamicHosts()
133+
cleanup := adminHandlerFixture(t,
134+
[]string{"10.0.0.1:80", "10.0.0.2:80"},
135+
nil,
136+
)
137+
defer cleanup()
138+
139+
results := callAdminUpstreams(t)
140+
byAddr := resultsByAddress(results)
141+
142+
for _, addr := range []string{"10.0.0.1:80", "10.0.0.2:80"} {
143+
if _, ok := byAddr[addr]; !ok {
144+
t.Errorf("expected static upstream %q in response", addr)
145+
}
146+
}
147+
if len(results) != 2 {
148+
t.Errorf("expected exactly 2 results, got %d", len(results))
149+
}
150+
}
151+
152+
// TestAdminUpstreamsDynamicOnly verifies that dynamic upstreams (from
153+
// dynamicHosts) appear in the response with correct addresses.
154+
func TestAdminUpstreamsDynamicOnly(t *testing.T) {
155+
resetDynamicHosts()
156+
cleanup := adminHandlerFixture(t,
157+
nil,
158+
[]string{"10.0.1.1:80", "10.0.1.2:80"},
159+
)
160+
defer cleanup()
161+
162+
results := callAdminUpstreams(t)
163+
byAddr := resultsByAddress(results)
164+
165+
for _, addr := range []string{"10.0.1.1:80", "10.0.1.2:80"} {
166+
if _, ok := byAddr[addr]; !ok {
167+
t.Errorf("expected dynamic upstream %q in response", addr)
168+
}
169+
}
170+
if len(results) != 2 {
171+
t.Errorf("expected exactly 2 results, got %d", len(results))
172+
}
173+
}
174+
175+
// TestAdminUpstreamsBothPools verifies that static and dynamic upstreams are
176+
// both present in the same response and that there is no overlap or omission.
177+
func TestAdminUpstreamsBothPools(t *testing.T) {
178+
resetDynamicHosts()
179+
cleanup := adminHandlerFixture(t,
180+
[]string{"10.0.2.1:80"},
181+
[]string{"10.0.2.2:80"},
182+
)
183+
defer cleanup()
184+
185+
results := callAdminUpstreams(t)
186+
if len(results) != 2 {
187+
t.Fatalf("expected 2 results (1 static + 1 dynamic), got %d", len(results))
188+
}
189+
190+
byAddr := resultsByAddress(results)
191+
if _, ok := byAddr["10.0.2.1:80"]; !ok {
192+
t.Error("static upstream missing from response")
193+
}
194+
if _, ok := byAddr["10.0.2.2:80"]; !ok {
195+
t.Error("dynamic upstream missing from response")
196+
}
197+
}
198+
199+
// TestAdminUpstreamsNoOverlapBetweenPools verifies that an address registered
200+
// only as a static upstream does not also appear as a dynamic entry, and
201+
// vice-versa.
202+
func TestAdminUpstreamsNoOverlapBetweenPools(t *testing.T) {
203+
resetDynamicHosts()
204+
cleanup := adminHandlerFixture(t,
205+
[]string{"10.0.3.1:80"},
206+
[]string{"10.0.3.2:80"},
207+
)
208+
defer cleanup()
209+
210+
results := callAdminUpstreams(t)
211+
seen := make(map[string]int)
212+
for _, r := range results {
213+
seen[r.Address]++
214+
}
215+
for addr, count := range seen {
216+
if count > 1 {
217+
t.Errorf("address %q appeared %d times; expected exactly once", addr, count)
218+
}
219+
}
220+
}
221+
222+
// TestAdminUpstreamsReportsFailCounts verifies that fail counts accumulated on
223+
// a dynamic upstream's Host are reflected in the response.
224+
func TestAdminUpstreamsReportsFailCounts(t *testing.T) {
225+
resetDynamicHosts()
226+
227+
const addr = "10.0.4.1:80"
228+
h := new(Host)
229+
_ = h.countFail(3)
230+
231+
dynamicHostsMu.Lock()
232+
dynamicHosts[addr] = dynamicHostEntry{host: h, lastSeen: time.Now()}
233+
dynamicHostsMu.Unlock()
234+
defer func() {
235+
dynamicHostsMu.Lock()
236+
delete(dynamicHosts, addr)
237+
dynamicHostsMu.Unlock()
238+
}()
239+
240+
results := callAdminUpstreams(t)
241+
byAddr := resultsByAddress(results)
242+
243+
status, ok := byAddr[addr]
244+
if !ok {
245+
t.Fatalf("expected %q in response", addr)
246+
}
247+
if status.Fails != 3 {
248+
t.Errorf("expected Fails=3, got %d", status.Fails)
249+
}
250+
}
251+
252+
// TestAdminUpstreamsReportsNumRequests verifies that the active request count
253+
// for a static upstream is reflected in the response.
254+
func TestAdminUpstreamsReportsNumRequests(t *testing.T) {
255+
resetDynamicHosts()
256+
257+
const addr = "10.0.4.2:80"
258+
u := &Upstream{Dial: addr}
259+
u.fillHost()
260+
defer func() { _, _ = hosts.Delete(addr) }()
261+
262+
_ = u.Host.countRequest(2)
263+
defer func() { _ = u.Host.countRequest(-2) }()
264+
265+
results := callAdminUpstreams(t)
266+
byAddr := resultsByAddress(results)
267+
268+
status, ok := byAddr[addr]
269+
if !ok {
270+
t.Fatalf("expected %q in response", addr)
271+
}
272+
if status.NumRequests != 2 {
273+
t.Errorf("expected NumRequests=2, got %d", status.NumRequests)
274+
}
275+
}

0 commit comments

Comments
 (0)