snapshot of https://github.com/ory/kratos.git (master, 2026-09-15; blobs > 131072B omitted)
Some checks failed
CI / Run tests and lints (push) Failing after 29s
CI / Build CLI docs (push) Has been skipped
CI / Run Playwright end-to-end tests (mysql) (push) Failing after 3s
CI / Run Playwright end-to-end tests (postgres) (push) Failing after 2s
CI / Run Playwright end-to-end tests (sqlite) (push) Failing after 3s
CodeQL / Analyze (go) (push) Failing after 4s
CodeQL / Analyze (javascript) (push) Failing after 3s
Docker Image Scanners / scanners (push) Failing after 4s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Run end-to-end tests (postgres) (push) Failing after 19s
CI / Run end-to-end tests (sqlite) (push) Failing after 2s
CI / Run Playwright end-to-end tests (cockroach) (push) Failing after 2s
CI / Draft newsletter (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
Some checks failed
CI / Run tests and lints (push) Failing after 29s
CI / Build CLI docs (push) Has been skipped
CI / Run Playwright end-to-end tests (mysql) (push) Failing after 3s
CI / Run Playwright end-to-end tests (postgres) (push) Failing after 2s
CI / Run Playwright end-to-end tests (sqlite) (push) Failing after 3s
CodeQL / Analyze (go) (push) Failing after 4s
CodeQL / Analyze (javascript) (push) Failing after 3s
Docker Image Scanners / scanners (push) Failing after 4s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Run end-to-end tests (postgres) (push) Failing after 19s
CI / Run end-to-end tests (sqlite) (push) Failing after 2s
CI / Run Playwright end-to-end tests (cockroach) (push) Failing after 2s
CI / Draft newsletter (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
This commit is contained in:
469
selfservice/strategy/password/validator_test.go
Normal file
469
selfservice/strategy/password/validator_test.go
Normal file
@@ -0,0 +1,469 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package password_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha1" //#nosec G505 -- compatibility for imported passwords
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/go-retryablehttp"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/ory/herodot"
|
||||
"github.com/ory/kratos/driver"
|
||||
"github.com/ory/kratos/driver/config"
|
||||
"github.com/ory/kratos/pkg"
|
||||
"github.com/ory/kratos/selfservice/strategy/password"
|
||||
"github.com/ory/kratos/text"
|
||||
"github.com/ory/x/configx"
|
||||
"github.com/ory/x/contextx"
|
||||
"github.com/ory/x/httpx"
|
||||
)
|
||||
|
||||
// testRegistry embeds RegistryDefault and overrides HTTPClient to inject a
|
||||
// controllable HTTP client for HIBP-related tests.
|
||||
type testRegistry struct {
|
||||
*driver.RegistryDefault
|
||||
cl *retryablehttp.Client
|
||||
}
|
||||
|
||||
func (r *testRegistry) HTTPClient(_ context.Context, _ ...httpx.ResilientOptions) *retryablehttp.Client {
|
||||
return r.cl
|
||||
}
|
||||
|
||||
// newTestValidator wires base with a fake HIBP HTTP client and returns the
|
||||
// validator along with the fake client for request assertions and stubbing.
|
||||
func newTestValidator(t *testing.T, base *driver.RegistryDefault, opts ...httpx.ResilientOptions) (*password.DefaultPasswordValidator, *fakeHttpClient) {
|
||||
t.Helper()
|
||||
fakeClient := NewFakeHTTPClient()
|
||||
cl := httpx.NewResilientClient(append([]httpx.ResilientOptions{
|
||||
httpx.ResilientClientWithMaxRetry(1),
|
||||
httpx.ResilientClientWithConnectionTimeout(time.Millisecond),
|
||||
}, opts...)...)
|
||||
cl.HTTPClient = &fakeClient.Client
|
||||
s, err := password.NewDefaultPasswordValidatorStrategy(&testRegistry{RegistryDefault: base, cl: cl})
|
||||
require.NoError(t, err)
|
||||
return s, fakeClient
|
||||
}
|
||||
|
||||
func TestDefaultPasswordValidationStrategy(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Tests are based on:
|
||||
// - https://www.troyhunt.com/passwords-evolved-authentication-guidance-for-the-modern-era/
|
||||
// - https://www.microsoft.com/en-us/research/wp-content/uploads/2016/06/Microsoft_Password_Guidance-1.pdf
|
||||
|
||||
t.Run("default strategy", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, reg := pkg.NewFastRegistryWithMocks(t)
|
||||
s, err := password.NewDefaultPasswordValidatorStrategy(reg)
|
||||
require.NoError(t, err)
|
||||
|
||||
for k, tc := range []struct {
|
||||
id, pw string
|
||||
expectedErr error
|
||||
}{
|
||||
{pw: "", expectedErr: text.NewErrorValidationPasswordMinLength(0, 0)},
|
||||
{pw: "12", expectedErr: text.NewErrorValidationPasswordMinLength(0, 0)},
|
||||
{pw: "1234", expectedErr: text.NewErrorValidationPasswordMinLength(0, 0)},
|
||||
{pw: "123456", expectedErr: text.NewErrorValidationPasswordMinLength(0, 0)},
|
||||
{pw: "12345678", expectedErr: text.NewErrorValidationPasswordTooManyBreaches(0)},
|
||||
{pw: "password", expectedErr: text.NewErrorValidationPasswordTooManyBreaches(0)},
|
||||
{pw: "1234567890", expectedErr: text.NewErrorValidationPasswordTooManyBreaches(0)},
|
||||
{pw: "qwertyui", expectedErr: text.NewErrorValidationPasswordTooManyBreaches(0)},
|
||||
{pw: "l3f9to", expectedErr: text.NewErrorValidationPasswordMinLength(0, 0)},
|
||||
{pw: "l3f9toh1uaf81n21"},
|
||||
{pw: "l3f9toh1uaf81n21", id: "l3f9toh1uaf81n21", expectedErr: text.NewErrorValidationPasswordIdentifierTooSimilar()},
|
||||
{pw: "l3f9toh1"},
|
||||
{pw: "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"},
|
||||
// simple permutation tests
|
||||
{id: "hello@example.com", pw: "hello@example.com12345", expectedErr: text.NewErrorValidationPasswordIdentifierTooSimilar()},
|
||||
{id: "hello@example.com", pw: "123hello@example.com123", expectedErr: text.NewErrorValidationPasswordIdentifierTooSimilar()},
|
||||
{id: "hello@example.com", pw: "hello@exam", expectedErr: text.NewErrorValidationPasswordIdentifierTooSimilar()},
|
||||
{id: "hello@example.com", pw: "HELLO@EXAMPLE.COM", expectedErr: text.NewErrorValidationPasswordIdentifierTooSimilar()},
|
||||
{id: "hello@example.com", pw: "HELLO@example.com", expectedErr: text.NewErrorValidationPasswordIdentifierTooSimilar()},
|
||||
{pw: "hello@example.com", id: "hello@exam", expectedErr: text.NewErrorValidationPasswordIdentifierTooSimilar()},
|
||||
{id: "hello@example.com", pw: "h3ll0@example", expectedErr: text.NewErrorValidationPasswordIdentifierTooSimilar()},
|
||||
{pw: "hello@example.com", id: "hello@exam", expectedErr: text.NewErrorValidationPasswordIdentifierTooSimilar()},
|
||||
{id: "abcd", pw: "9d3c8a1b"},
|
||||
{id: "a", pw: "kjOklafe"},
|
||||
{id: "ab", pw: "0000ab0000123"},
|
||||
// longest common substring with long password
|
||||
{id: "d4f6090b-5a84", pw: "d4f6090b-5a84-2184-4404-8d1b-8da3eb00ebbe"},
|
||||
{id: "asdflasdflasdf", pw: "asdflasdflpiuhefnciluaksdzuföfhg"},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := s.Validate(t.Context(), tc.id, tc.pw)
|
||||
if tc.expectedErr == nil {
|
||||
require.NoErrorf(t, err, "id: %s, pw: %s", tc.id, tc.pw)
|
||||
} else {
|
||||
require.ErrorIsf(t, err, tc.expectedErr, "id: %s, pw: %s", tc.id, tc.pw)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("failure cases", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, base := pkg.NewFastRegistryWithMocks(t)
|
||||
s, fakeClient := newTestValidator(t, base, httpx.ResilientClientWithMaxRetryWait(time.Millisecond))
|
||||
|
||||
t.Run("case=should send request to pwnedpasswords.com", func(t *testing.T) {
|
||||
ctx := contextx.WithConfigValue(t.Context(), config.ViperKeyIgnoreNetworkErrors, false)
|
||||
require.Error(t, s.Validate(ctx, "mohutdesub", "damrumukuh"))
|
||||
require.Contains(t, fakeClient.RequestedURLs(), "https://api.pwnedpasswords.com/range/BCBA9")
|
||||
})
|
||||
|
||||
t.Run("case=should fail if request fails and ignoreNetworkErrors is not set", func(t *testing.T) {
|
||||
ctx := contextx.WithConfigValue(t.Context(), config.ViperKeyIgnoreNetworkErrors, false)
|
||||
fakeClient.RespondWithError("Network request failed")
|
||||
require.Error(t, s.Validate(ctx, "", "sumdarmetp"))
|
||||
})
|
||||
|
||||
t.Run("case=should not fail if request fails and ignoreNetworkErrors is set", func(t *testing.T) {
|
||||
ctx := contextx.WithConfigValue(t.Context(), config.ViperKeyIgnoreNetworkErrors, true)
|
||||
fakeClient.RespondWithError("Network request failed")
|
||||
require.NoError(t, s.Validate(ctx, "", "pepegtawni"))
|
||||
})
|
||||
|
||||
t.Run("case=should fail if response has non 200 code and ignoreNetworkErrors is not set", func(t *testing.T) {
|
||||
ctx := contextx.WithConfigValue(t.Context(), config.ViperKeyIgnoreNetworkErrors, false)
|
||||
fakeClient.RespondWith(http.StatusForbidden, "")
|
||||
require.Error(t, s.Validate(ctx, "", "jolhakowef"))
|
||||
})
|
||||
|
||||
t.Run("case=should not fail if response has non 200 code code and ignoreNetworkErrors is set", func(t *testing.T) {
|
||||
ctx := contextx.WithConfigValue(t.Context(), config.ViperKeyIgnoreNetworkErrors, true)
|
||||
fakeClient.RespondWith(http.StatusInternalServerError, "")
|
||||
require.NoError(t, s.Validate(ctx, "", "jenuzuhjoj"))
|
||||
})
|
||||
|
||||
t.Run("case=should fail with a network error if reading the response body fails and ignoreNetworkErrors is not set", func(t *testing.T) {
|
||||
ctx := contextx.WithConfigValue(t.Context(), config.ViperKeyIgnoreNetworkErrors, false)
|
||||
fakeClient.RespondWithBodyError("003D68EB55068C33ACE09247EE4C639306B:3\n", errBodyRead)
|
||||
require.ErrorIs(t, s.Validate(ctx, "", "vebjihwoct"), password.ErrNetworkFailure)
|
||||
})
|
||||
|
||||
t.Run("case=should not fail if reading the response body fails and ignoreNetworkErrors is set", func(t *testing.T) {
|
||||
ctx := contextx.WithConfigValue(t.Context(), config.ViperKeyIgnoreNetworkErrors, true)
|
||||
fakeClient.RespondWithBodyError("003D68EB55068C33ACE09247EE4C639306B:3\n", errBodyRead)
|
||||
require.NoError(t, s.Validate(ctx, "", "hufzeqmalt"))
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("max breaches", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const maxBreaches = 5
|
||||
_, base := pkg.NewFastRegistryWithMocks(t, configx.WithValue(config.ViperKeyPasswordMaxBreaches, maxBreaches))
|
||||
s, fakeClient := newTestValidator(t, base)
|
||||
|
||||
hibpResp := make(chan string, 1)
|
||||
fakeClient.responder = func(req *http.Request) (*http.Response, error) {
|
||||
buffer := bytes.NewBufferString(<-hibpResp)
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(buffer),
|
||||
ContentLength: int64(buffer.Len()),
|
||||
Request: req,
|
||||
}, nil
|
||||
}
|
||||
|
||||
hashPw := func(t *testing.T, pw string) string {
|
||||
//#nosec G401 -- sha1 is used for k-anonymity
|
||||
h := sha1.New()
|
||||
_, err := h.Write([]byte(pw))
|
||||
require.NoError(t, err)
|
||||
hpw := h.Sum(nil)
|
||||
return fmt.Sprintf("%X", hpw)[5:]
|
||||
}
|
||||
randomPassword := func(t *testing.T) string {
|
||||
pw := make([]byte, 10)
|
||||
_, err := rand.Read(pw)
|
||||
require.NoError(t, err)
|
||||
return fmt.Sprintf("%x", pw)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
res func(t *testing.T, hash string) string
|
||||
expectErr error
|
||||
}{
|
||||
{
|
||||
name: "contains invalid data which is ignored",
|
||||
res: func(t *testing.T, hash string) string {
|
||||
return fmt.Sprintf("%s:2\ninvalid", hash)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "is missing a colon",
|
||||
res: func(t *testing.T, hash string) string {
|
||||
return hash
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "contains invalid hash count",
|
||||
res: func(t *testing.T, hash string) string {
|
||||
return fmt.Sprintf("%s:text\n%s:2", hashPw(t, randomPassword(t)), hash)
|
||||
},
|
||||
expectErr: herodot.ErrUpstreamError(),
|
||||
},
|
||||
{
|
||||
name: "is missing hash count",
|
||||
res: func(t *testing.T, hash string) string {
|
||||
return fmt.Sprintf("%s\n%s:2", hash, hashPw(t, randomPassword(t)))
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "response contains no matches",
|
||||
res: func(t *testing.T, hash string) string {
|
||||
return fmt.Sprintf("%s:57", hashPw(t, randomPassword(t)))
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "contains less than maxBreachesThreshold",
|
||||
res: func(t *testing.T, hash string) string {
|
||||
return fmt.Sprintf(
|
||||
"%s:%d\n%s:%d",
|
||||
hash,
|
||||
maxBreaches,
|
||||
hashPw(t, randomPassword(t)),
|
||||
maxBreaches+1,
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "contains less than maxBreachesThreshold with a leading comma",
|
||||
res: func(t *testing.T, hash string) string {
|
||||
return fmt.Sprintf(
|
||||
"%s:%d\n%s:0,%d",
|
||||
hash,
|
||||
maxBreaches,
|
||||
hashPw(t, randomPassword(t)),
|
||||
maxBreaches+1,
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "contains more than maxBreachesThreshold",
|
||||
res: func(t *testing.T, hash string) string {
|
||||
return fmt.Sprintf("%s:%d", hash, maxBreaches+1)
|
||||
},
|
||||
expectErr: text.NewErrorValidationPasswordTooManyBreaches(
|
||||
int64(maxBreaches) + 1, // #nosec G115
|
||||
),
|
||||
},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("case=%s/expected err=%s", tc.name, tc.expectErr), func(t *testing.T) {
|
||||
pw := randomPassword(t)
|
||||
hash := hashPw(t, pw)
|
||||
hibpResp <- tc.res(t, hash)
|
||||
|
||||
err := s.Validate(context.Background(), "", pw)
|
||||
assert.ErrorIs(t, err, tc.expectErr)
|
||||
})
|
||||
|
||||
// verify the fetch was done, i.e. channel is empty
|
||||
select {
|
||||
case r := <-hibpResp:
|
||||
t.Logf("expected the validate step to fetch the response, but I still got %s", r)
|
||||
t.FailNow()
|
||||
default:
|
||||
// continue
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestChangeHaveIBeenPwnedValidationHost(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }))
|
||||
t.Cleanup(testServer.Close)
|
||||
testServerURL, err := url.Parse(testServer.URL)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, base := pkg.NewFastRegistryWithMocks(t, configx.WithValue(config.ViperKeyPasswordHaveIBeenPwnedHost, testServerURL.Host))
|
||||
s, fakeClient := newTestValidator(t, base)
|
||||
|
||||
testServerExpectedCallURL := fmt.Sprintf("https://%s/range/BCBA9", testServerURL.Host)
|
||||
|
||||
t.Run("case=should send request to test server", func(t *testing.T) {
|
||||
ctx := contextx.WithConfigValue(t.Context(), config.ViperKeyIgnoreNetworkErrors, false)
|
||||
require.Error(t, s.Validate(ctx, "mohutdesub", "damrumukuh"))
|
||||
require.Contains(t, fakeClient.RequestedURLs(), testServerExpectedCallURL)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDisableHaveIBeenPwnedValidationHost(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, base := pkg.NewFastRegistryWithMocks(t, configx.WithValue(config.ViperKeyPasswordHaveIBeenPwnedEnabled, false))
|
||||
s, fakeClient := newTestValidator(t, base)
|
||||
|
||||
t.Run("case=should not send request to test server", func(t *testing.T) {
|
||||
require.NoError(t, s.Validate(t.Context(), "mohutdesub", "damrumukuh"))
|
||||
require.Empty(t, fakeClient.RequestedURLs())
|
||||
})
|
||||
}
|
||||
|
||||
func TestChangeMinPasswordLength(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, reg := pkg.NewFastRegistryWithMocks(t, configx.WithValue(config.ViperKeyPasswordMinLength, 10))
|
||||
s, err := password.NewDefaultPasswordValidatorStrategy(reg)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("case=should not fail if password is longer than min length", func(t *testing.T) {
|
||||
require.NoError(t, s.Validate(t.Context(), "", "kuobahcaas"))
|
||||
})
|
||||
|
||||
t.Run("case=should fail if password is shorter than min length", func(t *testing.T) {
|
||||
require.Error(t, s.Validate(t.Context(), "", "rfqyfjied"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestChangeIdentifierSimilarityCheckEnabled(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, reg := pkg.NewFastRegistryWithMocks(t)
|
||||
s, err := password.NewDefaultPasswordValidatorStrategy(reg)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("case=should not fail if password is similar to identifier", func(t *testing.T) {
|
||||
ctx := contextx.WithConfigValue(t.Context(), config.ViperKeyPasswordIdentifierSimilarityCheckEnabled, false)
|
||||
require.NoError(t, s.Validate(ctx, "bosqwfaxee", "bosqwfaxee"))
|
||||
})
|
||||
|
||||
t.Run("case=should fail if password is similar to identifier", func(t *testing.T) {
|
||||
ctx := contextx.WithConfigValue(t.Context(), config.ViperKeyPasswordIdentifierSimilarityCheckEnabled, true)
|
||||
require.Error(t, s.Validate(ctx, "bosqwfaxee", "bosqwfaxee"))
|
||||
})
|
||||
}
|
||||
|
||||
// TestIdentifierSimilarityCheckMaxLength guards against an algorithmic-complexity
|
||||
// DoS (H1 #3866310): the O(n·m) identifier-similarity check must skip oversized
|
||||
// operands, which a short identifier can never be similar to anyway.
|
||||
func TestIdentifierSimilarityCheckMaxLength(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Disable HaveIBeenPwned so validation is deterministic and offline.
|
||||
_, reg := pkg.NewFastRegistryWithMocks(t, configx.WithValue(config.ViperKeyPasswordHaveIBeenPwnedEnabled, false))
|
||||
s, err := password.NewDefaultPasswordValidatorStrategy(reg)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := contextx.WithConfigValue(t.Context(), config.ViperKeyPasswordIdentifierSimilarityCheckEnabled, true)
|
||||
|
||||
t.Run("case=skips the similarity check for oversized operands", func(t *testing.T) {
|
||||
// Two identical strings one char past the 256-char cap would trip the
|
||||
// too-similar check if it ran, so a nil result proves the length guard
|
||||
// skipped it. Staying just over the cap keeps the test cheap even if the
|
||||
// guard regresses (the check allocates an O(n·m) matrix).
|
||||
large := strings.Repeat("a", 257)
|
||||
require.NoError(t, s.Validate(ctx, large, large))
|
||||
})
|
||||
|
||||
t.Run("case=still detects similarity for short operands", func(t *testing.T) {
|
||||
require.ErrorIs(t, s.Validate(ctx, "bosqwfaxee", "bosqwfaxee"), text.NewErrorValidationPasswordIdentifierTooSimilar())
|
||||
})
|
||||
}
|
||||
|
||||
type fakeHttpClient struct {
|
||||
http.Client
|
||||
|
||||
requestedURLs []string
|
||||
responder func(*http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
func NewFakeHTTPClient() *fakeHttpClient {
|
||||
client := fakeHttpClient{
|
||||
responder: func(*http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("No responder defined in fake HTTP client")
|
||||
},
|
||||
}
|
||||
client.Client = http.Client{
|
||||
Transport: &fakeRoundTripper{&client},
|
||||
Timeout: time.Second,
|
||||
}
|
||||
return &client
|
||||
}
|
||||
|
||||
func (c *fakeHttpClient) RespondWith(status int, body string) {
|
||||
c.responder = func(request *http.Request) (*http.Response, error) {
|
||||
buffer := bytes.NewBufferString(body)
|
||||
return &http.Response{
|
||||
StatusCode: status,
|
||||
Body: io.NopCloser(buffer),
|
||||
ContentLength: int64(buffer.Len()),
|
||||
Request: request,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (c *fakeHttpClient) RespondWithError(err string) {
|
||||
c.responder = func(*http.Request) (*http.Response, error) {
|
||||
return nil, errors.New(err)
|
||||
}
|
||||
}
|
||||
|
||||
// RespondWithBodyError responds with a status 200 whose body yields prefix and
|
||||
// then fails, mimicking a connection reset or a client timeout that only hits
|
||||
// once the response is already being read.
|
||||
func (c *fakeHttpClient) RespondWithBodyError(prefix string, err error) {
|
||||
c.responder = func(request *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(io.MultiReader(strings.NewReader(prefix), errorReader{err})),
|
||||
Request: request,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (c *fakeHttpClient) Reset() {
|
||||
c.requestedURLs = nil
|
||||
}
|
||||
|
||||
func (c *fakeHttpClient) RequestedURLs() []string {
|
||||
return c.requestedURLs
|
||||
}
|
||||
|
||||
func (c *fakeHttpClient) handle(request *http.Request) (*http.Response, error) {
|
||||
c.requestedURLs = append(c.requestedURLs, request.URL.String())
|
||||
if request.Body != nil {
|
||||
_ = request.Body.Close()
|
||||
}
|
||||
return c.responder(request)
|
||||
}
|
||||
|
||||
type fakeRoundTripper struct {
|
||||
client *fakeHttpClient
|
||||
}
|
||||
|
||||
func (rt *fakeRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
return rt.client.handle(request)
|
||||
}
|
||||
|
||||
// errBodyRead is the error net/http returns from a response body read when the
|
||||
// client's timeout fires after the response headers have been received.
|
||||
var errBodyRead = errors.New("net/http: request canceled (Client.Timeout or context cancellation while reading body)")
|
||||
|
||||
type errorReader struct{ err error }
|
||||
|
||||
func (r errorReader) Read([]byte) (int, error) { return 0, r.err }
|
||||
Reference in New Issue
Block a user