@marcel91200 / caddy Mirror · read-only
Fork 0

Fast and extensible multi-platform HTTP/1-2-3 web server with automatic HTTPS

Mirrored from https://github.com/Slazee/caddy.git

Synced Nov 10, 23:45 UTC

master
go 612 lines 17.2 KB
Raw
1 package caddytest
2
3 import (
4 "bytes"
5 "context"
6 "crypto/tls"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "io"
11 "io/fs"
12 "log"
13 "net"
14 "net/http"
15 "net/http/cookiejar"
16 "os"
17 "path"
18 "reflect"
19 "regexp"
20 "runtime"
21 "strings"
22 "testing"
23 "time"
24
25 "github.com/aryann/difflib"
26
27 caddycmd "github.com/caddyserver/caddy/v2/cmd"
28
29 "github.com/caddyserver/caddy/v2/caddyconfig"
30 // plug in Caddy modules here
31 _ "github.com/caddyserver/caddy/v2/modules/standard"
32 )
33
34 // Config store any configuration required to make the tests run
35 type Config struct {
36 // Port we expect caddy to listening on
37 AdminPort int
38 // Certificates we expect to be loaded before attempting to run the tests
39 Certificates []string
40 // TestRequestTimeout is the time to wait for a http request to
41 TestRequestTimeout time.Duration
42 // LoadRequestTimeout is the time to wait for the config to be loaded against the caddy server
43 LoadRequestTimeout time.Duration
44 }
45
46 // Default testing values
47 var Default = Config{
48 AdminPort: 2999, // different from what a real server also running on a developer's machine might be
49 Certificates: []string{"/caddy.localhost.crt", "/caddy.localhost.key"},
50 TestRequestTimeout: 5 * time.Second,
51 LoadRequestTimeout: 5 * time.Second,
52 }
53
54 var (
55 matchKey = regexp.MustCompile(`(/[\w\d\.]+\.key)`)
56 matchCert = regexp.MustCompile(`(/[\w\d\.]+\.crt)`)
57 )
58
59 // Tester represents an instance of a test client.
60 type Tester struct {
61 Client *http.Client
62 configLoaded bool
63 t testing.TB
64 config Config
65 }
66
67 // NewTester will create a new testing client with an attached cookie jar
68 func NewTester(t testing.TB) *Tester {
69 jar, err := cookiejar.New(nil)
70 if err != nil {
71 t.Fatalf("failed to create cookiejar: %s", err)
72 }
73
74 return &Tester{
75 Client: &http.Client{
76 Transport: CreateTestingTransport(),
77 Jar: jar,
78 Timeout: Default.TestRequestTimeout,
79 },
80 configLoaded: false,
81 t: t,
82 config: Default,
83 }
84 }
85
86 // WithDefaultOverrides this will override the default test configuration with the provided values.
87 func (tc *Tester) WithDefaultOverrides(overrides Config) *Tester {
88 if overrides.AdminPort != 0 {
89 tc.config.AdminPort = overrides.AdminPort
90 }
91 if len(overrides.Certificates) > 0 {
92 tc.config.Certificates = overrides.Certificates
93 }
94 if overrides.TestRequestTimeout != 0 {
95 tc.config.TestRequestTimeout = overrides.TestRequestTimeout
96 tc.Client.Timeout = overrides.TestRequestTimeout
97 }
98 if overrides.LoadRequestTimeout != 0 {
99 tc.config.LoadRequestTimeout = overrides.LoadRequestTimeout
100 }
101
102 return tc
103 }
104
105 type configLoadError struct {
106 Response string
107 }
108
109 func (e configLoadError) Error() string { return e.Response }
110
111 func timeElapsed(start time.Time, name string) {
112 elapsed := time.Since(start)
113 log.Printf("%s took %s", name, elapsed)
114 }
115
116 // InitServer this will configure the server with a configurion of a specific
117 // type. The configType must be either "json" or the adapter type.
118 func (tc *Tester) InitServer(rawConfig string, configType string) {
119 if err := tc.initServer(rawConfig, configType); err != nil {
120 tc.t.Logf("failed to load config: %s", err)
121 tc.t.Fail()
122 }
123 if err := tc.ensureConfigRunning(rawConfig, configType); err != nil {
124 tc.t.Logf("failed ensuring config is running: %s", err)
125 tc.t.Fail()
126 }
127 }
128
129 // InitServer this will configure the server with a configurion of a specific
130 // type. The configType must be either "json" or the adapter type.
131 func (tc *Tester) initServer(rawConfig string, configType string) error {
132 if testing.Short() {
133 tc.t.SkipNow()
134 return nil
135 }
136
137 err := validateTestPrerequisites(tc)
138 if err != nil {
139 tc.t.Skipf("skipping tests as failed integration prerequisites. %s", err)
140 return nil
141 }
142
143 tc.t.Cleanup(func() {
144 if tc.t.Failed() && tc.configLoaded {
145 res, err := http.Get(fmt.Sprintf("http://localhost:%d/config/", tc.config.AdminPort))
146 if err != nil {
147 tc.t.Log("unable to read the current config")
148 return
149 }
150 defer res.Body.Close()
151 body, _ := io.ReadAll(res.Body)
152
153 var out bytes.Buffer
154 _ = json.Indent(&out, body, "", " ")
155 tc.t.Logf("----------- failed with config -----------\n%s", out.String())
156 }
157 })
158
159 rawConfig = prependCaddyFilePath(rawConfig)
160 // normalize JSON config
161 if configType == "json" {
162 tc.t.Logf("Before: %s", rawConfig)
163 var conf any
164 if err := json.Unmarshal([]byte(rawConfig), &conf); err != nil {
165 return err
166 }
167 c, err := json.Marshal(conf)
168 if err != nil {
169 return err
170 }
171 rawConfig = string(c)
172 tc.t.Logf("After: %s", rawConfig)
173 }
174 client := &http.Client{
175 Timeout: tc.config.LoadRequestTimeout,
176 }
177 start := time.Now()
178 req, err := http.NewRequest("POST", fmt.Sprintf("http://localhost:%d/load", tc.config.AdminPort), strings.NewReader(rawConfig))
179 if err != nil {
180 tc.t.Errorf("failed to create request. %s", err)
181 return err
182 }
183
184 if configType == "json" {
185 req.Header.Add("Content-Type", "application/json")
186 } else {
187 req.Header.Add("Content-Type", "text/"+configType)
188 }
189
190 res, err := client.Do(req) //nolint:gosec // no SSRF because URL is hard-coded to localhost, and port comes from config
191 if err != nil {
192 tc.t.Errorf("unable to contact caddy server. %s", err)
193 return err
194 }
195 timeElapsed(start, "caddytest: config load time")
196
197 defer res.Body.Close()
198 body, err := io.ReadAll(res.Body)
199 if err != nil {
200 tc.t.Errorf("unable to read response. %s", err)
201 return err
202 }
203
204 if res.StatusCode != 200 {
205 return configLoadError{Response: string(body)}
206 }
207
208 tc.configLoaded = true
209 return nil
210 }
211
212 func (tc *Tester) ensureConfigRunning(rawConfig string, configType string) error {
213 expectedBytes := []byte(prependCaddyFilePath(rawConfig))
214 if configType != "json" {
215 adapter := caddyconfig.GetAdapter(configType)
216 if adapter == nil {
217 return fmt.Errorf("adapter of config type is missing: %s", configType)
218 }
219 expectedBytes, _, _ = adapter.Adapt([]byte(rawConfig), nil)
220 }
221
222 var expected any
223 err := json.Unmarshal(expectedBytes, &expected)
224 if err != nil {
225 return err
226 }
227
228 client := &http.Client{
229 Timeout: tc.config.LoadRequestTimeout,
230 }
231
232 fetchConfig := func(client *http.Client) any {
233 resp, err := client.Get(fmt.Sprintf("http://localhost:%d/config/", tc.config.AdminPort))
234 if err != nil {
235 return nil
236 }
237 defer resp.Body.Close()
238 actualBytes, err := io.ReadAll(resp.Body)
239 if err != nil {
240 return nil
241 }
242 var actual any
243 err = json.Unmarshal(actualBytes, &actual)
244 if err != nil {
245 return nil
246 }
247 return actual
248 }
249
250 for retries := 10; retries > 0; retries-- {
251 if reflect.DeepEqual(expected, fetchConfig(client)) {
252 return nil
253 }
254 time.Sleep(1 * time.Second)
255 }
256 tc.t.Errorf("POSTed configuration isn't active")
257 return errors.New("EnsureConfigRunning: POSTed configuration isn't active")
258 }
259
260 const initConfig = `{
261 admin localhost:%d
262 }
263 `
264
265 // validateTestPrerequisites ensures the certificates are available in the
266 // designated path and Caddy sub-process is running.
267 func validateTestPrerequisites(tc *Tester) error {
268 // check certificates are found
269 for _, certName := range tc.config.Certificates {
270 if _, err := os.Stat(getIntegrationDir() + certName); errors.Is(err, fs.ErrNotExist) {
271 return fmt.Errorf("caddy integration test certificates (%s) not found", certName)
272 }
273 }
274
275 if isCaddyAdminRunning(tc) != nil {
276 // setup the init config file, and set the cleanup afterwards
277 f, err := os.CreateTemp("", "")
278 if err != nil {
279 return err
280 }
281 tc.t.Cleanup(func() {
282 _ = f.Close()
283 os.Remove(f.Name()) //nolint:gosec // false positive, filename comes from std lib, no path traversal
284 })
285 if _, err := fmt.Fprintf(f, initConfig, tc.config.AdminPort); err != nil {
286 _ = f.Close()
287 return err
288 }
289
290 // start inprocess caddy server
291 os.Args = []string{"caddy", "run", "--config", f.Name(), "--adapter", "caddyfile"}
292 go func() {
293 caddycmd.Main()
294 }()
295
296 // wait for caddy to start serving the initial config
297 for retries := 10; retries > 0 && isCaddyAdminRunning(tc) != nil; retries-- {
298 time.Sleep(1 * time.Second)
299 }
300 }
301
302 // one more time to return the error
303 return isCaddyAdminRunning(tc)
304 }
305
306 func isCaddyAdminRunning(tc *Tester) error {
307 // assert that caddy is running
308 client := &http.Client{
309 Timeout: tc.config.LoadRequestTimeout,
310 }
311 resp, err := client.Get(fmt.Sprintf("http://localhost:%d/config/", tc.config.AdminPort))
312 if err != nil {
313 return fmt.Errorf("caddy integration test caddy server not running. Expected to be listening on localhost:%d", tc.config.AdminPort)
314 }
315 resp.Body.Close()
316
317 return nil
318 }
319
320 func getIntegrationDir() string {
321 _, filename, _, ok := runtime.Caller(1)
322 if !ok {
323 panic("unable to determine the current file path")
324 }
325
326 return path.Dir(filename)
327 }
328
329 // use the convention to replace /[certificatename].[crt|key] with the full path
330 // this helps reduce the noise in test configurations and also allow this
331 // to run in any path
332 func prependCaddyFilePath(rawConfig string) string {
333 r := matchKey.ReplaceAllString(rawConfig, getIntegrationDir()+"$1")
334 r = matchCert.ReplaceAllString(r, getIntegrationDir()+"$1")
335 return r
336 }
337
338 // CreateTestingTransport creates a testing transport that forces call dialing connections to happen locally
339 func CreateTestingTransport() *http.Transport {
340 dialer := net.Dialer{
341 Timeout: 5 * time.Second,
342 KeepAlive: 5 * time.Second,
343 }
344
345 dialContext := func(ctx context.Context, network, addr string) (net.Conn, error) {
346 parts := strings.Split(addr, ":")
347 destAddr := fmt.Sprintf("127.0.0.1:%s", parts[1])
348 log.Printf("caddytest: redirecting the dialer from %s to %s", addr, destAddr)
349 return dialer.DialContext(ctx, network, destAddr)
350 }
351
352 return &http.Transport{
353 Proxy: http.ProxyFromEnvironment,
354 DialContext: dialContext,
355 ForceAttemptHTTP2: true,
356 MaxIdleConns: 100,
357 IdleConnTimeout: 90 * time.Second,
358 TLSHandshakeTimeout: 5 * time.Second,
359 ExpectContinueTimeout: 1 * time.Second,
360 TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec
361 }
362 }
363
364 // AssertLoadError will load a config and expect an error
365 func AssertLoadError(t *testing.T, rawConfig string, configType string, expectedError string) {
366 t.Helper()
367
368 tc := NewTester(t)
369
370 err := tc.initServer(rawConfig, configType)
371 if !strings.Contains(err.Error(), expectedError) {
372 t.Errorf("expected error \"%s\" but got \"%s\"", expectedError, err.Error())
373 }
374 }
375
376 // AssertRedirect makes a request and asserts the redirection happens
377 func (tc *Tester) AssertRedirect(requestURI string, expectedToLocation string, expectedStatusCode int) *http.Response {
378 tc.t.Helper()
379
380 redirectPolicyFunc := func(req *http.Request, via []*http.Request) error {
381 return http.ErrUseLastResponse
382 }
383
384 // using the existing client, we override the check redirect policy for this test
385 old := tc.Client.CheckRedirect
386 tc.Client.CheckRedirect = redirectPolicyFunc
387 defer func() { tc.Client.CheckRedirect = old }()
388
389 resp, err := tc.Client.Get(requestURI)
390 if err != nil {
391 tc.t.Errorf("failed to call server %s", err)
392 return nil
393 }
394
395 if expectedStatusCode != resp.StatusCode {
396 tc.t.Errorf("requesting \"%s\" expected status code: %d but got %d", requestURI, expectedStatusCode, resp.StatusCode)
397 }
398
399 loc, err := resp.Location()
400 if err != nil {
401 tc.t.Errorf("requesting \"%s\" expected location: \"%s\" but got error: %s", requestURI, expectedToLocation, err)
402 }
403 if loc == nil && expectedToLocation != "" {
404 tc.t.Errorf("requesting \"%s\" expected a Location header, but didn't get one", requestURI)
405 }
406 if loc != nil {
407 if expectedToLocation != loc.String() {
408 tc.t.Errorf("requesting \"%s\" expected location: \"%s\" but got \"%s\"", requestURI, expectedToLocation, loc.String())
409 }
410 }
411
412 return resp
413 }
414
415 // CompareAdapt adapts a config and then compares it against an expected result
416 func CompareAdapt(t testing.TB, filename, rawConfig string, adapterName string, expectedResponse string) bool {
417 t.Helper()
418
419 cfgAdapter := caddyconfig.GetAdapter(adapterName)
420 if cfgAdapter == nil {
421 t.Logf("unrecognized config adapter '%s'", adapterName)
422 return false
423 }
424
425 options := make(map[string]any)
426
427 result, warnings, err := cfgAdapter.Adapt([]byte(rawConfig), options)
428 if err != nil {
429 t.Logf("adapting config using %s adapter: %v", adapterName, err)
430 return false
431 }
432
433 // prettify results to keep tests human-manageable
434 var prettyBuf bytes.Buffer
435 err = json.Indent(&prettyBuf, result, "", "\t")
436 if err != nil {
437 return false
438 }
439 result = prettyBuf.Bytes()
440
441 if len(warnings) > 0 {
442 for _, w := range warnings {
443 t.Logf("warning: %s:%d: %s: %s", filename, w.Line, w.Directive, w.Message)
444 }
445 }
446
447 diff := difflib.Diff(
448 strings.Split(expectedResponse, "\n"),
449 strings.Split(string(result), "\n"))
450
451 // scan for failure
452 failed := false
453 for _, d := range diff {
454 if d.Delta != difflib.Common {
455 failed = true
456 break
457 }
458 }
459
460 if failed {
461 for _, d := range diff {
462 switch d.Delta {
463 case difflib.Common:
464 fmt.Printf(" %s\n", d.Payload)
465 case difflib.LeftOnly:
466 fmt.Printf(" - %s\n", d.Payload)
467 case difflib.RightOnly:
468 fmt.Printf(" + %s\n", d.Payload)
469 }
470 }
471 return false
472 }
473 return true
474 }
475
476 // AssertAdapt adapts a config and then tests it against an expected result
477 func AssertAdapt(t testing.TB, rawConfig string, adapterName string, expectedResponse string) {
478 t.Helper()
479
480 ok := CompareAdapt(t, "Caddyfile", rawConfig, adapterName, expectedResponse)
481 if !ok {
482 t.Fail()
483 }
484 }
485
486 // Generic request functions
487
488 func applyHeaders(t testing.TB, req *http.Request, requestHeaders []string) {
489 requestContentType := ""
490 for _, requestHeader := range requestHeaders {
491 arr := strings.SplitAfterN(requestHeader, ":", 2)
492 k := strings.TrimRight(arr[0], ":")
493 v := strings.TrimSpace(arr[1])
494 if k == "Content-Type" {
495 requestContentType = v
496 }
497 t.Logf("Request header: %s => %s", k, v)
498 req.Header.Set(k, v)
499 }
500
501 if requestContentType == "" {
502 t.Logf("Content-Type header not provided")
503 }
504 }
505
506 // AssertResponseCode will execute the request and verify the status code, returns a response for additional assertions
507 func (tc *Tester) AssertResponseCode(req *http.Request, expectedStatusCode int) *http.Response {
508 tc.t.Helper()
509
510 resp, err := tc.Client.Do(req) //nolint:gosec // no SSRFs demonstrated
511 if err != nil {
512 tc.t.Fatalf("failed to call server %s", err)
513 }
514
515 if expectedStatusCode != resp.StatusCode {
516 tc.t.Errorf("requesting \"%s\" expected status code: %d but got %d", req.URL.RequestURI(), expectedStatusCode, resp.StatusCode)
517 }
518
519 return resp
520 }
521
522 // AssertResponse requests a URI and asserts the status code and body.
523 func (tc *Tester) AssertResponse(req *http.Request, expectedStatusCode int, expectedBody string) (*http.Response, string) {
524 tc.t.Helper()
525
526 resp := tc.AssertResponseCode(req, expectedStatusCode)
527
528 defer resp.Body.Close()
529 bytes, err := io.ReadAll(resp.Body)
530 if err != nil {
531 tc.t.Fatalf("unable to read the response body %s", err)
532 }
533
534 body := string(bytes)
535
536 if body != expectedBody {
537 tc.t.Errorf("requesting \"%s\" expected response body \"%s\" but got \"%s\"", req.RequestURI, expectedBody, body)
538 }
539
540 return resp, body
541 }
542
543 // Verb specific test functions
544
545 // AssertGetResponse requests a URI with GET and expects a status code and body text.
546 func (tc *Tester) AssertGetResponse(requestURI string, expectedStatusCode int, expectedBody string) (*http.Response, string) {
547 tc.t.Helper()
548
549 req, err := http.NewRequest("GET", requestURI, nil)
550 if err != nil {
551 tc.t.Fatalf("unable to create request %s", err)
552 }
553
554 return tc.AssertResponse(req, expectedStatusCode, expectedBody)
555 }
556
557 // AssertDeleteResponse requests a URI with DELETE and expects a status code and body text.
558 func (tc *Tester) AssertDeleteResponse(requestURI string, expectedStatusCode int, expectedBody string) (*http.Response, string) {
559 tc.t.Helper()
560
561 req, err := http.NewRequest("DELETE", requestURI, nil)
562 if err != nil {
563 tc.t.Fatalf("unable to create request %s", err)
564 }
565
566 return tc.AssertResponse(req, expectedStatusCode, expectedBody)
567 }
568
569 // AssertPostResponseBody requests a URI with POST and asserts the response code and body.
570 func (tc *Tester) AssertPostResponseBody(requestURI string, requestHeaders []string, requestBody *bytes.Buffer, expectedStatusCode int, expectedBody string) (*http.Response, string) {
571 tc.t.Helper()
572
573 req, err := http.NewRequest("POST", requestURI, requestBody)
574 if err != nil {
575 tc.t.Errorf("failed to create request %s", err)
576 return nil, ""
577 }
578
579 applyHeaders(tc.t, req, requestHeaders)
580
581 return tc.AssertResponse(req, expectedStatusCode, expectedBody)
582 }
583
584 // AssertPutResponseBody requests a URI with PUT and asserts the response code and body.
585 func (tc *Tester) AssertPutResponseBody(requestURI string, requestHeaders []string, requestBody *bytes.Buffer, expectedStatusCode int, expectedBody string) (*http.Response, string) {
586 tc.t.Helper()
587
588 req, err := http.NewRequest("PUT", requestURI, requestBody)
589 if err != nil {
590 tc.t.Errorf("failed to create request %s", err)
591 return nil, ""
592 }
593
594 applyHeaders(tc.t, req, requestHeaders)
595
596 return tc.AssertResponse(req, expectedStatusCode, expectedBody)
597 }
598
599 // AssertPatchResponseBody requests a URI with PATCH and asserts the response code and body.
600 func (tc *Tester) AssertPatchResponseBody(requestURI string, requestHeaders []string, requestBody *bytes.Buffer, expectedStatusCode int, expectedBody string) (*http.Response, string) {
601 tc.t.Helper()
602
603 req, err := http.NewRequest("PATCH", requestURI, requestBody)
604 if err != nil {
605 tc.t.Errorf("failed to create request %s", err)
606 return nil, ""
607 }
608
609 applyHeaders(tc.t, req, requestHeaders)
610
611 return tc.AssertResponse(req, expectedStatusCode, expectedBody)
612 }