diff --git a/README.md b/README.md index cd1843c..918078e 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,30 @@ r.AddFilter(func(i *cassette.Interaction) error { }) ``` +### Sensitive data in responses + +Filters added using `*Recorder.AddFilter` are applied within VCR's custom `http.Transport`. This means that if you edit a response in such a filter then subsequent test code will see the edited response. This may not be desirable in all cases. For instance, if a response body contains an OAuth access token that is needed for subsequent requests, then redact the access token in `SaveFilter` will result in authorization failures. + +Another way to edit recorded interactions is to use `*Recorder.AddSaveFilter`. Filters added with this method are applied just before interactions are saved when `*Recorder.Stop` is called. + +```go +r, err := recorder.New("fixtures/filters") +if err != nil { + log.Fatal(err) +} +defer r.Stop() // Make sure recorder is stopped once done with it + +// Your test code will continue to see the real access token and +// it is redacted before the recorded interactions are saved +r.AddSaveFilter(func(i *cassette.Interaction) error { + if strings.Contains(i.URL, "/oauth/token") { + i.Response.Body = `{"access_token": "[REDACTED]"}` + } + + return nil +}) +``` + ## Passing Through Requests Sometimes you want to allow specific requests to pass through to the remote diff --git a/cassette/cassette.go b/cassette/cassette.go index cf9e340..5dbe109 100644 --- a/cassette/cassette.go +++ b/cassette/cassette.go @@ -129,8 +129,11 @@ type Cassette struct { // Matches actual request with interaction requests. Matcher Matcher `yaml:"-"` - // Filters interactions before being saved. + // Filters interactions before when they are captured. Filters []Filter `yaml:"-"` + + // SaveFilters are applied to interactions just before they are saved. + SaveFilters []Filter `yaml:"-"` } // New creates a new empty cassette @@ -142,6 +145,7 @@ func New(name string) *Cassette { Interactions: make([]*Interaction, 0), Matcher: DefaultMatcher, Filters: make([]Filter, 0), + SaveFilters: make([]Filter, 0), } return c @@ -190,6 +194,14 @@ func (c *Cassette) Save() error { return nil } + for _, interaction := range c.Interactions { + for _, filter := range c.SaveFilters { + if err := filter(interaction); err != nil { + return err + } + } + } + // Create directory for cassette if missing cassetteDir := filepath.Dir(c.File) if _, err := os.Stat(cassetteDir); os.IsNotExist(err) { diff --git a/recorder/recorder.go b/recorder/recorder.go index 750aecd..83109d3 100644 --- a/recorder/recorder.go +++ b/recorder/recorder.go @@ -292,6 +292,16 @@ func (r *Recorder) AddFilter(filter cassette.Filter) { } } +// AddSaveFilter appends a hook to modify a request before it is saved. +// +// This filter is suitable for treating recorded responses to remove sensitive data. Altering responses using a regular +// AddFilter can have unintended consequences on code that is consuming responses. +func (r *Recorder) AddSaveFilter(filter cassette.Filter) { + if r.cassette != nil { + r.cassette.SaveFilters = append(r.cassette.SaveFilters, filter) + } +} + // Mode returns recorder state func (r *Recorder) Mode() Mode { return r.mode diff --git a/recorder/recorder_test.go b/recorder/recorder_test.go index 6421e77..a1e8de0 100644 --- a/recorder/recorder_test.go +++ b/recorder/recorder_test.go @@ -280,7 +280,46 @@ func TestFilter(t *testing.T) { t.Fatalf("got:\t%s\n\twant:\t%s", string(body), string(dummyBody)) } } +} + +func TestSaveFilter(t *testing.T) { + dummyBody := "[REDACTED]" + + runID, cassPath, tests := setupTests(t, "test_save_filter") + recorder, server := httpRecorderTestSetup(t, runID, cassPath, recorder.ModeRecording) + serverURL := server.URL + + // Add a filter which replaces each request body in the stored cassette: + recorder.AddSaveFilter(func(i *cassette.Interaction) error { + i.Response.Body = dummyBody + return nil + }) + + t.Log("make http requests") + for _, test := range tests { + test.perform(t, serverURL, recorder) + } + + // Make sure recorder is stopped once done with it + server.Close() + t.Log("server shut down") + + recorder.Stop() + t.Log("recorder stopped") + // Load the cassette we just stored: + c, err := cassette.Load(cassPath) + if err != nil { + t.Fatal(err) + } + + // Assert that each body has been set to our dummy value + for i := range tests { + body := c.Interactions[i].Response.Body + if body != dummyBody { + t.Fatalf("got:\t%s\n\twant:\t%s", string(body), string(dummyBody)) + } + } } func httpRecorderTestSetup(t *testing.T, runID string, cassPath string, mode recorder.Mode) (*recorder.Recorder, *httptest.Server) {