blob: 7c1e395fe38aa014b27bf03e41a566cf667a1f4d [file] [log] [blame]
// 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"
"io/ioutil"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"regexp"
"strings"
"testing"
"unicode/utf8"
)
// HandlerWithCheck returns an http.Handler that responds to each request
// by running the test script files mached 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 := ioutil.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 := ioutil.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, _ = 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 = &current.Case.postbody
case "postquery":
targ = &current.Case.postquery
case "posttype":
targ = &current.Case.posttype
case "hint":
targ = &current.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 := 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
}
// cut returns the result of cutting s around the first instance of sep.
func cut(s, sep string) (before, after string, ok bool) {
if i := strings.Index(s, sep); i >= 0 {
return s[:i], s[i+len(sep):], true
}
return s, "", false
}
// cutAny returns the result of cutting s around the first instance of
// any code point from any.
func cutAny(s, any string) (before, after string, ok bool) {
if i := strings.IndexAny(s, any); i >= 0 {
_, size := utf8.DecodeRuneInString(s[i:])
return s[:i], s[i+size:], true
}
return s, "", false
}
// 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")
}