diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 878c957..ea50a09 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -41,11 +41,11 @@ jobs: name: examples runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Run examples run: make examples - name: Archive code coverage results - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: allure-results path: ./examples/allure-results \ No newline at end of file diff --git a/builder_request.go b/builder_request.go index 67657f4..32d575b 100644 --- a/builder_request.go +++ b/builder_request.go @@ -111,6 +111,22 @@ func (qt *cute) RequestRetryBroken(broken bool) RequestHTTPBuilder { return qt } +// RequestSanitizerHook assigns the provided RequestSanitizerHook to the test, +// allowing URL sanitization before logging or reporting. +func (qt *cute) RequestSanitizerHook(hook RequestSanitizerHook) RequestHTTPBuilder { + qt.tests[qt.countTests].RequestSanitizer = hook + + return qt +} + +// ResponseSanitizerHook assigns the provided ResponseSanitizerHook to the test, +// allowing URL sanitization before logging or reporting. +func (qt *cute) ResponseSanitizerHook(hook ResponseSanitizerHook) RequestHTTPBuilder { + qt.tests[qt.countTests].ResponseSanitizer = hook + + return qt +} + func (qt *cute) Request(r *http.Request) ExpectHTTPBuilder { qt.tests[qt.countTests].Request.Base = r diff --git a/examples/inside_step_test.go b/examples/inside_step_test.go index d980589..61c15a8 100644 --- a/examples/inside_step_test.go +++ b/examples/inside_step_test.go @@ -12,6 +12,7 @@ import ( "github.com/ozontech/allure-go/pkg/framework/provider" "github.com/ozontech/allure-go/pkg/framework/runner" + "github.com/ozontech/cute" ) diff --git a/examples/masked_data_test.go b/examples/masked_data_test.go new file mode 100644 index 0000000..af9a1e6 --- /dev/null +++ b/examples/masked_data_test.go @@ -0,0 +1,64 @@ +//go:build example +// +build example + +package examples + +import ( + "context" + "net/http" + "net/url" + "testing" + "time" + + "github.com/ozontech/allure-go/pkg/framework/provider" + "github.com/ozontech/allure-go/pkg/framework/runner" + + "github.com/ozontech/cute" +) + +func TestSanitizer(t *testing.T) { + runner.Run(t, "Single test with request and response sanitizer", func(t provider.T) { + + t.WithNewStep("First step", func(sCtx provider.StepCtx) { + sCtx.NewStep("Inside first step") + }) + + t.WithNewStep("Step name", func(sCtx provider.StepCtx) { + u, _ := url.Parse("https://jsonplaceholder.typicode.com/posts/1/comments?example=11") + query := u.Query() + query.Set("name", "Vasya") + u.RawQuery = query.Encode() + + cute.NewTestBuilder(). + Title("Super simple test"). + Tags("simple", "suite", "some_local_tag", "json"). + Parallel(). + Create(). + RequestSanitizerHook(func(req *http.Request) { + req.URL.Path = "/path/masked" + + values := req.URL.Query() + values.Set("example", "masked") + + req.URL.RawQuery = values.Encode() + + req.Header["some_header"] = []string{"masked"} + }). + ResponseSanitizerHook(func(resp *http.Response) { + resp.Header["some_header"] = []string{"masked"} + resp.Header["Content-Type"] = []string{"masked"} + }). + RequestBuilder( + cute.WithHeaders(map[string][]string{ + "some_header": []string{"something"}, + }), + cute.WithURL(u), + cute.WithMethod(http.MethodPost), + ). + ExpectExecuteTimeout(10*time.Second). + ExpectStatus(http.StatusCreated). + ExecuteTest(context.Background(), sCtx) + }) + }) + +} diff --git a/interface.go b/interface.go index 5cac53a..0f514ee 100644 --- a/interface.go +++ b/interface.go @@ -218,6 +218,20 @@ type RequestParams interface { // Deprecated: use RequestRetryBroken instead RequestRepeatBroken(broken bool) RequestHTTPBuilder RequestRetryBroken(broken bool) RequestHTTPBuilder + + // RequestSanitizerHook sets a RequestSanitizerHook function for the request. + // This hook allows you to modify or mask parts of the request URL (e.g., hide sensitive data) + // before it is logged or added to the test report (Allure). + // Example usage: RequestWithSanitizeHook(func(req *http.Request) { ... }). + // Example: RequestWithSanitizeHook(func(req *http.Request) { req.URL.Path = "/masked" }). + // Example: RequestWithSanitizeHook(func(req *http.Request) { req.Header["some_header"] = []string{"masked"} }). + RequestSanitizerHook(hook RequestSanitizerHook) RequestHTTPBuilder + + // ResponseSanitizerHook sets a ResponseSanitizerHook function for the request. + // This hook allows you to modify or mask parts of the response body (e.g., hide sensitive data) + // before it is logged or added to the test report (Allure). + // Example usage: ResponseWithSanitizeHook(func(resp *http.Response) { ... }). + ResponseSanitizerHook(hook ResponseSanitizerHook) RequestHTTPBuilder } // ExpectHTTPBuilder is a scope of methods for validate http response diff --git a/roundtripper.go b/roundtripper.go index bb971c6..989f251 100644 --- a/roundtripper.go +++ b/roundtripper.go @@ -10,9 +10,10 @@ import ( "time" "github.com/ozontech/allure-go/pkg/allure" + "moul.io/http2curl/v2" + cuteErrors "github.com/ozontech/cute/errors" "github.com/ozontech/cute/internal/utils" - "moul.io/http2curl/v2" ) func (it *Test) makeRequest(t internalT, req *http.Request) (*http.Response, []error) { @@ -34,7 +35,7 @@ func (it *Test) makeRequest(t internalT, req *http.Request) (*http.Response, []e } for i := 1; i <= countRepeat; i++ { - it.executeWithStep(t, createTitle(i, countRepeat, req), func(t T) []error { + it.executeWithStep(t, it.createTitle(i, countRepeat, req), func(t T) []error { resp, err = it.doRequest(t, req) if err != nil { if it.Request.Retry.Broken { @@ -148,6 +149,12 @@ func (it *Test) addInformationRequest(t T, req *http.Request) error { err error ) + if it.RequestSanitizer != nil { + it.RequestSanitizer(req) + } + + it.lastRequestURL = req.URL.String() + curl, err := http2curl.GetCurlCommand(req) if err != nil { return err @@ -215,6 +222,10 @@ func (it *Test) addInformationResponse(t T, response *http.Response) error { err error ) + if it.ResponseSanitizer != nil { + it.ResponseSanitizer(response) + } + headers, _ := utils.ToJSON(response.Header) if headers != "" { t.WithNewParameters("response_headers", headers) @@ -265,8 +276,24 @@ func (it *Test) addInformationResponse(t T, response *http.Response) error { return nil } -func createTitle(try, countRepeat int, req *http.Request) string { - title := req.Method + " " + req.URL.String() +func (it *Test) createTitle(try, countRepeat int, req *http.Request) string { + toProcess := req + + // We have to execute sanitizer hook because + // we need to log it and it can contain sensitive data + if it.RequestSanitizer != nil { + clone, err := copyRequest(req.Context(), req) + + // ignore error, because we want to log request + // and it does not matter if we can copy request + if err == nil { + it.RequestSanitizer(clone) + + toProcess = clone + } + } + + title := toProcess.Method + " " + toProcess.URL.String() if countRepeat == 1 { return title diff --git a/test.go b/test.go index 873eec0..5fca33d 100644 --- a/test.go +++ b/test.go @@ -29,12 +29,21 @@ var ( errorRequestURLEmpty = errors.New("url request must be not empty") ) +// RequestSanitizerHook is a function used to modify the request URL +// before it is logged or attached to test reports (e.g., for hiding secrets). +type RequestSanitizerHook func(req *http.Request) + +// ResponseSanitizerHook is a function used to modify the response +// before it is logged or attached to test reports (e.g., for hiding secrets). +type ResponseSanitizerHook func(resp *http.Response) + // Test is a main struct of test. // You may field Request and Expect for create simple test // Parallel can be used to control the parallelism of a Test type Test struct { - httpClient *http.Client - jsonMarshaler JSONMarshaler + httpClient *http.Client + jsonMarshaler JSONMarshaler + lastRequestURL string Name string Parallel bool @@ -44,6 +53,9 @@ type Test struct { Middleware *Middleware Request *Request Expect *Expect + + RequestSanitizer RequestSanitizerHook + ResponseSanitizer ResponseSanitizerHook } // Retry is a struct to control the retry of a whole single test (not only the request) @@ -474,6 +486,7 @@ func (it *Test) beforeTest(t internalT, req *http.Request) []error { }) } +// createRequest builds the final *http.Request to be executed by the test. func (it *Test) createRequest(ctx context.Context) (*http.Request, error) { var ( req = it.Request.Base diff --git a/test_test.go b/test_test.go index ca34f77..4074030 100644 --- a/test_test.go +++ b/test_test.go @@ -6,12 +6,15 @@ import ( "errors" "io" "net/http" + "net/http/httptest" "net/url" + "strings" "testing" "github.com/ozontech/allure-go/pkg/framework/core/common" - "github.com/ozontech/cute/internal/utils" "github.com/stretchr/testify/require" + + "github.com/ozontech/cute/internal/utils" ) func TestCreateRequest(t *testing.T) { @@ -163,3 +166,109 @@ func TestValidateResponseWithErrors(t *testing.T) { require.Len(t, errs, 2) } + +type mockRoundTripper struct{} + +func (m *mockRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: 200, + Request: req, + Body: io.NopCloser(strings.NewReader("mock response")), + }, nil +} + +func TestSanitizeURLHook(t *testing.T) { + client := &http.Client{ + Transport: &mockRoundTripper{}, + } + + test := &Test{ + httpClient: client, + Retry: &Retry{ + currentCount: 0, + MaxAttempts: 0, + Delay: 0, + }, + Request: &Request{ + Builders: []RequestBuilder{ + WithMethod(http.MethodGet), + WithURI("http://localhost/api?key=123"), + }, + Retry: &RequestRetryPolitic{ + Count: 1, + Delay: 2, + }, + }, + RequestSanitizer: sanitizeKeyParam("****"), + } + + req, err := test.createRequest(context.Background()) + require.NoError(t, err) + require.NotNil(t, req) + + newT := createAllureT(t) + + err = test.addInformationRequest(newT, req) + require.NoError(t, err) + + decodedQuery, err := url.QueryUnescape(req.URL.RawQuery) + require.NoError(t, err) + require.Equal(t, "key=****", decodedQuery) +} + +func TestSanitizeURL_LastRequestURL(t *testing.T) { + client := &http.Client{ + Transport: &mockRoundTripper{}, + } + + test := &Test{ + httpClient: client, + Request: &Request{ + Builders: []RequestBuilder{ + WithMethod(http.MethodGet), + WithURI("http://localhost/api?key=123"), + }, + }, + RequestSanitizer: sanitizeKeyParam("****"), + } + + allureT := createAllureT(t) + test.Execute(context.Background(), allureT) + + decodedURL, err := url.QueryUnescape(test.lastRequestURL) + require.NoError(t, err) + require.Contains(t, decodedURL, "key=****", "Expected masked key in lastRequestURL") +} + +func sanitizeKeyParam(mask string) RequestSanitizerHook { + return func(req *http.Request) { + q := req.URL.Query() + q.Set("key", mask) + req.URL.RawQuery = q.Encode() + } +} + +func TestSanitizeURL_RealRequest(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + t.Logf("Server received URL: %s, Body: %s", r.URL.String(), string(body)) + require.Contains(t, r.URL.String(), "key=123", "Sanitizer must not change real request") + w.WriteHeader(200) + })) + defer ts.Close() + + client := &http.Client{} + test := &Test{ + httpClient: client, + Request: &Request{ + Builders: []RequestBuilder{ + WithMethod(http.MethodGet), + WithURI(ts.URL + "/api?key=123"), + }, + }, + RequestSanitizer: sanitizeKeyParam("****"), + } + + allureT := createAllureT(t) + test.Execute(context.Background(), allureT) +}