diff --git a/Makefile b/Makefile index b773558..60bc9ce 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,7 @@ -.PHONY: build +.PHONY: build acceptance + build: - go build -ldflags "-s -w -X 'github.com/friendsofgo/killgrave/internal/app/cmd._version=`git rev-parse --abbrev-ref HEAD`-`git rev-parse --short HEAD`'" -o bin/killgrave cmd/killgrave/main.go \ No newline at end of file + go build -ldflags "-s -w -X 'github.com/friendsofgo/killgrave/internal/app/cmd._version=`git rev-parse --abbrev-ref HEAD`-`git rev-parse --short HEAD`'" -o bin/killgrave cmd/killgrave/main.go + +acceptance: build + @(cd acceptance && go test -count=1 -v ./...) \ No newline at end of file diff --git a/acceptance/acceptance_test.go b/acceptance/acceptance_test.go index bab2d04..a8d665c 100644 --- a/acceptance/acceptance_test.go +++ b/acceptance/acceptance_test.go @@ -3,13 +3,18 @@ package acceptance import ( "bufio" "bytes" + "errors" "fmt" "io" + "io/fs" + "log" "net/http" "net/url" "os" "os/exec" "path/filepath" + "regexp" + "strconv" "strings" "testing" "time" @@ -17,78 +22,150 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/tools/txtar" + + "github.com/friendsofgo/killgrave/acceptance/utils/network" ) -const addr = "http://localhost:3000" +const ( + // testsDir is the directory, within the `acceptance` folder, + // where the acceptance tests are located at. + testsDir = "tests" + + // addr is the address where the Killgrave binary + addr = "localhost" + + // bin is the path to where the Killgrave binary + // is expected to run acceptance tests. + bin = "../bin/killgrave" +) +// Test is the entry point for the acceptance tests. func Test(t *testing.T) { - // For every test directory + // First of all, we extract the Killgrave version by running `killgrave version`. + // This is useful, not only to write a log that can serve as metadata for the test results, + // but also to ensure that the Killgrave binary is available. + version, err := extractKillgraveVersion(t) + if err != nil { + var pathErr *fs.PathError + if errors.As(err, &pathErr) || strings.Contains(err.Error(), "executable file not found in $PATH") { + log.Fatalf("Attention! It looks like you haven't compiled Killgrave, the execution of the Killgrave "+ + "binary has failed with: %v", err) + } + log.Fatalf("The execution of `killgrave version` has failed with: %v", err) + } + t.Logf("Running acceptance tests with Killgrave version: %s", version) + + // Once we now that the Killgrave binary is available, we can proceed with the acceptance tests. + // The first step is to collect all test cases from the `tests` directory. For each test: + // + // 0. The test case self-contain on each directory, which name is used as the test name. + // 1. Requires a `config.txtar` file at the root level of the test case directory, + // it is used to initialize a file system with all the configuration-related files, + // which not only includes the Killgrave configuration file but also the imposters. + // 2. Runs Killgrave in any available port (so we can run multiple test cases at the same + // time), using the configuration files from the previous step. + // 3. Requires an `http` directory which contains a set of request and response pairs. + // Each pair is defined by two files: `req.http` and `res.http`, where the request + // is the HTTP request that will be performed as part of the test, and the response + // is the response expected from Killgrave (which will be asserted). + // Each pair is considered one of the test cases that compose the acceptance test, + // defined by the aforementioned parent directory. tcs := collectTestCases(t) for _, tc := range tcs { tc := tc t.Run(tc.name, func(t *testing.T) { - // Prepare the config directory - path, clean := createTmpCfgDir(t, tc) - t.Cleanup(clean) + t.Parallel() - // Start the application - stop := runApplication(t, path) - t.Cleanup(stop) + // 1. Create a temporary directory with the configuration files. + path := createTmpCfgDir(t, tc) - // For every request and response pair + // 2. Start the Killgrave process. + address := runApplication(t, path) + + // 3. Collect the request and response pairs + // and iterate over them to perform the tests. rrs := collectRequestResponses(t, tc.path) for _, rr := range rrs { rr := rr t.Run(rr.name, func(t *testing.T) { + // Override the address + rr.overrideAddress(address) + // Send the request res, err := http.DefaultClient.Do(rr.req) require.NoError(t, err) // Assert the res - rr.assertResponse(res) + rr.assertResponse(t, res) }) } }) } } -type tc struct { +// testCase represents a test case to be run. +// It is defined by the name of the test case and the path +// to the directory where the testCase files live in. +type testCase struct { name string path string } -func collectTestCases(t *testing.T) (cases []tc) { +// collectTestCases walks over the `tests` directory and +// constructs all the testCase's from the directories found. +func collectTestCases(t *testing.T) []testCase { + var tcs []testCase + cwd, err := os.Getwd() require.NoError(t, err) - testsDir := filepath.Join(cwd, "tests") + testsDir := filepath.Join(cwd, testsDir) entries, err := os.ReadDir(testsDir) require.NoError(t, err) for _, entry := range entries { if entry.IsDir() { - cases = append(cases, tc{ + tcs = append(tcs, testCase{ name: entry.Name(), path: filepath.Join(testsDir, entry.Name()), }) } } - return + return tcs } -type rr struct { - *testing.T +// reqRes is a data structure that holds the information required to run +// each of the test cases that compose each acceptance test: +// - the name of the test case. +// - the request to be sent to Killgrave, as *http.Request. +// - the expected response from Killgrave, as []byte. +type reqRes struct { name string req *http.Request res []byte } -func (rr rr) assertResponse(response *http.Response) { +// overrideAddress changes the request's URL to use the provided address. +// This is useful to run the tests against different addresses, e.g. different ports, +// which is a requirement to be able to run the acceptance tests concurrently. +func (rr reqRes) overrideAddress(address string) { + rr.req.URL.Scheme = "http" + rr.req.URL.Host = address +} + +// assertResponse is a self-contained function that can be used to assert +// that the response received from Killgrave matches the expected response. +// +// It builds the response string from the response object, and then it +// compares it with the expected response (from the test definition). +func (rr reqRes) assertResponse(t *testing.T, response *http.Response) { + t.Helper() + // Read the response body body, err := io.ReadAll(response.Body) - require.NoError(rr, err) + require.NoError(t, err) // Format the status line statusLine := fmt.Sprintf("HTTP/%d.%d %s", response.ProtoMajor, response.ProtoMinor, response.Status) @@ -99,15 +176,17 @@ func (rr rr) assertResponse(response *http.Response) { // Format the headers var headersBuilder strings.Builder err = response.Header.Write(&headersBuilder) - require.NoError(rr, err) + require.NoError(t, err) headers := strings.ReplaceAll(headersBuilder.String(), "\r\n", "\n") // Format the response res := fmt.Sprintf("%s\n%s\n\n%s", statusLine, headers, body) - assert.Equal(rr, string(rr.res), res) + assert.Equal(t, string(rr.res), res) } -func collectRequestResponses(t *testing.T, path string) (rrs []rr) { +// collectRequestResponses walks over the `http` directory of the test case +// and collects all the reqRes pairs. In other words, it collects all the test cases. +func collectRequestResponses(t *testing.T, path string) (rrs []reqRes) { httpDir := filepath.Join(path, "http") entries, err := os.ReadDir(httpDir) require.NoError(t, err) @@ -124,8 +203,7 @@ func collectRequestResponses(t *testing.T, path string) (rrs []rr) { archive := txtar.Parse(contents) - rrs = append(rrs, rr{ - T: t, + rrs = append(rrs, reqRes{ name: entry.Name(), req: readRequest(t, find(archive.Files, "req.http")), res: find(archive.Files, "res.http"), @@ -141,10 +219,11 @@ func find(ff []txtar.File, name string) []byte { return f.Data } } - return nil } +// readRequest reads a raw HTTP request from a []byte (e.g. read from a file), +// and instantiates the equivalent *http.Request object from it. func readRequest(t *testing.T, raw []byte) *http.Request { req, err := http.ReadRequest(bufio.NewReader(bytes.NewReader(raw))) require.NoError(t, err) @@ -158,16 +237,24 @@ func readRequest(t *testing.T, raw []byte) *http.Request { return req } -func createTmpCfgDir(t *testing.T, tc tc) (string, func()) { +// createTmpCfgDir creates a temporary directory with the configuration files defined +// in the `config.txtar` file, replicating the structure to be used by Killgrave, when executed. +// +// We follow this approach because this way we can test the application assuming the binary +// already exists, so these tests can be run with a recently generated binary (e.g. a release). +// +// Additionally, in the future we might explore ways to reuse this setup to run these tests +// as "integration tests", so faking using a fake, likely in-memory, file system but directly +// calling app.Run(). +func createTmpCfgDir(t *testing.T, tc testCase) string { + // First, we read the `config.txtar` file and initialize a txtar.Archive with its contents. tmpCfgDir := filepath.Join(os.TempDir(), tc.name) - cfgFilePath := filepath.Join(tc.path, "config.txtar") - contents, err := os.ReadFile(cfgFilePath) require.NoError(t, err) - archive := txtar.Parse(contents) + // Then, we create the temporary directory and write the files. for _, f := range archive.Files { filePath := filepath.Join(tmpCfgDir, f.Name) fileDir := filepath.Dir(filePath) @@ -179,27 +266,83 @@ func createTmpCfgDir(t *testing.T, tc tc) (string, func()) { require.NoError(t, err) } - return tmpCfgDir, func() { + // Tell the testing framework to clean up the temporary directory after the test is done. + t.Cleanup(func() { err := os.RemoveAll(tmpCfgDir) require.NoError(t, err) - } + }) + + return tmpCfgDir } -func runApplication(t *testing.T, from string) func() { - cmd := exec.Command("killgrave", "--imposters", filepath.Join(from, "imposters")) +// runApplication runs Killgrave assuming the binary already exists. +// It uses the imposters located at `from`, which path must be absolute. +// It runs the application on any available port, so we can run multiple +// tests concurrently. It returns the address as the first return value. +// +// For now, it redirects the application's output (stdout and stderr) +// to the test's output, but in the future, we might want to capture +// the output to assert the logs, and or use it in a smarter way. +func runApplication(t *testing.T, from string) string { + // Look for any available port. + port, err := network.AnyAvailablePort() + address := addr + ":" + strconv.Itoa(port) + require.NoError(t, err, "failed to find an available port") + + // Prepare the `killgrave` command, and start it. + cmd := exec.Command(bin, "-P", strconv.Itoa(port), "--imposters", filepath.Join(from, "imposters")) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr - err := cmd.Start() + err = cmd.Start() require.NoError(t, err) - // Trick to give time to the app to start - time.Sleep(1 * time.Second) - - return func() { + // Tell the testing framework to stop the process after the test is done. + t.Cleanup(func() { err := cmd.Process.Signal(os.Interrupt) require.NoError(t, err) err = cmd.Wait() require.NoError(t, err) + }) + + // Wait for the application to be ready. + const ( + maxWaitTime = 2 * time.Second + checkEvery = 100 * time.Millisecond + ) + require.Eventually(t, func() bool { + res, err := http.Get("http://" + address + "/nonExistingEndpoint") + return err == nil && res != nil && res.StatusCode == http.StatusNotFound + }, maxWaitTime, checkEvery) + + return address +} + +// extractKillgraveVersion runs the `killgrave version` command and uses a regular expression +// to extract the version from the output. In case there's any error (e.g. the binary is not +// available), it returns the error. +func extractKillgraveVersion(t *testing.T) (string, error) { + t.Helper() + + // Prepare the `killgrave version` command. + cmd := exec.Command(bin, "version", "-v") + + // Capture the command's output. + out := new(bytes.Buffer) + cmd.Stdout = out + + // Run the command, and check for errors. + err := cmd.Run() + if err != nil { + return "", err } + + // Extract the Killgrave version from the output. + re := regexp.MustCompile(`Killgrave version:\s*([a-zA-Z0-9\-]+)`) + match := re.FindStringSubmatch(out.String()) + if len(match) == 2 { + return match[1], nil + } + + return "", errors.New("version not found") } diff --git a/acceptance/tests/simple/config.txtar b/acceptance/tests/simple/config.txtar index b0aee7c..a857747 100644 --- a/acceptance/tests/simple/config.txtar +++ b/acceptance/tests/simple/config.txtar @@ -1,10 +1,25 @@ --- imposters/create_gopher.imp.json -- +-- imposters/gophers.imp.json -- [ + { + "request": { + "method": "GET", + "endpoint": "/gophers/01D8EMQ185CA8PRGE20DKZTGSR", + "headers": { + "Content-Type": "application/json" + } + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "bodyFile": "responses/create_gopher_response.json" + } + }, { "request": { "method": "POST", "endpoint": "/gophers", - "schemaFile": "schemas/create_gopher_request.json", "headers": { "Content-Type": "application/json" }, @@ -13,60 +28,29 @@ } }, "response": { - "status": 200, + "status": 201, "headers": { "Content-Type": "application/json" }, "bodyFile": "responses/create_gopher_response.json" } }, + { + "request": { + "method": "GET", + "endpoint": "/gophers/{_id:[\\w]{26}}", + "headers": { + "Content-Type": "application/json" + } + }, + "response": { + "status": 404 + } + }, { "t": "random_text" } ] --- imposters/schemas/create_gopher_request.json -- -{ - "type": "object", - "properties": { - "data": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "gophers" - ] - }, - "attributes": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "color": { - "type": "string" - }, - "age": { - "type": "integer" - } - }, - "required": [ - "name", - "color", - "age" - ] - } - }, - "required": [ - "type", - "attributes" - ] - } - }, - "required": [ - "data" - ] -} -- imposters/responses/create_gopher_response.json -- { "data": { diff --git a/acceptance/tests/simple/http/simple.txtar b/acceptance/tests/simple/http/create.txtar similarity index 95% rename from acceptance/tests/simple/http/simple.txtar rename to acceptance/tests/simple/http/create.txtar index ca39c6a..4444642 100644 --- a/acceptance/tests/simple/http/simple.txtar +++ b/acceptance/tests/simple/http/create.txtar @@ -6,7 +6,7 @@ Content-Type: application/json {"data": {"type": "gophers", "attributes": {"name": "Zebediah", "color": "Purple", "age": 54}}} -- res.http -- -HTTP/1.1 200 OK +HTTP/1.1 201 Created Content-Length: 214 Content-Type: application/json diff --git a/acceptance/tests/simple/http/fetchExisting.txtar b/acceptance/tests/simple/http/fetchExisting.txtar new file mode 100644 index 0000000..4a78d3f --- /dev/null +++ b/acceptance/tests/simple/http/fetchExisting.txtar @@ -0,0 +1,21 @@ +-- req.http -- +GET /gophers/01D8EMQ185CA8PRGE20DKZTGSR HTTP/1.1 +Content-Type: application/json + +-- res.http -- +HTTP/1.1 200 OK +Content-Length: 214 +Content-Type: application/json + + +{ + "data": { + "type": "gophers", + "id": "01D8EMQ185CA8PRGE20DKZTGSR", + "attributes": { + "name": "Zebediah", + "color": "Purple", + "age": 54 + } + } +} diff --git a/acceptance/tests/simple/http/fetchNotFound.txtar b/acceptance/tests/simple/http/fetchNotFound.txtar new file mode 100644 index 0000000..702a1e0 --- /dev/null +++ b/acceptance/tests/simple/http/fetchNotFound.txtar @@ -0,0 +1,9 @@ +-- req.http -- +GET /gophers/01D8EMQ185CA8PRGE20DKZT404 HTTP/1.1 +Content-Type: application/json + +-- res.http -- +HTTP/1.1 404 Not Found +Content-Length: 0 + + diff --git a/acceptance/utils/network/network.go b/acceptance/utils/network/network.go new file mode 100644 index 0000000..2b5f77c --- /dev/null +++ b/acceptance/utils/network/network.go @@ -0,0 +1,19 @@ +package network + +import "net" + +func AnyAvailablePort() (int, error) { + // Create a new TCP listener on port 0 (which means "any available port") + listener, err := net.Listen("tcp", ":0") + if err != nil { + return 0, err + } + + // Extract the port number from the listener address + port := listener.Addr().(*net.TCPAddr).Port + + // Close the listener to free up the port + err = listener.Close() + + return port, err +} diff --git a/go.mod b/go.mod index a14f6b3..96ef1bf 100644 --- a/go.mod +++ b/go.mod @@ -1,26 +1,29 @@ module github.com/friendsofgo/killgrave -go 1.21 +go 1.22.0 + +toolchain go1.22.3 require ( - github.com/gorilla/handlers v1.5.1 + github.com/gorilla/handlers v1.5.2 github.com/gorilla/mux v1.8.1 github.com/radovskyb/watcher v1.0.7 github.com/spf13/cobra v1.8.1 github.com/stretchr/testify v1.9.0 github.com/xeipuuv/gojsonschema v1.2.0 + golang.org/x/tools v0.27.0 gopkg.in/yaml.v2 v2.4.0 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect - github.com/felixge/httpsnoop v1.0.1 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect - gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index aa67312..58d599a 100644 --- a/go.sum +++ b/go.sum @@ -3,16 +3,19 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/felixge/httpsnoop v1.0.1 h1:lvB5Jl89CsZtGIWuTcDM1E/vkVs49/Ml7JJe07l8SPQ= -github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= -github.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH4= -github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= +github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= @@ -38,9 +41,11 @@ github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHo github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= +golang.org/x/tools v0.27.0 h1:qEKojBykQkQ4EynWy4S8Weg69NumxKdn40Fce3uc/8o= +golang.org/x/tools v0.27.0/go.mod h1:sUi0ZgbwW9ZPAq26Ekut+weQPR5eIM6GQLQ1Yjm1H0Q= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=