master
go 399 lines 9.73 KB
Raw
1 // SPDX-License-Identifier: GPL-3.0-or-later
2
3 package httpsd
4
5 import (
6 "context"
7 "fmt"
8 "io"
9 "net/http"
10 "net/http/httptest"
11 "os"
12 "strings"
13 "sync"
14 "testing"
15 "time"
16
17 "github.com/netdata/netdata/go/plugins/pkg/confopt"
18 "github.com/netdata/netdata/go/plugins/pkg/web"
19 "github.com/netdata/netdata/go/plugins/plugin/agent/discovery/sd/model"
20
21 "github.com/stretchr/testify/assert"
22 "github.com/stretchr/testify/require"
23 )
24
25 func TestNewDiscoverer(t *testing.T) {
26 tests := map[string]struct {
27 cfg Config
28 wantErr bool
29 validate func(*testing.T, *Discoverer)
30 }{
31 "valid defaults": {
32 cfg: Config{
33 HTTPConfig: web.HTTPConfig{
34 RequestConfig: web.RequestConfig{URL: "http://127.0.0.1"},
35 },
36 },
37 validate: func(t *testing.T, d *Discoverer) {
38 assert.Equal(t, defaultInterval, d.interval)
39 assert.Equal(t, defaultTimeout, d.client.Timeout)
40 assert.Equal(t, formatAuto, d.parser.format)
41 },
42 },
43 "explicit one shot": {
44 cfg: Config{
45 HTTPConfig: web.HTTPConfig{
46 RequestConfig: web.RequestConfig{URL: "http://127.0.0.1"},
47 },
48 Interval: durationPtr(0),
49 },
50 validate: func(t *testing.T, d *Discoverer) {
51 assert.Zero(t, d.interval)
52 },
53 },
54 "negative interval": {
55 cfg: Config{
56 HTTPConfig: web.HTTPConfig{
57 RequestConfig: web.RequestConfig{URL: "http://127.0.0.1"},
58 },
59 Interval: durationPtr(-time.Second),
60 },
61 wantErr: true,
62 },
63 "explicit format": {
64 cfg: Config{
65 HTTPConfig: web.HTTPConfig{
66 RequestConfig: web.RequestConfig{URL: "http://127.0.0.1"},
67 },
68 Format: "yaml",
69 },
70 validate: func(t *testing.T, d *Discoverer) {
71 assert.Equal(t, formatYAML, d.parser.format)
72 },
73 },
74 "negative timeout uses default": {
75 cfg: Config{
76 HTTPConfig: web.HTTPConfig{
77 RequestConfig: web.RequestConfig{URL: "http://127.0.0.1"},
78 ClientConfig: web.ClientConfig{Timeout: confopt.Duration(-time.Second)},
79 },
80 },
81 validate: func(t *testing.T, d *Discoverer) {
82 assert.Equal(t, defaultTimeout, d.client.Timeout)
83 },
84 },
85 "missing url": {
86 wantErr: true,
87 },
88 "unsupported scheme": {
89 cfg: Config{
90 HTTPConfig: web.HTTPConfig{
91 RequestConfig: web.RequestConfig{URL: "ftp://127.0.0.1"},
92 },
93 },
94 wantErr: true,
95 },
96 "unsupported format": {
97 cfg: Config{
98 HTTPConfig: web.HTTPConfig{
99 RequestConfig: web.RequestConfig{URL: "http://127.0.0.1"},
100 },
101 Format: "toml",
102 },
103 wantErr: true,
104 },
105 }
106
107 for name, tc := range tests {
108 t.Run(name, func(t *testing.T) {
109 d, err := NewDiscoverer(tc.cfg)
110
111 if tc.wantErr {
112 assert.Error(t, err)
113 return
114 }
115
116 require.NoError(t, err)
117 require.NotNil(t, d)
118 if tc.validate != nil {
119 tc.validate(t, d)
120 }
121 })
122 }
123 }
124
125 func TestDiscoverer_fetchTargetGroup(t *testing.T) {
126 var gotMethod, gotBody, gotHeader, gotAuth string
127 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
128 gotMethod = r.Method
129 gotHeader = r.Header.Get("X-Test")
130 gotAuth = r.Header.Get("Authorization")
131 bs, _ := ioReadAllString(r)
132 gotBody = bs
133
134 w.Header().Set("Content-Type", "application/json")
135 _, _ = fmt.Fprint(w, `[{"name":"api","url":"http://127.0.0.1"}]`)
136 }))
137 defer srv.Close()
138
139 d, err := NewDiscoverer(Config{
140 HTTPConfig: web.HTTPConfig{
141 RequestConfig: web.RequestConfig{
142 URL: srv.URL,
143 Method: http.MethodPost,
144 Body: "request-body",
145 Username: "user",
146 Password: "pass",
147 Headers: map[string]string{"X-Test": "value"},
148 },
149 },
150 })
151 require.NoError(t, err)
152
153 tgg, err := d.fetchTargetGroup(context.Background())
154 require.NoError(t, err)
155
156 assert.Equal(t, http.MethodPost, gotMethod)
157 assert.Equal(t, "request-body", gotBody)
158 assert.Equal(t, "value", gotHeader)
159 assert.NotEmpty(t, gotAuth)
160 assert.Equal(t, fullName, tgg.Provider())
161 assert.Len(t, tgg.Targets(), 1)
162 assert.Contains(t, tgg.Source(), "discoverer=http,url="+srv.URL+",hash=")
163 }
164
165 func TestDiscoverer_NotFollowRedirects(t *testing.T) {
166 var finalHit bool
167 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
168 switch r.URL.Path {
169 case "/":
170 http.Redirect(w, r, "/final", http.StatusFound)
171 case "/final":
172 finalHit = true
173 w.Header().Set("Content-Type", "application/json")
174 _, _ = fmt.Fprint(w, `[]`)
175 default:
176 http.NotFound(w, r)
177 }
178 }))
179 defer srv.Close()
180
181 d, err := NewDiscoverer(Config{
182 HTTPConfig: web.HTTPConfig{
183 RequestConfig: web.RequestConfig{URL: srv.URL},
184 ClientConfig: web.ClientConfig{NotFollowRedirect: true},
185 },
186 })
187 require.NoError(t, err)
188
189 _, err = d.fetchTargetGroup(context.Background())
190 require.Error(t, err)
191 assert.Contains(t, err.Error(), "redirect")
192 assert.False(t, finalHit)
193 }
194
195 func TestDiscoverer_BearerTokenFileReread(t *testing.T) {
196 tokenFile := t.TempDir() + "/token"
197 require.NoError(t, os.WriteFile(tokenFile, []byte("token-1"), 0o600))
198
199 var mu sync.Mutex
200 var auths []string
201 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
202 mu.Lock()
203 auths = append(auths, r.Header.Get("Authorization"))
204 mu.Unlock()
205
206 w.Header().Set("Content-Type", "application/json")
207 _, _ = fmt.Fprint(w, `[]`)
208 }))
209 defer srv.Close()
210
211 d, err := NewDiscoverer(Config{
212 HTTPConfig: web.HTTPConfig{
213 RequestConfig: web.RequestConfig{
214 URL: srv.URL,
215 BearerTokenFile: tokenFile,
216 },
217 },
218 })
219 require.NoError(t, err)
220
221 _, err = d.fetchTargetGroup(context.Background())
222 require.NoError(t, err)
223 require.NoError(t, os.WriteFile(tokenFile, []byte("token-2"), 0o600))
224 _, err = d.fetchTargetGroup(context.Background())
225 require.NoError(t, err)
226
227 mu.Lock()
228 defer mu.Unlock()
229 require.Len(t, auths, 2)
230 assert.Equal(t, "Bearer token-1", auths[0])
231 assert.Equal(t, "Bearer token-2", auths[1])
232 }
233
234 func TestDiscoverer_ResponseBodyLimit(t *testing.T) {
235 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
236 chunk := strings.Repeat("x", 1024)
237 for written := int64(0); written <= responseBodyLimit; written += int64(len(chunk)) {
238 if _, err := w.Write([]byte(chunk)); err != nil {
239 return
240 }
241 }
242 }))
243 defer srv.Close()
244
245 d, err := NewDiscoverer(Config{
246 HTTPConfig: web.HTTPConfig{
247 RequestConfig: web.RequestConfig{URL: srv.URL},
248 },
249 })
250 require.NoError(t, err)
251
252 _, err = d.fetchTargetGroup(context.Background())
253 assert.Error(t, err)
254 assert.Contains(t, err.Error(), "response body exceeds limit")
255 }
256
257 func TestDiscoverer_ErrorUsesSanitizedURL(t *testing.T) {
258 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
259 http.Error(w, "nope", http.StatusInternalServerError)
260 }))
261 defer srv.Close()
262
263 rawURL := strings.Replace(srv.URL, "http://", "http://user:pass@", 1) + "/path?token=secret#fragment"
264 d, err := NewDiscoverer(Config{
265 HTTPConfig: web.HTTPConfig{
266 RequestConfig: web.RequestConfig{URL: rawURL},
267 },
268 })
269 require.NoError(t, err)
270
271 _, err = d.fetchTargetGroup(context.Background())
272 require.Error(t, err)
273
274 assert.Contains(t, err.Error(), "/path")
275 assert.NotContains(t, err.Error(), "user")
276 assert.NotContains(t, err.Error(), "pass")
277 assert.NotContains(t, err.Error(), "token")
278 assert.NotContains(t, err.Error(), "secret")
279 assert.NotContains(t, err.Error(), "fragment")
280 }
281
282 func TestDiscoverer_DiscoverFailureDoesNotEmit(t *testing.T) {
283 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
284 http.Error(w, "nope", http.StatusInternalServerError)
285 }))
286 defer srv.Close()
287
288 d, err := NewDiscoverer(Config{
289 HTTPConfig: web.HTTPConfig{
290 RequestConfig: web.RequestConfig{URL: srv.URL},
291 },
292 Interval: durationPtr(0),
293 })
294 require.NoError(t, err)
295
296 ch := make(chan []model.TargetGroup, 1)
297 d.Discover(context.Background(), ch)
298
299 select {
300 case got := <-ch:
301 t.Fatalf("expected no emission, got %v", got)
302 default:
303 }
304 }
305
306 func TestDiscoverer_DiscoverEmptySuccessEmitsEmptyTargetGroup(t *testing.T) {
307 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
308 w.Header().Set("Content-Type", "application/json")
309 _, _ = fmt.Fprint(w, `[]`)
310 }))
311 defer srv.Close()
312
313 d, err := NewDiscoverer(Config{
314 HTTPConfig: web.HTTPConfig{
315 RequestConfig: web.RequestConfig{URL: srv.URL},
316 },
317 Interval: durationPtr(0),
318 })
319 require.NoError(t, err)
320
321 ch := make(chan []model.TargetGroup, 1)
322 d.Discover(context.Background(), ch)
323
324 select {
325 case got := <-ch:
326 require.Len(t, got, 1)
327 assert.Empty(t, got[0].Targets())
328 case <-time.After(time.Second):
329 t.Fatal("expected empty target group emission")
330 }
331 }
332
333 func TestDiscoverer_DiscoverPollsInterval(t *testing.T) {
334 var (
335 mu sync.Mutex
336 calls int
337 )
338 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
339 mu.Lock()
340 calls++
341 mu.Unlock()
342
343 w.Header().Set("Content-Type", "application/json")
344 _, _ = fmt.Fprint(w, `[]`)
345 }))
346 defer srv.Close()
347
348 d, err := NewDiscoverer(Config{
349 HTTPConfig: web.HTTPConfig{
350 RequestConfig: web.RequestConfig{URL: srv.URL},
351 },
352 Interval: durationPtr(10 * time.Millisecond),
353 })
354 require.NoError(t, err)
355
356 ctx, cancel := context.WithCancel(context.Background())
357 defer cancel()
358
359 ch := make(chan []model.TargetGroup, 4)
360 done := make(chan struct{})
361 go func() {
362 defer close(done)
363 d.Discover(ctx, ch)
364 }()
365
366 for range 2 {
367 select {
368 case got := <-ch:
369 require.Len(t, got, 1)
370 assert.Empty(t, got[0].Targets())
371 case <-time.After(time.Second):
372 t.Fatal("expected target group emission")
373 }
374 }
375
376 cancel()
377 select {
378 case <-done:
379 case <-time.After(time.Second):
380 t.Fatal("discoverer did not stop after context cancellation")
381 }
382
383 mu.Lock()
384 defer mu.Unlock()
385 assert.GreaterOrEqual(t, calls, 2)
386 }
387
388 func durationPtr(d time.Duration) *confopt.LongDuration {
389 v := confopt.LongDuration(d)
390 return &v
391 }
392
393 func ioReadAllString(r *http.Request) (string, error) {
394 bs, err := io.ReadAll(r.Body)
395 if err != nil {
396 return "", err
397 }
398 return string(bs), nil
399 }