Repository: go-resty/resty
Pull request #628 (2023-03-12 07:35:18)

Add go fmt to github actions

codecov[bot]: ## [Codecov](https://codecov.io/gh/go-resty/resty/pull/628?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=go-resty) Report
> Merging [#628](https://codecov.io/gh/go-resty/resty/pull/628?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=go-resty) (121a62e) into [master](https://codecov.io/gh/go-resty/resty/commit/d54c9568cede1bba51f0b071b5f5149ee713bba0?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=go-resty) (d54c956) will **not change** coverage.
> The diff coverage is `n/a`.

```diff
@@           Coverage Diff           @@
##           master     #628   +/-   ##
=======================================
  Coverage   95.77%   95.77%           
=======================================
  Files          10       10           
  Lines        1395     1395           
=======================================
  Hits         1336     1336           
  Misses         37       37           
  Partials       22       22           
```



Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=go-resty). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=go-resty)


Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 5922129..9595781 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -34,6 +34,9 @@ jobs:
         with:
           go-version: ${{ matrix.go }}
 
+      - name: Format
+        run: diff -u <(echo -n) <(go fmt $(go list ./...))
+
       - name: Test
         run: go test ./... -race -coverprofile=coverage.txt -covermode=atomic
 
diff --git a/client.go b/client.go
index 95c3a7d..a7af66e 100644
--- a/client.go
+++ b/client.go
@@ -55,6 +55,8 @@ var (
 	hdrContentLengthKey   = http.CanonicalHeaderKey("Content-Length")
 	hdrContentEncodingKey = http.CanonicalHeaderKey("Content-Encoding")
 	hdrLocationKey        = http.CanonicalHeaderKey("Location")
+	hdrAuthorizationKey   = http.CanonicalHeaderKey("Authorization")
+	hdrWwwAuthenticateKey = http.CanonicalHeaderKey("WWW-Authenticate")
 
 	plainTextType   = "text/plain; charset=utf-8"
 	jsonContentType = "application/json"
@@ -399,6 +401,34 @@ func (c *Client) SetAuthScheme(scheme string) *Client {
 	return c
 }
 
+// SetDigestAuth method sets the Digest Access auth scheme for the client. If a server responds with 401 and sends
+// a Digest challenge in the WWW-Authenticate Header, requests will be resent with the appropriate Authorization Header.
+//
+// For Example: To set the Digest scheme with user "Mufasa" and password "Circle Of Life"
+//
+//	client.SetDigestAuth("Mufasa", "Circle Of Life")
+//
+// Information about Digest Access Authentication can be found in RFC7616:
+//
+//	https://datatracker.ietf.org/doc/html/rfc7616
+//
+// See `Request.SetDigestAuth`.
+func (c *Client) SetDigestAuth(username, password string) *Client {
+	oldTransport := c.httpClient.Transport
+	c.OnBeforeRequest(func(c *Client, _ *Request) error {
+		c.httpClient.Transport = &digestTransport{
+			digestCredentials: digestCredentials{username, password},
+			transport:         oldTransport,
+		}
+		return nil
+	})
+	c.OnAfterResponse(func(c *Client, _ *Response) error {
+		c.httpClient.Transport = oldTransport
+		return nil
+	})
+	return c
+}
+
 // R method creates a new request instance, its used for Get, Post, Put, Delete, Patch, Head, Options, etc.
 func (c *Client) R() *Request {
 	r := &Request{
diff --git a/digest.go b/digest.go
new file mode 100644
index 0000000..0a7bb10
--- /dev/null
+++ b/digest.go
@@ -0,0 +1,272 @@
+package resty
+
+import (
+	"crypto/md5"
+	"crypto/rand"
+	"crypto/sha256"
+	"crypto/sha512"
+	"errors"
+	"fmt"
+	"hash"
+	"io"
+	"net/http"
+	"strings"
+)
+
+var (
+	ErrDigestBadChallenge    = errors.New("digest: challenge is bad")
+	ErrDigestCharset         = errors.New("digest: unsupported charset")
+	ErrDigestAlgNotSupported = errors.New("digest: algorithm is not supported")
+	ErrDigestQopNotSupported = errors.New("digest: no supported qop in list")
+	ErrDigestNoQop           = errors.New("digest: qop must be specified")
+)
+
+var hashFuncs = map[string]func() hash.Hash{
+	"":                 md5.New,
+	"MD5":              md5.New,
+	"MD5-sess":         md5.New,
+	"SHA-256":          sha256.New,
+	"SHA-256-sess":     sha256.New,
+	"SHA-512-256":      sha512.New,
+	"SHA-512-256-sess": sha512.New,
+}
+
+type digestCredentials struct {
+	username, password string
+}
+
+type digestTransport struct {
+	digestCredentials
+	transport http.RoundTripper
+}
+
+func (dt *digestTransport) RoundTrip(req *http.Request) (*http.Response, error) {
+	// Copy the request, so we don't modify the input.
+	req2 := new(http.Request)
+	*req2 = *req
+	req2.Header = make(http.Header)
+	for k, s := range req.Header {
+		req2.Header[k] = s
+	}
+
+	// Make a request to get the 401 that contains the challenge.
+	resp, err := dt.transport.RoundTrip(req)
+	if err != nil || resp.StatusCode != http.StatusUnauthorized {
+		return resp, err
+	}
+	chal := resp.Header.Get(hdrWwwAuthenticateKey)
+	if chal == "" {
+		return resp, ErrDigestBadChallenge
+	}
+
+	c, err := parseChallenge(chal)
+	if err != nil {
+		return resp, err
+	}
+
+	// Form credentials based on the challenge
+	cr := dt.newCredentials(req2, c)
+	auth, err := cr.authorize()
+	if err != nil {
+		return resp, err
+	}
+	err = resp.Body.Close()
+	if err != nil {
+		return nil, err
+	}
+
+	// Make authenticated request
+	req2.Header.Set(hdrAuthorizationKey, auth)
+	return dt.transport.RoundTrip(req2)
+}
+
+func (dt *digestTransport) newCredentials(req *http.Request, c *challenge) *credentials {
+	return &credentials{
+		username:   dt.username,
+		userhash:   c.userhash,
+		realm:      c.realm,
+		nonce:      c.nonce,
+		digestURI:  req.URL.RequestURI(),
+		algorithm:  c.algorithm,
+		sessionAlg: strings.HasSuffix(c.algorithm, "-sess"),
+		opaque:     c.opaque,
+		messageQop: c.qop,
+		nc:         0,
+		method:     req.Method,
+		password:   dt.password,
+	}
+}
+
+type challenge struct {
+	realm     string
+	domain    string
+	nonce     string
+	opaque    string
+	stale     string
+	algorithm string
+	qop       string
+	userhash  string
+}
+
+func parseChallenge(input string) (*challenge, error) {
+	const ws = " \n\r\t"
+	const qs = `"`
+	s := strings.Trim(input, ws)
+	if !strings.HasPrefix(s, "Digest ") {
+		return nil, ErrDigestBadChallenge
+	}
+	s = strings.Trim(s[7:], ws)
+	sl := strings.Split(s, ", ")
+	c := &challenge{}
+	var r []string
+	for i := range sl {
+		r = strings.SplitN(sl[i], "=", 2)
+		switch r[0] {
+		case "realm":
+			c.realm = strings.Trim(r[1], qs)
+		case "domain":
+			c.domain = strings.Trim(r[1], qs)
+		case "nonce":
+			c.nonce = strings.Trim(r[1], qs)
+		case "opaque":
+			c.opaque = strings.Trim(r[1], qs)
+		case "stale":
+			c.stale = r[1]
+		case "algorithm":
+			c.algorithm = r[1]
+		case "qop":
+			c.qop = strings.Trim(r[1], qs)
+		case "charset":
+			if strings.ToUpper(strings.Trim(r[1], qs)) != "UTF-8" {
+				return nil, ErrDigestCharset
+			}
+		case "userhash":
+			c.userhash = strings.Trim(r[1], qs)
+		default:
+			return nil, ErrDigestBadChallenge
+		}
+	}
+	return c, nil
+}
+
+type credentials struct {
+	username   string
+	userhash   string
+	realm      string
+	nonce      string
+	digestURI  string
+	algorithm  string
+	sessionAlg bool
+	cNonce     string
+	opaque     string
+	messageQop string
+	nc         int
+	method     string
+	password   string
+}
+
+func (c *credentials) authorize() (string, error) {
+	if _, ok := hashFuncs[c.algorithm]; !ok {
+		return "", ErrDigestAlgNotSupported
+	}
+
+	if err := c.validateQop(); err != nil {
+		return "", err
+	}
+
+	resp, err := c.resp()
+	if err != nil {
+		return "", err
+	}
+
+	sl := make([]string, 0, 10)
+	if c.userhash == "true" {
+		// RFC 7616 3.4.4
+		c.username = c.h(fmt.Sprintf("%s:%s", c.username, c.realm))
+		sl = append(sl, fmt.Sprintf(`userhash=%s`, c.userhash))
+	}
+	sl = append(sl, fmt.Sprintf(`username="%s"`, c.username))
+	sl = append(sl, fmt.Sprintf(`realm="%s"`, c.realm))
+	sl = append(sl, fmt.Sprintf(`nonce="%s"`, c.nonce))
+	sl = append(sl, fmt.Sprintf(`uri="%s"`, c.digestURI))
+	sl = append(sl, fmt.Sprintf(`response="%s"`, resp))
+	sl = append(sl, fmt.Sprintf(`algorithm=%s`, c.algorithm))
+	if c.opaque != "" {
+		sl = append(sl, fmt.Sprintf(`opaque="%s"`, c.opaque))
+	}
+	if c.messageQop != "" {
+		sl = append(sl, fmt.Sprintf("qop=%s", c.messageQop))
+		sl = append(sl, fmt.Sprintf("nc=%08x", c.nc))
+		sl = append(sl, fmt.Sprintf(`cnonce="%s"`, c.cNonce))
+	}
+
+	return fmt.Sprintf("Digest %s", strings.Join(sl, ", ")), nil
+}
+
+func (c *credentials) validateQop() error {
+	// Currently only supporting auth quality of protection. TODO: add auth-int support
+	// NOTE: cURL support auth-int qop for requests other than POST and PUT (i.e. w/o body) by hashing an empty string
+	// is this applicable for resty? see: https://github.com/curl/curl/blob/307b7543ea1e73ab04e062bdbe4b5bb409eaba3a/lib/vauth/digest.c#L774
+	if c.messageQop == "" {
+		return ErrDigestNoQop
+	}
+	possibleQops := strings.Split(c.messageQop, ", ")
+	var authSupport bool
+	for _, qop := range possibleQops {
+		if qop == "auth" {
+			authSupport = true
+			break
+		}
+	}
+	if !authSupport {
+		return ErrDigestQopNotSupported
+	}
+
+	c.messageQop = "auth"
+
+	return nil
+}
+
+func (c *credentials) h(data string) string {
+	hfCtor := hashFuncs[c.algorithm]
+	hf := hfCtor()
+	_, _ = hf.Write([]byte(data)) // Hash.Write never returns an error
+	return fmt.Sprintf("%x", hf.Sum(nil))
+}
+
+func (c *credentials) resp() (string, error) {
+	c.nc++
+
+	b := make([]byte, 16)
+	_, err := io.ReadFull(rand.Reader, b)
+	if err != nil {
+		return "", err
+	}
+	c.cNonce = fmt.Sprintf("%x", b)[:32]
+
+	ha1 := c.ha1()
+	ha2 := c.ha2()
+
+	return c.kd(ha1, fmt.Sprintf("%s:%08x:%s:%s:%s",
+		c.nonce, c.nc, c.cNonce, c.messageQop, ha2)), nil
+}
+
+func (c *credentials) kd(secret, data string) string {
+	return c.h(fmt.Sprintf("%s:%s", secret, data))
+}
+
+// RFC 7616 3.4.2
+func (c *credentials) ha1() string {
+	ret := c.h(fmt.Sprintf("%s:%s:%s", c.username, c.realm, c.password))
+	if c.sessionAlg {
+		return c.h(fmt.Sprintf("%s:%s:%s", ret, c.nonce, c.cNonce))
+	}
+
+	return ret
+}
+
+// RFC 7616 3.4.3
+func (c *credentials) ha2() string {
+	// currently no auth-int support
+	return c.h(fmt.Sprintf("%s:%s", c.method, c.digestURI))
+}
diff --git a/request.go b/request.go
index adb129e..49d9582 100644
--- a/request.go
+++ b/request.go
@@ -502,6 +502,35 @@ func (r *Request) SetAuthScheme(scheme string) *Request {
 	return r
 }
 
+// SetDigestAuth method sets the Digest Access auth scheme for the HTTP request. If a server responds with 401 and sends
+// a Digest challenge in the WWW-Authenticate Header, the request will be resent with the appropriate Authorization Header.
+//
+// For Example: To set the Digest scheme with username "Mufasa" and password "Circle Of Life"
+//
+//	client.R().SetDigestAuth("Mufasa", "Circle Of Life")
+//
+// Information about Digest Access Authentication can be found in RFC7616:
+//
+//	https://datatracker.ietf.org/doc/html/rfc7616
+//
+// This method overrides the username and password set by method `Client.SetDigestAuth`.
+func (r *Request) SetDigestAuth(username, password string) *Request {
+	oldTransport := r.client.httpClient.Transport
+	r.client.OnBeforeRequest(func(c *Client, _ *Request) error {
+		c.httpClient.Transport = &digestTransport{
+			digestCredentials: digestCredentials{username, password},
+			transport:         oldTransport,
+		}
+		return nil
+	})
+	r.client.OnAfterResponse(func(c *Client, _ *Response) error {
+		c.httpClient.Transport = oldTransport
+		return nil
+	})
+
+	return r
+}
+
 // SetOutput method sets the output file for current HTTP request. Current HTTP response will be
 // saved into given file. It is similar to `curl -o` flag. Absolute path or relative path can be used.
 // If is it relative path then output file goes under the output directory, as mentioned


Test patch:
diff --git a/client_test.go b/client_test.go
index 96b70ea..030abcc 100644
--- a/client_test.go
+++ b/client_test.go
@@ -82,6 +82,76 @@ func TestClientAuthScheme(t *testing.T) {
 
 }
 
+func TestClientDigestAuth(t *testing.T) {
+	conf := defaultDigestServerConf()
+	ts := createDigestServer(t, conf)
+	defer ts.Close()
+
+	c := dc().
+		SetBaseURL(ts.URL+"/").
+		SetDigestAuth(conf.username, conf.password)
+
+	resp, err := c.R().
+		SetResult(&AuthSuccess{}).
+		Get(conf.uri)
+	assertError(t, err)
+	assertEqual(t, http.StatusOK, resp.StatusCode())
+
+	t.Logf("Result Success: %q", resp.Result().(*AuthSuccess))
+	logResponse(t, resp)
+}
+
+func TestClientDigestSession(t *testing.T) {
+	conf := defaultDigestServerConf()
+	conf.algo = "MD5-sess"
+	ts := createDigestServer(t, conf)
+	defer ts.Close()
+
+	c := dc().
+		SetBaseURL(ts.URL+"/").
+		SetDigestAuth(conf.username, conf.password)
+
+	resp, err := c.R().
+		SetResult(&AuthSuccess{}).
+		Get(conf.uri)
+	assertError(t, err)
+	assertEqual(t, http.StatusOK, resp.StatusCode())
+
+	t.Logf("Result Success: %q", resp.Result().(*AuthSuccess))
+	logResponse(t, resp)
+}
+
+func TestClientDigestErrors(t *testing.T) {
+	type test struct {
+		mutateConf func(*digestServerConfig)
+		expect     error
+	}
+	tests := []test{
+		{mutateConf: func(c *digestServerConfig) { c.algo = "BAD_ALGO" }, expect: ErrDigestAlgNotSupported},
+		{mutateConf: func(c *digestServerConfig) { c.qop = "bad-qop" }, expect: ErrDigestQopNotSupported},
+		{mutateConf: func(c *digestServerConfig) { c.qop = "" }, expect: ErrDigestNoQop},
+		{mutateConf: func(c *digestServerConfig) { c.charset = "utf-16" }, expect: ErrDigestCharset},
+		{mutateConf: func(c *digestServerConfig) { c.uri = "/bad" }, expect: ErrDigestBadChallenge},
+		{mutateConf: func(c *digestServerConfig) { c.uri = "/unknown_param" }, expect: ErrDigestBadChallenge},
+		{mutateConf: func(c *digestServerConfig) { c.uri = "/no_challenge" }, expect: ErrDigestBadChallenge},
+		{mutateConf: func(c *digestServerConfig) { c.uri = "/status_500" }, expect: nil},
+	}
+
+	for _, tc := range tests {
+		conf := defaultDigestServerConf()
+		tc.mutateConf(conf)
+		ts := createDigestServer(t, conf)
+
+		c := dc().
+			SetBaseURL(ts.URL+"/").
+			SetDigestAuth(conf.username, conf.password)
+
+		_, err := c.R().Get(conf.uri)
+		assertErrorIs(t, tc.expect, err)
+		ts.Close()
+	}
+}
+
 func TestOnAfterMiddleware(t *testing.T) {
 	ts := createGenServer(t)
 	defer ts.Close()
diff --git a/request_test.go b/request_test.go
index 769a5e1..7f31484 100644
--- a/request_test.go
+++ b/request_test.go
@@ -629,6 +629,40 @@ func TestRequestAuthScheme(t *testing.T) {
 	assertEqual(t, http.StatusOK, resp.StatusCode())
 }
 
+func TestRequestDigestAuth(t *testing.T) {
+	conf := defaultDigestServerConf()
+	ts := createDigestServer(t, nil)
+	defer ts.Close()
+
+	resp, err := dclr().
+		SetDigestAuth(conf.username, conf.password).
+		SetResult(&AuthSuccess{}).
+		Get(ts.URL + conf.uri)
+
+	assertError(t, err)
+	assertEqual(t, http.StatusOK, resp.StatusCode())
+
+	t.Logf("Result Success: %q", resp.Result().(*AuthSuccess))
+	logResponse(t, resp)
+}
+
+func TestRequestDigestAuthFail(t *testing.T) {
+	conf := defaultDigestServerConf()
+	ts := createDigestServer(t, nil)
+	defer ts.Close()
+
+	resp, err := dclr().
+		SetDigestAuth(conf.username, "wrongPassword").
+		SetError(AuthError{}).
+		Get(ts.URL + conf.uri)
+
+	assertError(t, err)
+	assertEqual(t, http.StatusUnauthorized, resp.StatusCode())
+
+	t.Logf("Result Error: %q", resp.Error().(*AuthError))
+	logResponse(t, resp)
+}
+
 func TestFormData(t *testing.T) {
 	ts := createFormPostServer(t)
 	defer ts.Close()
diff --git a/resty_test.go b/resty_test.go
index 01d9a3b..88e85ba 100644
--- a/resty_test.go
+++ b/resty_test.go
@@ -6,9 +6,11 @@ package resty
 
 import (
 	"compress/gzip"
+	"crypto/md5"
 	"encoding/base64"
 	"encoding/json"
 	"encoding/xml"
+	"errors"
 	"fmt"
 	"io"
 	"net/http"
@@ -621,6 +623,120 @@ func createRedirectServer(t *testing.T) *httptest.Server {
 	return ts
 }
 
+type digestServerConfig struct {
+	realm, qop, nonce, opaque, algo, uri, charset, username, password string
+}
+
+func defaultDigestServerConf() *digestServerConfig {
+	return &digestServerConfig{
+		realm:    "testrealm@host.com",
+		qop:      "auth",
+		nonce:    "dcd98b7102dd2f0e8b11d0f600bfb0c093",
+		opaque:   "5ccc069c403ebaf9f0171e9517f40e41",
+		algo:     "MD5",
+		uri:      "/dir/index.html",
+		charset:  "utf-8",
+		username: "Mufasa",
+		password: "Circle Of Life",
+	}
+}
+
+func createDigestServer(t *testing.T, conf *digestServerConfig) *httptest.Server {
+	if conf == nil {
+		conf = defaultDigestServerConf()
+	}
+	ts := createTestServer(func(w http.ResponseWriter, r *http.Request) {
+		t.Logf("Method: %v", r.Method)
+		t.Logf("Path: %v", r.URL.Path)
+
+		switch r.URL.Path {
+		case "/bad":
+			w.Header().Set("WWW-Authenticate", "Bad Challenge")
+			w.WriteHeader(http.StatusUnauthorized)
+			return
+		case "/unknown_param":
+			w.Header().Set("WWW-Authenticate", "Digest unknown_param=true")
+			w.WriteHeader(http.StatusUnauthorized)
+			return
+		case "/no_challenge":
+			w.Header().Set("WWW-Authenticate", "")
+			w.WriteHeader(http.StatusUnauthorized)
+			return
+		case "/status_500":
+			w.WriteHeader(http.StatusInternalServerError)
+			return
+		}
+
+		w.Header().Set(hdrContentTypeKey, "application/json; charset=utf-8")
+
+		if !authorizationHeaderValid(t, r, conf) {
+			w.Header().Set("WWW-Authenticate",
+				fmt.Sprintf(`Digest realm="%s", domain="%s", qop="%s", algorithm=%s, nonce="%s", opaque="%s", userhash=true, charset=%s, stale=FALSE`,
+					conf.realm, conf.uri, conf.qop, conf.algo, conf.nonce, conf.opaque, conf.charset))
+			w.WriteHeader(http.StatusUnauthorized)
+			_, _ = w.Write([]byte(`{ "id": "unauthorized", "message": "Invalid credentials" }`))
+		} else {
+			w.WriteHeader(http.StatusOK)
+			_, _ = w.Write([]byte(`{ "id": "success", "message": "login successful" }`))
+		}
+	})
+
+	return ts
+}
+
+func authorizationHeaderValid(t *testing.T, r *http.Request, conf *digestServerConfig) bool {
+	h := func(data string) (string, error) {
+		hf := md5.New()
+
+		_, err := io.WriteString(hf, data)
+		if err != nil {
+			return "", err
+		}
+
+		return fmt.Sprintf("%x", hf.Sum(nil)), nil
+	}
+	input := r.Header.Get(hdrAuthorizationKey)
+	if input == "" {
+		return false
+	}
+	const ws = " \n\r\t"
+	const qs = `"`
+	s := strings.Trim(input, ws)
+	assertEqual(t, true, strings.HasPrefix(s, "Digest "))
+	s = strings.Trim(s[7:], ws)
+	sl := strings.Split(s, ", ")
+
+	pairs := make(map[string]string, len(sl))
+	for i := range sl {
+		pair := strings.SplitN(sl[i], "=", 2)
+		pairs[pair[0]] = strings.Trim(pair[1], qs)
+	}
+
+	assertEqual(t, conf.opaque, pairs["opaque"])
+	assertEqual(t, conf.algo, pairs["algorithm"])
+	assertEqual(t, "true", pairs["userhash"])
+
+	userhash, err := h(fmt.Sprintf("%s:%s", conf.username, conf.realm))
+	assertError(t, err)
+	assertEqual(t, userhash, pairs["username"])
+
+	ha1, err := h(fmt.Sprintf("%s:%s:%s", conf.username, conf.realm, conf.password))
+	assertError(t, err)
+	if strings.HasSuffix(conf.algo, "-sess") {
+		ha1, err = h(fmt.Sprintf("%s:%s:%s", ha1, pairs["nonce"], pairs["cnonce"]))
+		assertError(t, err)
+	}
+	ha2, err := h(fmt.Sprintf("%s:%s", r.Method, conf.uri))
+	assertError(t, err)
+	nonceCount, err := strconv.Atoi(pairs["nc"])
+	assertError(t, err)
+	kd, err := h(fmt.Sprintf("%s:%s", ha1, fmt.Sprintf("%s:%08x:%s:%s:%s",
+		pairs["nonce"], nonceCount, pairs["cnonce"], pairs["qop"], ha2)))
+	assertError(t, err)
+
+	return kd == pairs["response"]
+}
+
 func createTestServer(fn func(w http.ResponseWriter, r *http.Request)) *httptest.Server {
 	return httptest.NewServer(http.HandlerFunc(fn))
 }
@@ -673,6 +789,14 @@ func assertError(t *testing.T, err error) {
 	}
 }
 
+func assertErrorIs(t *testing.T, e, g error) (r bool) {
+	if !errors.Is(g, e) {
+		t.Errorf("Expected [%v], got [%v]", e, g)
+	}
+
+	return true
+}
+
 func assertEqual(t *testing.T, e, g interface{}) (r bool) {
 	if !equal(e, g) {
 		t.Errorf("Expected [%v], got [%v]", e, g)