| // Copyright 2021 The Go Authors. All rights reserved. | |
| // Use of this source code is governed by a BSD-style | |
| // license that can be found in the LICENSE file. | |
| // Package webtest implements script-based testing for web servers. | |
| // | |
| // The scripts, described below, can be run against http.Handler | |
| // implementations or against running servers. Testing against an | |
| // http.Handler makes it easier to test handlers serving multiple sites | |
| // as well as scheme-based features like redirecting to HTTPS. | |
| // Testing against a running server provides a more complete end-to-end test. | |
| // | |
| // The test functions TestHandler and TestServer take a *testing.T | |
| // and a glob pattern, which must match at least one file. | |
| // They create a subtest of the top-level test for each script. | |
| // Within each per-script subtest, they create a per-case subtest | |
| // for each case in the script, making it easy to run selected cases. | |
| // | |
| // The functions CheckHandler and CheckServer are similar but do | |
| // not require a *testing.T, making them suitable for use in other contexts. | |
| // They run the entire script and return a multiline error summarizing | |
| // any problems. | |
| // | |
| // # Scripts | |
| // | |
| // A script is a text file containing a sequence of cases, separated by blank lines. | |
| // Lines beginning with # characters are ignored as comments. | |
| // A case is a sequence of lines describing a request, along with checks to be | |
| // applied to the response. For example, here is a trivial script: | |
| // | |
| // GET / | |
| // body contains Go is an open source programming language | |
| // | |
| // This script has a single case. The first line describes the request. | |
| // The second line describes a single check to be applied to the response. | |
| // In this case, the request is a GET of the URL /, and the response body | |
| // must contain the text “Go is an open source programming language”. | |
| // | |
| // # Requests | |
| // | |
| // Each case begins with a line starting with GET, HEAD, or POST. | |
| // The argument (the remainder of the line) is the URL to be used in the request. | |
| // Following this line, the request can be further customized using | |
| // lines of the form | |
| // | |
| // <verb> <text> | |
| // | |
| // where the verb is a single space-separated word and the text is arbitrary text | |
| // to the end of the line, or multiline text (described below). | |
| // | |
| // The possible values for <verb> are as follows. | |
| // | |
| // The verb “hint” specifies text to be printed if the test case fails, as a | |
| // hint about what might be wrong. | |
| // | |
| // The verbs “postbody”, “postquery”, and “posttype” customize a POST request. | |
| // | |
| // For example: | |
| // | |
| // POST /api | |
| // posttype application/json | |
| // postbody {"go": true} | |
| // | |
| // This describes a POST request with a posted Content-Type of “application/json” | |
| // and a body “{"go": true}”. | |
| // | |
| // The “postquery” verb specifies a post body in the form of a sequence of | |
| // key-value pairs, query-encoded and concatenated automatically as a | |
| // convenience. Using “postquery” also sets the default posted Content-Type | |
| // to “application/x-www-form-urlencoded”. | |
| // | |
| // For example: | |
| // | |
| // POST /api | |
| // postquery | |
| // x=hello world | |
| // y=Go & You | |
| // | |
| // This stanza sends a request with post body “x=hello+world&y=Go+%26+You”. | |
| // (The multiline syntax is described in detail below.) | |
| // | |
| // # Checks | |
| // | |
| // By default, a stanza like the ones above checks only that the request | |
| // succeeds in returning a response with HTTP status code 200 (OK). | |
| // Additional checks are specified by more lines of the form | |
| // | |
| // <value> [<key>] <op> <text> | |
| // | |
| // In the example above, <value> is “body”, there is no <key>, | |
| // <op> is “contains”, and <text> is “Go is an open source programming language”. | |
| // Whether there is a <key> depends on the <value>; “body” does not have one. | |
| // | |
| // The possible values for <value> are: | |
| // | |
| // body - the full response body | |
| // code - the HTTP status code | |
| // header <key> - the value in the header line with the given key | |
| // redirect - the target of a redirect, as found in the Location header | |
| // trimbody - the response body, trimmed | |
| // | |
| // If a case contains no check of “code”, then it defaults to checking that | |
| // the HTTP status code is 200, as described above, with one exception: | |
| // if the case contains a check of “redirect”, then the code is required to | |
| // be a 30x code. | |
| // | |
| // The “trimbody” value is the body with all runs of spaces and tabs | |
| // reduced to single spaces, leading and trailing spaces removed on | |
| // each line, and blank lines removed. | |
| // | |
| // The possible operators for <op> are: | |
| // | |
| // == - the value must be equal to the text | |
| // != - the value must not be equal to the text | |
| // ~ - the value must match the text interpreted as a regular expression | |
| // !~ - the value must not match the text interpreted as a regular expression | |
| // contains - the value must contain the text as a substring | |
| // !contains - the value must not contain the text as a substring | |
| // | |
| // For example: | |
| // | |
| // GET /change/75944e2e3a63 | |
| // hint no change redirect - hg to git mapping not registered? | |
| // code == 302 | |
| // redirect contains bdb10cf | |
| // body contains bdb10cf | |
| // body !contains UA- | |
| // | |
| // GET /pkg/net/http/httptrace/ | |
| // body ~ Got1xxResponse.*// Go 1\.11 | |
| // body ~ GotFirstResponseByte func\(\)\s*$ | |
| // | |
| // # Multiline Texts | |
| // | |
| // The <text> in a request or check line can take a multiline form, | |
| // by omitting it from the original line and then specifying the text | |
| // as one or more following lines, each indented by a single tab. | |
| // The text is taken to be the sequence of indented lines, including | |
| // the final newline, but with the leading tab removed from each. | |
| // | |
| // The “postquery” example above showed the multiline syntax. | |
| // Another common use is for multiline “body” checks. For example: | |
| // | |
| // GET /hello | |
| // body == | |
| // <!DOCTYPE html> | |
| // hello, world | |
| package webtest | |
| import ( | |
| "bytes" | |
| "errors" | |
| "fmt" | |
| "io" | |
| "io/fs" | |
| "net/http" | |
| "net/http/httptest" | |
| "net/url" | |
| "os" | |
| "path/filepath" | |
| "regexp" | |
| "strings" | |
| "testing" | |
| ) | |
| // HandlerWithCheck returns an http.Handler that responds to each request | |
| // by running the test script files matched by glob against the handler h. | |
| // If the tests pass, the returned http.Handler responds with status code 200. | |
| // If they fail, it prints the details and responds with status code 503 | |
| // (service unavailable). | |
| func HandlerWithCheck(h http.Handler, path string, fsys fs.FS, glob string) http.Handler { | |
| return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| if r.URL.Path == path { | |
| err := CheckHandler(fsys, glob, h) | |
| if err != nil { | |
| http.Error(w, "webtest.CheckHandler failed:\n"+err.Error()+"\n", http.StatusInternalServerError) | |
| } else { | |
| fmt.Fprintf(w, "ok\n") | |
| } | |
| return | |
| } | |
| h.ServeHTTP(w, r) | |
| }) | |
| } | |
| // CheckHandler runs the test script files in fsys matched by glob | |
| // against the handler h. If any errors are encountered, | |
| // CheckHandler returns an error listing the problems. | |
| func CheckHandler(fsys fs.FS, glob string, h http.Handler) error { | |
| return check(fsys, glob, func(c *case_) error { return c.runHandler(h) }) | |
| } | |
| func check(fsys fs.FS, glob string, do func(*case_) error) error { | |
| files, err := fs.Glob(fsys, glob) | |
| if err != nil { | |
| return err | |
| } | |
| if len(files) == 0 { | |
| return fmt.Errorf("no files match %#q", glob) | |
| } | |
| var buf bytes.Buffer | |
| for _, file := range files { | |
| data, err := fs.ReadFile(fsys, file) | |
| if err != nil { | |
| fmt.Fprintf(&buf, "# %s\n%v\n", file, err) | |
| continue | |
| } | |
| script, err := parseScript(file, string(data)) | |
| if err != nil { | |
| fmt.Fprintf(&buf, "# %s\n%v\n", file, err) | |
| continue | |
| } | |
| hdr := false | |
| for _, c := range script.cases { | |
| if err := do(c); err != nil { | |
| if !hdr { | |
| fmt.Fprintf(&buf, "# %s\n", file) | |
| hdr = true | |
| } | |
| fmt.Fprintf(&buf, "## %s %s\n", c.method, c.url) | |
| fmt.Fprintf(&buf, "%v\n", err) | |
| } | |
| } | |
| } | |
| if buf.Len() > 0 { | |
| return errors.New(buf.String()) | |
| } | |
| return nil | |
| } | |
| // TestHandler runs the test script files matched by glob | |
| // against the handler h. | |
| func TestHandler(t *testing.T, glob string, h http.Handler) { | |
| test(t, glob, func(c *case_) error { return c.runHandler(h) }) | |
| } | |
| func test(t *testing.T, glob string, do func(*case_) error) { | |
| files, err := filepath.Glob(glob) | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| if len(files) == 0 { | |
| t.Fatalf("no files match %#q", glob) | |
| } | |
| for _, file := range files { | |
| t.Run(filepath.Base(file), func(t *testing.T) { | |
| data, err := os.ReadFile(file) | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| script, err := parseScript(file, string(data)) | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| for _, c := range script.cases { | |
| t.Run(c.method+"/"+strings.TrimPrefix(c.url, "/"), func(t *testing.T) { | |
| if err := do(c); err != nil { | |
| t.Fatal(err) | |
| } | |
| }) | |
| } | |
| }) | |
| } | |
| } | |
| // A script is a parsed test script. | |
| type script struct { | |
| cases []*case_ | |
| } | |
| // A case_ is a single test case (GET/HEAD/POST) in a script. | |
| type case_ struct { | |
| file string | |
| line int | |
| method string | |
| url string | |
| postbody string | |
| postquery string | |
| posttype string | |
| hint string | |
| checks []*cmpCheck | |
| } | |
| // A cmp is a single comparison (check) made against a test case. | |
| type cmpCheck struct { | |
| file string | |
| line int | |
| what string | |
| whatArg string | |
| op string | |
| want string | |
| wantRE *regexp.Regexp | |
| } | |
| // runHandler runs a test case against the handler h. | |
| func (c *case_) runHandler(h http.Handler) error { | |
| w := httptest.NewRecorder() | |
| r, err := c.newRequest(c.url) | |
| if err != nil { | |
| return err | |
| } | |
| h.ServeHTTP(w, r) | |
| return c.check(w.Result(), w.Body.String()) | |
| } | |
| // runServer runs a test case against the server at address addr. | |
| func (c *case_) runServer(addr string) error { | |
| baseURL := "" | |
| if strings.HasPrefix(addr, "http://") || strings.HasPrefix(addr, "https://") { | |
| // addr is a base URL | |
| if !strings.HasSuffix(addr, "/") { | |
| addr += "/" | |
| } | |
| baseURL = addr | |
| } else { | |
| // addr is an HTTP proxy | |
| baseURL = "http://" + addr + "/" | |
| } | |
| // Build full URL for request. | |
| u := c.url | |
| if !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") { | |
| u = strings.TrimSuffix(baseURL, "/") | |
| if !strings.HasPrefix(c.url, "/") { | |
| u += "/" | |
| } | |
| u += c.url | |
| } | |
| req, err := c.newRequest(u) | |
| if err != nil { | |
| return fmt.Errorf("%s:%d: %s %s: %s", c.file, c.line, c.method, c.url, err) | |
| } | |
| tr := &http.Transport{} | |
| if !strings.HasPrefix(u, baseURL) { | |
| // If u does not begin with baseURL, then we're in the proxy case | |
| // and we try to tunnel the network activity through the proxy's address. | |
| proxyURL, err := url.Parse(baseURL) | |
| if err != nil { | |
| return fmt.Errorf("invalid addr: %v", err) | |
| } | |
| tr.Proxy = func(*http.Request) (*url.URL, error) { return proxyURL, nil } | |
| } | |
| resp, err := tr.RoundTrip(req) | |
| if err != nil { | |
| return fmt.Errorf("%s:%d: %s %s: %s", c.file, c.line, c.method, c.url, err) | |
| } | |
| body, err := io.ReadAll(resp.Body) | |
| resp.Body.Close() | |
| if err != nil { | |
| return fmt.Errorf("%s:%d: %s %s: reading body: %s", c.file, c.line, c.method, c.url, err) | |
| } | |
| return c.check(resp, string(body)) | |
| } | |
| // newRequest creates a new request for the case c, | |
| // using the URL u. | |
| func (c *case_) newRequest(u string) (*http.Request, error) { | |
| body := c.requestBody() | |
| r, err := http.NewRequest(c.method, u, body) | |
| if err != nil { | |
| return nil, err | |
| } | |
| typ := c.posttype | |
| if body != nil && typ == "" { | |
| typ = "application/x-www-form-urlencoded" | |
| } | |
| if typ != "" { | |
| r.Header.Set("Content-Type", typ) | |
| } | |
| return r, nil | |
| } | |
| // requestBody returns the body for the case's request. | |
| func (c *case_) requestBody() io.Reader { | |
| if c.postbody == "" { | |
| return nil | |
| } | |
| return strings.NewReader(c.postbody) | |
| } | |
| // check checks the response against the comparisons for the case. | |
| func (c *case_) check(resp *http.Response, body string) error { | |
| var msg bytes.Buffer | |
| for _, chk := range c.checks { | |
| what := chk.what | |
| if chk.whatArg != "" { | |
| what += " " + chk.whatArg | |
| } | |
| var value string | |
| switch chk.what { | |
| default: | |
| value = "unknown what: " + chk.what | |
| case "body": | |
| value = body | |
| case "trimbody": | |
| value = trim(body) | |
| case "code": | |
| value = fmt.Sprint(resp.StatusCode) | |
| case "header": | |
| value = resp.Header.Get(chk.whatArg) | |
| case "redirect": | |
| if resp.StatusCode/10 == 30 { | |
| value = resp.Header.Get("Location") | |
| } | |
| } | |
| switch chk.op { | |
| default: | |
| fmt.Fprintf(&msg, "%s:%d: unknown operator %s\n", chk.file, chk.line, chk.op) | |
| case "==": | |
| if value != chk.want { | |
| fmt.Fprintf(&msg, "%s:%d: %s = %q, want %q\n", chk.file, chk.line, what, value, chk.want) | |
| } | |
| case "!=": | |
| if value == chk.want { | |
| fmt.Fprintf(&msg, "%s:%d: %s == %q (but want !=)\n", chk.file, chk.line, what, value) | |
| } | |
| case "~": | |
| if !chk.wantRE.MatchString(value) { | |
| fmt.Fprintf(&msg, "%s:%d: %s does not match %#q (but should)\n\t%s\n", chk.file, chk.line, what, chk.want, indent(value)) | |
| } | |
| case "!~": | |
| if chk.wantRE.MatchString(value) { | |
| fmt.Fprintf(&msg, "%s:%d: %s matches %#q (but should not)\n\t%s\n", chk.file, chk.line, what, chk.want, indent(value)) | |
| } | |
| case "contains": | |
| if !strings.Contains(value, chk.want) { | |
| fmt.Fprintf(&msg, "%s:%d: %s does not contain %#q (but should)\n\t%s\n", chk.file, chk.line, what, chk.want, indent(value)) | |
| } | |
| case "!contains": | |
| if strings.Contains(value, chk.want) { | |
| fmt.Fprintf(&msg, "%s:%d: %s contains %#q (but should not)\n\t%s\n", chk.file, chk.line, what, chk.want, indent(value)) | |
| } | |
| } | |
| } | |
| if msg.Len() > 0 && c.hint != "" { | |
| fmt.Fprintf(&msg, "hint: %s\n", indent(c.hint)) | |
| } | |
| if msg.Len() > 0 { | |
| return fmt.Errorf("%s:%d: %s %s\n%s", c.file, c.line, c.method, c.url, msg.String()) | |
| } | |
| return nil | |
| } | |
| // trim returns a trimming of s, in which all runs of spaces and tabs have | |
| // been collapsed to a single space, leading and trailing spaces have been | |
| // removed from each line, and blank lines are removed entirely. | |
| func trim(s string) string { | |
| s = regexp.MustCompile(`[ \t]+`).ReplaceAllString(s, " ") | |
| s = regexp.MustCompile(`(?m)(^ | $)`).ReplaceAllString(s, "") | |
| s = strings.TrimLeft(s, "\n") | |
| s = regexp.MustCompile(`\n\n+`).ReplaceAllString(s, "\n") | |
| return s | |
| } | |
| // indent indents text for formatting in a message. | |
| func indent(text string) string { | |
| if text == "" { | |
| return "(empty)" | |
| } | |
| if text == "\n" { | |
| return "(blank line)" | |
| } | |
| text = strings.TrimRight(text, "\n") | |
| if text == "" { | |
| return "(blank lines)" | |
| } | |
| text = strings.ReplaceAll(text, "\n", "\n\t") | |
| return text | |
| } | |
| // parseScript parses the test script in text. | |
| // Errors are reported as being from file, but file is not directly read. | |
| func parseScript(file, text string) (*script, error) { | |
| var current struct { | |
| Case *case_ | |
| Multiline *string | |
| } | |
| script := new(script) | |
| lastLineWasBlank := true | |
| lineno := 0 | |
| line := "" | |
| errorf := func(format string, args ...interface{}) error { | |
| if line != "" { | |
| line = "\n" + line | |
| } | |
| return fmt.Errorf("%s:%d: %v%s", file, lineno, fmt.Sprintf(format, args...), line) | |
| } | |
| for text != "" { | |
| lineno++ | |
| prevLine := line | |
| line, text, _ = strings.Cut(text, "\n") | |
| if strings.HasPrefix(line, "#") { | |
| continue | |
| } | |
| line = strings.TrimRight(line, " \t") | |
| if line == "" { | |
| lastLineWasBlank = true | |
| continue | |
| } | |
| what, args := splitOneField(line) | |
| // Add indented line to current multiline check, or else it ends. | |
| if what == "" { | |
| // Line is indented. | |
| if current.Multiline != nil { | |
| lastLineWasBlank = false | |
| *current.Multiline += args + "\n" | |
| continue | |
| } | |
| return nil, errorf("unexpected indented line") | |
| } | |
| // Multiline text is over; must be present. | |
| if current.Multiline != nil && *current.Multiline == "" { | |
| lineno-- | |
| line = prevLine | |
| return nil, errorf("missing multiline text") | |
| } | |
| current.Multiline = nil | |
| // Look for start of new check. | |
| switch what { | |
| case "GET", "HEAD", "POST": | |
| if !lastLineWasBlank { | |
| return nil, errorf("missing blank line before start of case") | |
| } | |
| if args == "" { | |
| return nil, errorf("missing %s URL", what) | |
| } | |
| cas := &case_{method: what, url: args, file: file, line: lineno} | |
| script.cases = append(script.cases, cas) | |
| current.Case = cas | |
| lastLineWasBlank = false | |
| continue | |
| } | |
| if lastLineWasBlank || current.Case == nil { | |
| return nil, errorf("missing GET/HEAD/POST at start of check") | |
| } | |
| // Look for case metadata. | |
| var targ *string | |
| switch what { | |
| case "postbody": | |
| targ = ¤t.Case.postbody | |
| case "postquery": | |
| targ = ¤t.Case.postquery | |
| case "posttype": | |
| targ = ¤t.Case.posttype | |
| case "hint": | |
| targ = ¤t.Case.hint | |
| } | |
| if targ != nil { | |
| if strings.HasPrefix(what, "post") && current.Case.method != "POST" { | |
| return nil, errorf("need POST (not %v) for %v", current.Case.method, what) | |
| } | |
| if args != "" { | |
| *targ = args | |
| } else { | |
| current.Multiline = targ | |
| } | |
| continue | |
| } | |
| // Start a comparison check. | |
| chk := &cmpCheck{file: file, line: lineno, what: what} | |
| current.Case.checks = append(current.Case.checks, chk) | |
| switch what { | |
| case "body", "code", "redirect": | |
| // no WhatArg | |
| case "header": | |
| chk.whatArg, args = splitOneField(args) | |
| if chk.whatArg == "" { | |
| return nil, errorf("missing header name") | |
| } | |
| } | |
| // Opcode, with optional leading "not" | |
| chk.op, args = splitOneField(args) | |
| switch chk.op { | |
| case "==", "!=", "~", "!~", "contains", "!contains": | |
| // ok | |
| default: | |
| return nil, errorf("unknown check operator %q", chk.op) | |
| } | |
| if args != "" { | |
| chk.want = args | |
| } else { | |
| current.Multiline = &chk.want | |
| } | |
| } | |
| // Finish each case. | |
| // Compute POST body from POST query. | |
| // Check that each regexp compiles, and insert "code equals 200" | |
| // in each case that doesn't already have a code check. | |
| for _, cas := range script.cases { | |
| if cas.postquery != "" { | |
| if cas.postbody != "" { | |
| line = "" | |
| lineno = cas.line | |
| return nil, errorf("case has postbody and postquery") | |
| } | |
| for _, kv := range strings.Split(cas.postquery, "\n") { | |
| kv = strings.TrimSpace(kv) | |
| if kv == "" { | |
| continue | |
| } | |
| k, v, ok := strings.Cut(kv, "=") | |
| if !ok { | |
| lineno = cas.line // close enough | |
| line = kv | |
| return nil, errorf("postquery has non key=value line") | |
| } | |
| if cas.postbody != "" { | |
| cas.postbody += "&" | |
| } | |
| cas.postbody += url.QueryEscape(k) + "=" + url.QueryEscape(v) | |
| } | |
| } | |
| sawCode := false | |
| for _, chk := range cas.checks { | |
| if chk.what == "code" || chk.what == "redirect" { | |
| sawCode = true | |
| } | |
| if chk.op == "~" || chk.op == "!~" { | |
| re, err := regexp.Compile(`(?m)` + chk.want) | |
| if err != nil { | |
| lineno = chk.line | |
| line = chk.want | |
| return nil, errorf("invalid regexp: %s", err) | |
| } | |
| chk.wantRE = re | |
| } | |
| } | |
| if !sawCode { | |
| line := cas.line | |
| if len(cas.checks) > 0 { | |
| line = cas.checks[0].line | |
| } | |
| chk := &cmpCheck{file: cas.file, line: line, what: "code", op: "==", want: "200"} | |
| cas.checks = append(cas.checks, chk) | |
| } | |
| } | |
| return script, nil | |
| } | |
| // splitOneField splits text at the first space or tab | |
| // and returns that first field and the remaining text. | |
| func splitOneField(text string) (field, rest string) { | |
| i := strings.IndexAny(text, " \t") | |
| if i < 0 { | |
| return text, "" | |
| } | |
| return text[:i], strings.TrimLeft(text[i:], " \t") | |
| } |