mirror of
https://github.com/caddyserver/caddy.git
synced 2024-12-01 05:04:05 +08:00
555 lines
16 KiB
Go
555 lines
16 KiB
Go
package caddytest
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/tls"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"net/http/cookiejar"
|
|
"os"
|
|
"path"
|
|
"reflect"
|
|
"regexp"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/aryann/difflib"
|
|
|
|
caddycmd "github.com/caddyserver/caddy/v2/cmd"
|
|
|
|
"github.com/caddyserver/caddy/v2/caddyconfig"
|
|
// plug in Caddy modules here
|
|
_ "github.com/caddyserver/caddy/v2/modules/standard"
|
|
)
|
|
|
|
// Defaults store any configuration required to make the tests run
|
|
type Defaults struct {
|
|
// Port we expect caddy to listening on
|
|
AdminPort int
|
|
// Certificates we expect to be loaded before attempting to run the tests
|
|
Certifcates []string
|
|
// TestRequestTimeout is the time to wait for a http request to
|
|
TestRequestTimeout time.Duration
|
|
// LoadRequestTimeout is the time to wait for the config to be loaded against the caddy server
|
|
LoadRequestTimeout time.Duration
|
|
}
|
|
|
|
// Default testing values
|
|
var Default = Defaults{
|
|
AdminPort: 2999, // different from what a real server also running on a developer's machine might be
|
|
Certifcates: []string{"/caddy.localhost.crt", "/caddy.localhost.key"},
|
|
TestRequestTimeout: 5 * time.Second,
|
|
LoadRequestTimeout: 5 * time.Second,
|
|
}
|
|
|
|
var (
|
|
matchKey = regexp.MustCompile(`(/[\w\d\.]+\.key)`)
|
|
matchCert = regexp.MustCompile(`(/[\w\d\.]+\.crt)`)
|
|
)
|
|
|
|
// Tester represents an instance of a test client.
|
|
type Tester struct {
|
|
Client *http.Client
|
|
configLoaded bool
|
|
t testing.TB
|
|
}
|
|
|
|
// NewTester will create a new testing client with an attached cookie jar
|
|
func NewTester(t testing.TB) *Tester {
|
|
jar, err := cookiejar.New(nil)
|
|
if err != nil {
|
|
t.Fatalf("failed to create cookiejar: %s", err)
|
|
}
|
|
|
|
return &Tester{
|
|
Client: &http.Client{
|
|
Transport: CreateTestingTransport(),
|
|
Jar: jar,
|
|
Timeout: Default.TestRequestTimeout,
|
|
},
|
|
configLoaded: false,
|
|
t: t,
|
|
}
|
|
}
|
|
|
|
type configLoadError struct {
|
|
Response string
|
|
}
|
|
|
|
func (e configLoadError) Error() string { return e.Response }
|
|
|
|
func timeElapsed(start time.Time, name string) {
|
|
elapsed := time.Since(start)
|
|
log.Printf("%s took %s", name, elapsed)
|
|
}
|
|
|
|
// InitServer this will configure the server with a configurion of a specific
|
|
// type. The configType must be either "json" or the adapter type.
|
|
func (tc *Tester) InitServer(rawConfig string, configType string) {
|
|
if err := tc.initServer(rawConfig, configType); err != nil {
|
|
tc.t.Logf("failed to load config: %s", err)
|
|
tc.t.Fail()
|
|
}
|
|
if err := tc.ensureConfigRunning(rawConfig, configType); err != nil {
|
|
tc.t.Logf("failed ensuring config is running: %s", err)
|
|
tc.t.Fail()
|
|
}
|
|
}
|
|
|
|
// InitServer this will configure the server with a configurion of a specific
|
|
// type. The configType must be either "json" or the adapter type.
|
|
func (tc *Tester) initServer(rawConfig string, configType string) error {
|
|
if testing.Short() {
|
|
tc.t.SkipNow()
|
|
return nil
|
|
}
|
|
|
|
err := validateTestPrerequisites(tc.t)
|
|
if err != nil {
|
|
tc.t.Skipf("skipping tests as failed integration prerequisites. %s", err)
|
|
return nil
|
|
}
|
|
|
|
tc.t.Cleanup(func() {
|
|
if tc.t.Failed() && tc.configLoaded {
|
|
res, err := http.Get(fmt.Sprintf("http://localhost:%d/config/", Default.AdminPort))
|
|
if err != nil {
|
|
tc.t.Log("unable to read the current config")
|
|
return
|
|
}
|
|
defer res.Body.Close()
|
|
body, _ := io.ReadAll(res.Body)
|
|
|
|
var out bytes.Buffer
|
|
_ = json.Indent(&out, body, "", " ")
|
|
tc.t.Logf("----------- failed with config -----------\n%s", out.String())
|
|
}
|
|
})
|
|
|
|
rawConfig = prependCaddyFilePath(rawConfig)
|
|
client := &http.Client{
|
|
Timeout: Default.LoadRequestTimeout,
|
|
}
|
|
start := time.Now()
|
|
req, err := http.NewRequest("POST", fmt.Sprintf("http://localhost:%d/load", Default.AdminPort), strings.NewReader(rawConfig))
|
|
if err != nil {
|
|
tc.t.Errorf("failed to create request. %s", err)
|
|
return err
|
|
}
|
|
|
|
if configType == "json" {
|
|
req.Header.Add("Content-Type", "application/json")
|
|
} else {
|
|
req.Header.Add("Content-Type", "text/"+configType)
|
|
}
|
|
|
|
res, err := client.Do(req)
|
|
if err != nil {
|
|
tc.t.Errorf("unable to contact caddy server. %s", err)
|
|
return err
|
|
}
|
|
timeElapsed(start, "caddytest: config load time")
|
|
|
|
defer res.Body.Close()
|
|
body, err := io.ReadAll(res.Body)
|
|
if err != nil {
|
|
tc.t.Errorf("unable to read response. %s", err)
|
|
return err
|
|
}
|
|
|
|
if res.StatusCode != 200 {
|
|
return configLoadError{Response: string(body)}
|
|
}
|
|
|
|
tc.configLoaded = true
|
|
return nil
|
|
}
|
|
|
|
func (tc *Tester) ensureConfigRunning(rawConfig string, configType string) error {
|
|
expectedBytes := []byte(prependCaddyFilePath(rawConfig))
|
|
if configType != "json" {
|
|
adapter := caddyconfig.GetAdapter(configType)
|
|
if adapter == nil {
|
|
return fmt.Errorf("adapter of config type is missing: %s", configType)
|
|
}
|
|
expectedBytes, _, _ = adapter.Adapt([]byte(rawConfig), nil)
|
|
}
|
|
|
|
var expected any
|
|
err := json.Unmarshal(expectedBytes, &expected)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
client := &http.Client{
|
|
Timeout: Default.LoadRequestTimeout,
|
|
}
|
|
|
|
fetchConfig := func(client *http.Client) any {
|
|
resp, err := client.Get(fmt.Sprintf("http://localhost:%d/config/", Default.AdminPort))
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
defer resp.Body.Close()
|
|
actualBytes, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var actual any
|
|
err = json.Unmarshal(actualBytes, &actual)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return actual
|
|
}
|
|
|
|
for retries := 10; retries > 0; retries-- {
|
|
if reflect.DeepEqual(expected, fetchConfig(client)) {
|
|
return nil
|
|
}
|
|
time.Sleep(1 * time.Second)
|
|
}
|
|
tc.t.Errorf("POSTed configuration isn't active")
|
|
return errors.New("EnsureConfigRunning: POSTed configuration isn't active")
|
|
}
|
|
|
|
const initConfig = `{
|
|
admin localhost:2999
|
|
}
|
|
`
|
|
|
|
// validateTestPrerequisites ensures the certificates are available in the
|
|
// designated path and Caddy sub-process is running.
|
|
func validateTestPrerequisites(t testing.TB) error {
|
|
// check certificates are found
|
|
for _, certName := range Default.Certifcates {
|
|
if _, err := os.Stat(getIntegrationDir() + certName); errors.Is(err, fs.ErrNotExist) {
|
|
return fmt.Errorf("caddy integration test certificates (%s) not found", certName)
|
|
}
|
|
}
|
|
|
|
if isCaddyAdminRunning() != nil {
|
|
// setup the init config file, and set the cleanup afterwards
|
|
f, err := os.CreateTemp("", "")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
t.Cleanup(func() {
|
|
os.Remove(f.Name())
|
|
})
|
|
if _, err := f.WriteString(initConfig); err != nil {
|
|
return err
|
|
}
|
|
|
|
// start inprocess caddy server
|
|
os.Args = []string{"caddy", "run", "--config", f.Name(), "--adapter", "caddyfile"}
|
|
go func() {
|
|
caddycmd.Main()
|
|
}()
|
|
|
|
// wait for caddy to start serving the initial config
|
|
for retries := 10; retries > 0 && isCaddyAdminRunning() != nil; retries-- {
|
|
time.Sleep(1 * time.Second)
|
|
}
|
|
}
|
|
|
|
// one more time to return the error
|
|
return isCaddyAdminRunning()
|
|
}
|
|
|
|
func isCaddyAdminRunning() error {
|
|
// assert that caddy is running
|
|
client := &http.Client{
|
|
Timeout: Default.LoadRequestTimeout,
|
|
}
|
|
resp, err := client.Get(fmt.Sprintf("http://localhost:%d/config/", Default.AdminPort))
|
|
if err != nil {
|
|
return fmt.Errorf("caddy integration test caddy server not running. Expected to be listening on localhost:%d", Default.AdminPort)
|
|
}
|
|
resp.Body.Close()
|
|
|
|
return nil
|
|
}
|
|
|
|
func getIntegrationDir() string {
|
|
_, filename, _, ok := runtime.Caller(1)
|
|
if !ok {
|
|
panic("unable to determine the current file path")
|
|
}
|
|
|
|
return path.Dir(filename)
|
|
}
|
|
|
|
// use the convention to replace /[certificatename].[crt|key] with the full path
|
|
// this helps reduce the noise in test configurations and also allow this
|
|
// to run in any path
|
|
func prependCaddyFilePath(rawConfig string) string {
|
|
r := matchKey.ReplaceAllString(rawConfig, getIntegrationDir()+"$1")
|
|
r = matchCert.ReplaceAllString(r, getIntegrationDir()+"$1")
|
|
return r
|
|
}
|
|
|
|
// CreateTestingTransport creates a testing transport that forces call dialing connections to happen locally
|
|
func CreateTestingTransport() *http.Transport {
|
|
dialer := net.Dialer{
|
|
Timeout: 5 * time.Second,
|
|
KeepAlive: 5 * time.Second,
|
|
DualStack: true,
|
|
}
|
|
|
|
dialContext := func(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
parts := strings.Split(addr, ":")
|
|
destAddr := fmt.Sprintf("127.0.0.1:%s", parts[1])
|
|
log.Printf("caddytest: redirecting the dialer from %s to %s", addr, destAddr)
|
|
return dialer.DialContext(ctx, network, destAddr)
|
|
}
|
|
|
|
return &http.Transport{
|
|
Proxy: http.ProxyFromEnvironment,
|
|
DialContext: dialContext,
|
|
ForceAttemptHTTP2: true,
|
|
MaxIdleConns: 100,
|
|
IdleConnTimeout: 90 * time.Second,
|
|
TLSHandshakeTimeout: 5 * time.Second,
|
|
ExpectContinueTimeout: 1 * time.Second,
|
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec
|
|
}
|
|
}
|
|
|
|
// AssertLoadError will load a config and expect an error
|
|
func AssertLoadError(t *testing.T, rawConfig string, configType string, expectedError string) {
|
|
tc := NewTester(t)
|
|
|
|
err := tc.initServer(rawConfig, configType)
|
|
if !strings.Contains(err.Error(), expectedError) {
|
|
t.Errorf("expected error \"%s\" but got \"%s\"", expectedError, err.Error())
|
|
}
|
|
}
|
|
|
|
// AssertRedirect makes a request and asserts the redirection happens
|
|
func (tc *Tester) AssertRedirect(requestURI string, expectedToLocation string, expectedStatusCode int) *http.Response {
|
|
redirectPolicyFunc := func(req *http.Request, via []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
}
|
|
|
|
// using the existing client, we override the check redirect policy for this test
|
|
old := tc.Client.CheckRedirect
|
|
tc.Client.CheckRedirect = redirectPolicyFunc
|
|
defer func() { tc.Client.CheckRedirect = old }()
|
|
|
|
resp, err := tc.Client.Get(requestURI)
|
|
if err != nil {
|
|
tc.t.Errorf("failed to call server %s", err)
|
|
return nil
|
|
}
|
|
|
|
if expectedStatusCode != resp.StatusCode {
|
|
tc.t.Errorf("requesting \"%s\" expected status code: %d but got %d", requestURI, expectedStatusCode, resp.StatusCode)
|
|
}
|
|
|
|
loc, err := resp.Location()
|
|
if err != nil {
|
|
tc.t.Errorf("requesting \"%s\" expected location: \"%s\" but got error: %s", requestURI, expectedToLocation, err)
|
|
}
|
|
if loc == nil && expectedToLocation != "" {
|
|
tc.t.Errorf("requesting \"%s\" expected a Location header, but didn't get one", requestURI)
|
|
}
|
|
if loc != nil {
|
|
if expectedToLocation != loc.String() {
|
|
tc.t.Errorf("requesting \"%s\" expected location: \"%s\" but got \"%s\"", requestURI, expectedToLocation, loc.String())
|
|
}
|
|
}
|
|
|
|
return resp
|
|
}
|
|
|
|
// CompareAdapt adapts a config and then compares it against an expected result
|
|
func CompareAdapt(t testing.TB, filename, rawConfig string, adapterName string, expectedResponse string) bool {
|
|
cfgAdapter := caddyconfig.GetAdapter(adapterName)
|
|
if cfgAdapter == nil {
|
|
t.Logf("unrecognized config adapter '%s'", adapterName)
|
|
return false
|
|
}
|
|
|
|
options := make(map[string]any)
|
|
|
|
result, warnings, err := cfgAdapter.Adapt([]byte(rawConfig), options)
|
|
if err != nil {
|
|
t.Logf("adapting config using %s adapter: %v", adapterName, err)
|
|
return false
|
|
}
|
|
|
|
// prettify results to keep tests human-manageable
|
|
var prettyBuf bytes.Buffer
|
|
err = json.Indent(&prettyBuf, result, "", "\t")
|
|
if err != nil {
|
|
return false
|
|
}
|
|
result = prettyBuf.Bytes()
|
|
|
|
if len(warnings) > 0 {
|
|
for _, w := range warnings {
|
|
t.Logf("warning: %s:%d: %s: %s", filename, w.Line, w.Directive, w.Message)
|
|
}
|
|
}
|
|
|
|
diff := difflib.Diff(
|
|
strings.Split(expectedResponse, "\n"),
|
|
strings.Split(string(result), "\n"))
|
|
|
|
// scan for failure
|
|
failed := false
|
|
for _, d := range diff {
|
|
if d.Delta != difflib.Common {
|
|
failed = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if failed {
|
|
for _, d := range diff {
|
|
switch d.Delta {
|
|
case difflib.Common:
|
|
fmt.Printf(" %s\n", d.Payload)
|
|
case difflib.LeftOnly:
|
|
fmt.Printf(" - %s\n", d.Payload)
|
|
case difflib.RightOnly:
|
|
fmt.Printf(" + %s\n", d.Payload)
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// AssertAdapt adapts a config and then tests it against an expected result
|
|
func AssertAdapt(t testing.TB, rawConfig string, adapterName string, expectedResponse string) {
|
|
ok := CompareAdapt(t, "Caddyfile", rawConfig, adapterName, expectedResponse)
|
|
if !ok {
|
|
t.Fail()
|
|
}
|
|
}
|
|
|
|
// Generic request functions
|
|
|
|
func applyHeaders(t testing.TB, req *http.Request, requestHeaders []string) {
|
|
requestContentType := ""
|
|
for _, requestHeader := range requestHeaders {
|
|
arr := strings.SplitAfterN(requestHeader, ":", 2)
|
|
k := strings.TrimRight(arr[0], ":")
|
|
v := strings.TrimSpace(arr[1])
|
|
if k == "Content-Type" {
|
|
requestContentType = v
|
|
}
|
|
t.Logf("Request header: %s => %s", k, v)
|
|
req.Header.Set(k, v)
|
|
}
|
|
|
|
if requestContentType == "" {
|
|
t.Logf("Content-Type header not provided")
|
|
}
|
|
}
|
|
|
|
// AssertResponseCode will execute the request and verify the status code, returns a response for additional assertions
|
|
func (tc *Tester) AssertResponseCode(req *http.Request, expectedStatusCode int) *http.Response {
|
|
resp, err := tc.Client.Do(req)
|
|
if err != nil {
|
|
tc.t.Fatalf("failed to call server %s", err)
|
|
}
|
|
|
|
if expectedStatusCode != resp.StatusCode {
|
|
tc.t.Errorf("requesting \"%s\" expected status code: %d but got %d", req.URL.RequestURI(), expectedStatusCode, resp.StatusCode)
|
|
}
|
|
|
|
return resp
|
|
}
|
|
|
|
// AssertResponse request a URI and assert the status code and the body contains a string
|
|
func (tc *Tester) AssertResponse(req *http.Request, expectedStatusCode int, expectedBody string) (*http.Response, string) {
|
|
resp := tc.AssertResponseCode(req, expectedStatusCode)
|
|
|
|
defer resp.Body.Close()
|
|
bytes, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
tc.t.Fatalf("unable to read the response body %s", err)
|
|
}
|
|
|
|
body := string(bytes)
|
|
|
|
if body != expectedBody {
|
|
tc.t.Errorf("requesting \"%s\" expected response body \"%s\" but got \"%s\"", req.RequestURI, expectedBody, body)
|
|
}
|
|
|
|
return resp, body
|
|
}
|
|
|
|
// Verb specific test functions
|
|
|
|
// AssertGetResponse GET a URI and expect a statusCode and body text
|
|
func (tc *Tester) AssertGetResponse(requestURI string, expectedStatusCode int, expectedBody string) (*http.Response, string) {
|
|
req, err := http.NewRequest("GET", requestURI, nil)
|
|
if err != nil {
|
|
tc.t.Fatalf("unable to create request %s", err)
|
|
}
|
|
|
|
return tc.AssertResponse(req, expectedStatusCode, expectedBody)
|
|
}
|
|
|
|
// AssertDeleteResponse request a URI and expect a statusCode and body text
|
|
func (tc *Tester) AssertDeleteResponse(requestURI string, expectedStatusCode int, expectedBody string) (*http.Response, string) {
|
|
req, err := http.NewRequest("DELETE", requestURI, nil)
|
|
if err != nil {
|
|
tc.t.Fatalf("unable to create request %s", err)
|
|
}
|
|
|
|
return tc.AssertResponse(req, expectedStatusCode, expectedBody)
|
|
}
|
|
|
|
// AssertPostResponseBody POST to a URI and assert the response code and body
|
|
func (tc *Tester) AssertPostResponseBody(requestURI string, requestHeaders []string, requestBody *bytes.Buffer, expectedStatusCode int, expectedBody string) (*http.Response, string) {
|
|
req, err := http.NewRequest("POST", requestURI, requestBody)
|
|
if err != nil {
|
|
tc.t.Errorf("failed to create request %s", err)
|
|
return nil, ""
|
|
}
|
|
|
|
applyHeaders(tc.t, req, requestHeaders)
|
|
|
|
return tc.AssertResponse(req, expectedStatusCode, expectedBody)
|
|
}
|
|
|
|
// AssertPutResponseBody PUT to a URI and assert the response code and body
|
|
func (tc *Tester) AssertPutResponseBody(requestURI string, requestHeaders []string, requestBody *bytes.Buffer, expectedStatusCode int, expectedBody string) (*http.Response, string) {
|
|
req, err := http.NewRequest("PUT", requestURI, requestBody)
|
|
if err != nil {
|
|
tc.t.Errorf("failed to create request %s", err)
|
|
return nil, ""
|
|
}
|
|
|
|
applyHeaders(tc.t, req, requestHeaders)
|
|
|
|
return tc.AssertResponse(req, expectedStatusCode, expectedBody)
|
|
}
|
|
|
|
// AssertPatchResponseBody PATCH to a URI and assert the response code and body
|
|
func (tc *Tester) AssertPatchResponseBody(requestURI string, requestHeaders []string, requestBody *bytes.Buffer, expectedStatusCode int, expectedBody string) (*http.Response, string) {
|
|
req, err := http.NewRequest("PATCH", requestURI, requestBody)
|
|
if err != nil {
|
|
tc.t.Errorf("failed to create request %s", err)
|
|
return nil, ""
|
|
}
|
|
|
|
applyHeaders(tc.t, req, requestHeaders)
|
|
|
|
return tc.AssertResponse(req, expectedStatusCode, expectedBody)
|
|
}
|