diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dc1688..22285aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # CHANGELOG +## Next Release + +- Add support for censoring XML, HTML and plain text bodies +- [BREAKING CHANGE] `CensorElement` now abstract base class, cannot be used directly + - Three types of `CensorElement` options available: + - `KeyCensorElement`: Censor the value of a specified key (will be ignored if used for plain text/HTML data) + - `RegexCensorElement`: Censor any string that matches a specified regex pattern (will check the value of a key-value pair if used for JSON/XML data) + - `TextCensorElement`: Censor a specified string (will check the value of a key-value pair if used for JSON/XML data; requires the whole body to match the specified string if used for plain text/HTML data) + - Body censoring: `KeyCensorElement` (recommended for JSON/XML if key is known), `TextCensorElement` (recommended for JSON/XML if value is known), and `RegexCensorElement` (recommended for plain text/HTML) + - Path element censoring: Use `RegexCensorElement` + - Query parameter censoring: Use `KeyCensorElement` + - Header censoring: Use `KeyCensorElement` +- [BREAKING CHANGE] `CensorHeadersByKeys`, `CensorBodyElementsByKeys`, `CensorQueryParametersByKeys` and `CensorPathElementsByPatterns` removed + - Use `CensorHeaders`, `CensorBodyElements`, `CensorQueryParameters` and `CensorPathElements` instead + ## v0.9.0 (2023-05-17) - Fix a bug where URLs were not being extracted correctly, potentially causing false matches when matching by URL diff --git a/EasyVCR.Tests/CensorsTest.cs b/EasyVCR.Tests/CensorsTest.cs index 045e336..4341c47 100644 --- a/EasyVCR.Tests/CensorsTest.cs +++ b/EasyVCR.Tests/CensorsTest.cs @@ -1,4 +1,8 @@ +using System; using System.Collections.Generic; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using System.Xml; using Microsoft.VisualStudio.TestTools.UnitTesting; namespace EasyVCR.Tests @@ -201,5 +205,337 @@ public void TestApplyPathElementsCensorsNoCensorsReturnsOriginalUrl() Assert.AreEqual(url, result); } + + /// + /// Test TextCensorElement works for XML bodies + /// + [TestMethod] + public async Task TestTextCensorOnXml() + { + var cassette = TestUtils.GetCassette("test_text_censor_on_xml"); + cassette.Erase(); // Erase cassette before recording + + // set up advanced settings + var censorString = new Guid().ToString(); // generate random string, high chance of not being in original data + var advancedSettings = new AdvancedSettings + { + Censors = new Censors(censorString).CensorBodyElements( + new List + { + // censor the word "r/ProgrammerHumor" + new TextCensorElement("r/ProgrammerHumor", false), + }), + }; + + // record cassette with advanced settings first + var client = HttpClients.NewHttpClient(cassette, Mode.Record, advancedSettings); + var fakeDataService = new FakeDataService(client); + var _ = await fakeDataService.GetXmlDataRawResponse(); + + // now replay cassette + client = HttpClients.NewHttpClient(cassette, Mode.Replay, advancedSettings); + fakeDataService = new FakeDataService(client); + var xmlData = await fakeDataService.GetXmlData(); + + Assert.IsNotNull(xmlData); + var xmlDocument = new XmlDocument(); + xmlDocument.LoadXml(xmlData); + + // word "r/ProgrammerHumor" should be censored + // for testing purposes, we know this is the "label" property of the "category" node under "feed" + var categoryNode = xmlDocument.FirstChild?.FirstChild; + Assert.IsNotNull(categoryNode); + Assert.AreEqual(censorString, categoryNode.Attributes["label"].Value); + } + + /// + /// Test KeyCensorElement works for XML bodies + /// + [TestMethod] + public async Task TestKeyCensorOnXml() + { + var cassette = TestUtils.GetCassette("test_key_censor_on_xml"); + cassette.Erase(); // Erase cassette before recording + + // set up advanced settings + var censorString = new Guid().ToString(); // generate random string, high chance of not being in original data + var advancedSettings = new AdvancedSettings + { + Censors = new Censors(censorString).CensorBodyElements( + new List + { + // censor the value of the "title" key + new KeyCensorElement("title", false), + }), + }; + + // record cassette with advanced settings first + var client = HttpClients.NewHttpClient(cassette, Mode.Record, advancedSettings); + var fakeDataService = new FakeDataService(client); + var _ = await fakeDataService.GetXmlDataRawResponse(); + + // now replay cassette + client = HttpClients.NewHttpClient(cassette, Mode.Replay, advancedSettings); + fakeDataService = new FakeDataService(client); + var xmlData = await fakeDataService.GetXmlData(); + + Assert.IsNotNull(xmlData); + var xmlDocument = new XmlDocument(); + xmlDocument.LoadXml(xmlData); + + // whole value of "title" key should be censored + var nodes = xmlDocument.SelectNodes("//title"); + Assert.IsNotNull(nodes); + foreach (XmlNode node in nodes) + { + Assert.AreEqual(censorString, node.InnerText); + } + } + + /// + /// Test RegexCensorElement works for XML bodies + /// + [TestMethod] + public async Task TestRegexCensorOnXml() + { + var cassette = TestUtils.GetCassette("test_regex_censor_on_xml"); + cassette.Erase(); // Erase cassette before recording + + // set up advanced settings + var censorString = new Guid().ToString(); // generate random string, high chance of not being in original data + var advancedSettings = new AdvancedSettings + { + Censors = new Censors(censorString).CensorBodyElements( + new List + { + // censor any value that looks like an date stamp + new RegexCensorElement(@"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}", false), + }), + }; + + // record cassette with advanced settings first + var client = HttpClients.NewHttpClient(cassette, Mode.Record, advancedSettings); + var fakeDataService = new FakeDataService(client); + var _ = await fakeDataService.GetXmlDataRawResponse(); + + // now replay cassette + client = HttpClients.NewHttpClient(cassette, Mode.Replay, advancedSettings); + fakeDataService = new FakeDataService(client); + var xmlData = await fakeDataService.GetXmlData(); + + Assert.IsNotNull(xmlData); + var xmlDocument = new XmlDocument(); + xmlDocument.LoadXml(xmlData); + + // all values that look like urls should be censored + // for testing purposes, we know this is stored in the "uri" nodes + var nodes = xmlDocument.SelectNodes("//uri"); + Assert.IsNotNull(nodes); + foreach (XmlNode node in nodes) + { + Assert.AreEqual(censorString, node.InnerText); + } + } + + [Ignore("Hard to test")] + [TestMethod] + public async Task TestTextCensorOnHtml() + { + // TextCensorHTML censors the whole text in the HTML body + // Would need an HTML page with a small body to test this + Assert.Fail(); + } + + [Ignore("Can't use KeyCensorElement on HTML bodies")] + [TestMethod] + public async Task TestKeyCensorOnHtml() + { + Assert.Fail("Can't use KeyCensorElement on HTML bodies"); + } + + [TestMethod] + public async Task TestRegexCensorOnHtml() + { + var cassette = TestUtils.GetCassette("test_regex_censor_on_html"); + cassette.Erase(); // Erase cassette before recording + + // set up advanced settings + var censorString = new Guid().ToString(); // generate random string, high chance of not being in original data + const string pattern = ".*"; + var advancedSettings = new AdvancedSettings + { + Censors = new Censors(censorString).CensorBodyElements( + new List + { + // censor the pattern + new RegexCensorElement(pattern, false), + }), + }; + + // record cassette with advanced settings first + var client = HttpClients.NewHttpClient(cassette, Mode.Record, advancedSettings); + var fakeDataService = new FakeDataService(client); + var _ = await fakeDataService.GetHtmlDataRawResponse(); + + // now replay cassette + client = HttpClients.NewHttpClient(cassette, Mode.Replay, advancedSettings); + fakeDataService = new FakeDataService(client); + var textData = await fakeDataService.GetHtmlData(); + + Assert.IsNotNull(textData); + + // censored pattern should no longer exist, and censor string should exist + Assert.IsFalse(Regex.IsMatch(textData, pattern)); + Assert.IsTrue(textData.Contains(censorString)); + } + + /// + /// Test TextCensorElement works for plain text bodies + /// + [TestMethod] + public async Task TestTextCensorOnText() + { + var cassette = TestUtils.GetCassette("test_text_censor_on_text"); + cassette.Erase(); // Erase cassette before recording + + // set up advanced settings + var censorString = new Guid().ToString(); // generate random string, high chance of not being in original data + const string textToCensor = "# UGAArchive\nArchives of projects I did as a student at The University of Georgia\n"; + var advancedSettings = new AdvancedSettings + { + Censors = new Censors(censorString).CensorBodyElements( + new List + { + // censor the text + new TextCensorElement(textToCensor, false), + }), + }; + + // record cassette with advanced settings first + var client = HttpClients.NewHttpClient(cassette, Mode.Record, advancedSettings); + var fakeDataService = new FakeDataService(client); + var _ = await fakeDataService.GetRawDataRawResponse(); + + // now replay cassette + client = HttpClients.NewHttpClient(cassette, Mode.Replay, advancedSettings); + fakeDataService = new FakeDataService(client); + var textData = await fakeDataService.GetRawData(); + + Assert.IsNotNull(textData); + + // censored word should no longer exist, and censor string should exist + Assert.IsFalse(textData.Contains(textToCensor)); + Assert.IsTrue(textData.Contains(censorString)); + } + + [Ignore("Can't use KeyCensorElement on plain text bodies")] + [TestMethod] + public async Task TestKeyCensorOnText() + { + Assert.Fail("Can't use KeyCensorElement on plain text bodies"); + } + + /// + /// Test RegexCensorElement works for plain text bodies + /// + [TestMethod] + public async Task TestRegexCensorOnText() + { + var cassette = TestUtils.GetCassette("test_regex_censor_on_text"); + cassette.Erase(); // Erase cassette before recording + + // set up advanced settings + var censorString = new Guid().ToString(); // generate random string, high chance of not being in original data + const string pattern = "^# UGAArchive"; + var advancedSettings = new AdvancedSettings + { + Censors = new Censors(censorString).CensorBodyElements( + new List + { + // censor the pattern + new RegexCensorElement(pattern, false), + }), + }; + + // record cassette with advanced settings first + var client = HttpClients.NewHttpClient(cassette, Mode.Record, advancedSettings); + var fakeDataService = new FakeDataService(client); + var _ = await fakeDataService.GetRawDataRawResponse(); + + // now replay cassette + client = HttpClients.NewHttpClient(cassette, Mode.Replay, advancedSettings); + fakeDataService = new FakeDataService(client); + var textData = await fakeDataService.GetRawData(); + + Assert.IsNotNull(textData); + + // censored pattern should no longer exist, and censor string should exist + Assert.IsFalse(Regex.IsMatch(textData, pattern)); + Assert.IsTrue(textData.Contains(censorString)); + } + + /// + /// Test that we can mix and match censor elements + /// + [TestMethod] + public async Task TestMixAndMatchCensorElements() + { + var cassette = TestUtils.GetCassette("test_mix_and_match_censor_elements"); + cassette.Erase(); // Erase cassette before recording + + // set up advanced settings + var censorString = new Guid().ToString(); // generate random string, high chance of not being in original data + var advancedSettings = new AdvancedSettings + { + Censors = new Censors(censorString).CensorBodyElements( + new List + { + // censor the word "r/ProgrammerHumor" + new TextCensorElement("r/ProgrammerHumor", false), + // censor the value of the "title" key + new KeyCensorElement("title", false), + // censor any value that looks like an date stamp + new RegexCensorElement(@"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}", false), + }), + }; + + // record cassette with advanced settings first + var client = HttpClients.NewHttpClient(cassette, Mode.Record, advancedSettings); + var fakeDataService = new FakeDataService(client); + var _ = await fakeDataService.GetXmlDataRawResponse(); + + // now replay cassette + client = HttpClients.NewHttpClient(cassette, Mode.Replay, advancedSettings); + fakeDataService = new FakeDataService(client); + var xmlData = await fakeDataService.GetXmlData(); + + // check that the xml data was censored + Assert.IsNotNull(xmlData); + var xmlDocument = new XmlDocument(); + xmlDocument.LoadXml(xmlData); + + // word "r/ProgrammerHumor" should be censored + // for testing purposes, we know this is the "label" property of the "category" node under "feed" + var categoryNode = xmlDocument.FirstChild?.FirstChild; + Assert.IsNotNull(categoryNode); + Assert.AreEqual(censorString, categoryNode.Attributes["label"].Value); + + // whole value of "title" key should be censored + var nodes = xmlDocument.SelectNodes("//title"); + Assert.IsNotNull(nodes); + foreach (XmlNode node in nodes) + { + Assert.AreEqual(censorString, node.InnerText); + } + + // all values that look like urls should be censored + // for testing purposes, we know this is stored in the "uri" nodes + nodes = xmlDocument.SelectNodes("//uri"); + Assert.IsNotNull(nodes); + foreach (XmlNode node in nodes) + { + Assert.AreEqual(censorString, node.InnerText); + } + } } } diff --git a/EasyVCR.Tests/ClientTest.cs b/EasyVCR.Tests/ClientTest.cs index d6f2f6b..86c2544 100644 --- a/EasyVCR.Tests/ClientTest.cs +++ b/EasyVCR.Tests/ClientTest.cs @@ -6,6 +6,7 @@ using System.Text; using System.Threading.Tasks; using System.Web; +using System.Xml; using EasyVCR.Handlers; using EasyVCR.RequestElements; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -89,13 +90,12 @@ public async Task TestAutoMode() cassette.Erase(); // Erase cassette before recording // in replay mode, if cassette is empty, should throw an exception - await Assert.ThrowsExceptionAsync(async () => await GetIPAddressDataRequest(cassette, Mode.Replay)); + await Assert.ThrowsExceptionAsync(async () => await GetJsonDataRequest(cassette, Mode.Replay)); Assert.IsTrue(cassette.NumInteractions == 0); // Make sure cassette is still empty // in auto mode, if cassette is empty, should make and record a real request - var summary = await GetIPAddressDataRequest(cassette, Mode.Auto); - Assert.IsNotNull(summary); - Assert.IsNotNull(summary.IPAddress); + var responseBody = await GetJsonDataRequest(cassette, Mode.Auto); + Assert.IsNotNull(responseBody); Assert.IsTrue(cassette.NumInteractions > 0); // Make sure cassette is no longer empty } @@ -107,20 +107,21 @@ public async Task TestCensors() // set up advanced settings const string censorString = "censored-by-test"; + var headerCensors = new List { new KeyCensorElement("Date", false) }; var advancedSettings = new AdvancedSettings { - Censors = new Censors(censorString).CensorHeadersByKeys(new List { "Date" }) + Censors = new Censors(censorString).CensorHeaders(headerCensors), }; // record cassette with advanced settings first var client = HttpClients.NewHttpClient(cassette, Mode.Record, advancedSettings); - var fakeDataService = new FakeJsonDataService(client); - var _ = await fakeDataService.GetIPAddressDataRawResponse(); + var fakeDataService = new FakeDataService(client); + var _ = await fakeDataService.GetJsonDataRawResponse(); // now replay cassette client = HttpClients.NewHttpClient(cassette, Mode.Replay, advancedSettings); - fakeDataService = new FakeJsonDataService(client); - var response = await fakeDataService.GetIPAddressDataRawResponse(); + fakeDataService = new FakeDataService(client); + var response = await fakeDataService.GetJsonDataRawResponse(); // check that the replayed response contains the censored header Assert.IsNotNull(response); @@ -142,26 +143,27 @@ public async Task TestRegexCensors() cassette.Erase(); // Erase cassette before recording // set up regex pattern - var url = new Uri(FakeDataService.GetIPAddressDataUrl()); + var url = new Uri(FakeDataService.JsonDataUrl); var domain = url.Host; var regexPattern = domain; // set up advanced settings const string censorString = "censored-by-test"; + var pathCensors = new List { new RegexCensorElement(regexPattern, false) }; var advancedSettings = new AdvancedSettings { - Censors = new Censors(censorString).CensorPathElementsByPatterns(new List { regexPattern }) + Censors = new Censors(censorString).CensorPathElements(pathCensors), }; // record cassette with advanced settings var client = HttpClients.NewHttpClient(cassette, Mode.Record, advancedSettings); - var fakeDataService = new FakeJsonDataService(client); - var _ = await fakeDataService.GetIPAddressDataRawResponse(); + var fakeDataService = new FakeDataService(client); + var _ = await fakeDataService.GetJsonDataRawResponse(); // verify that censoring does not interfere with replay client = HttpClients.NewHttpClient(cassette, Mode.Replay, advancedSettings); - fakeDataService = new FakeJsonDataService(client); - var response = await fakeDataService.GetIPAddressDataRawResponse(); + fakeDataService = new FakeDataService(client); + var response = await fakeDataService.GetJsonDataRawResponse(); Assert.IsNotNull(response); } @@ -249,15 +251,15 @@ public async Task TestDelay() // record cassette first var client = HttpClients.NewHttpClient(cassette, Mode.Record); - var fakeDataService = new FakeJsonDataService(client); - var _ = await fakeDataService.GetIPAddressDataRawResponse(); + var fakeDataService = new FakeDataService(client); + var _ = await fakeDataService.GetJsonDataRawResponse(); // baseline - how much time does it take to replay the cassette? client = HttpClients.NewHttpClient(cassette, Mode.Replay); - fakeDataService = new FakeJsonDataService(client); + fakeDataService = new FakeDataService(client); var stopwatch = new Stopwatch(); stopwatch.Start(); - var response = await fakeDataService.GetIPAddressDataRawResponse(); + var response = await fakeDataService.GetJsonDataRawResponse(); stopwatch.Stop(); // confirm the normal replay worked, note time @@ -271,12 +273,12 @@ public async Task TestDelay() ManualDelay = delay }; client = HttpClients.NewHttpClient(cassette, Mode.Replay, advancedSettings); - fakeDataService = new FakeJsonDataService(client); + fakeDataService = new FakeDataService(client); // time replay request stopwatch = new Stopwatch(); stopwatch.Start(); - response = await fakeDataService.GetIPAddressDataRawResponse(); + response = await fakeDataService.GetJsonDataRawResponse(); stopwatch.Stop(); // check that the delay was respected (within margin of error) @@ -291,7 +293,7 @@ public async Task TestErase() var cassette = TestUtils.GetCassette("test_erase"); // record something to the cassette - var _ = await GetIPAddressDataRequest(cassette, Mode.Record); + var _ = await GetJsonDataRequest(cassette, Mode.Record); Assert.IsTrue(cassette.NumInteractions > 0); // erase the cassette @@ -306,7 +308,7 @@ public async Task TestEraseAndPlayback() cassette.Erase(); // Erase cassette before recording // cassette is empty, so replaying should throw an exception - await Assert.ThrowsExceptionAsync(async () => await GetIPAddressDataRequest(cassette, Mode.Replay)); + await Assert.ThrowsExceptionAsync(async () => await GetJsonDataRequest(cassette, Mode.Replay)); } [TestMethod] @@ -315,10 +317,9 @@ public async Task TestEraseAndRecord() var cassette = TestUtils.GetCassette("test_erase_and_record"); cassette.Erase(); // Erase cassette before recording - var summary = await GetIPAddressDataRequest(cassette, Mode.Record); + var responseBody = await GetJsonDataRequest(cassette, Mode.Record); - Assert.IsNotNull(summary); - Assert.IsNotNull(summary.IPAddress); + Assert.IsNotNull(responseBody); Assert.IsTrue(cassette.NumInteractions > 0); // Make sure cassette is not empty } @@ -330,13 +331,13 @@ public async Task TestExpirationSettings() // record cassette first var client = HttpClients.NewHttpClient(cassette, Mode.Record); - var fakeDataService = new FakeJsonDataService(client); - await fakeDataService.GetIPAddressDataRawResponse(); + var fakeDataService = new FakeDataService(client); + await fakeDataService.GetJsonDataRawResponse(); // replay cassette with default expiration rules, should find a match client = HttpClients.NewHttpClient(cassette, Mode.Replay); - fakeDataService = new FakeJsonDataService(client); - var response = await fakeDataService.GetIPAddressDataRawResponse(); + fakeDataService = new FakeDataService(client); + var response = await fakeDataService.GetJsonDataRawResponse(); Assert.IsNotNull(response); // replay cassette with custom expiration rules, should not find a match because recording is expired (throw exception) @@ -347,8 +348,8 @@ public async Task TestExpirationSettings() }; Task.Delay(TimeSpan.FromSeconds(1)).Wait(); // Allow 1 second to lapse to ensure recording is now "expired" client = HttpClients.NewHttpClient(cassette, Mode.Replay, advancedSettings); - fakeDataService = new FakeJsonDataService(client); - await Assert.ThrowsExceptionAsync(async () => await fakeDataService.GetIPAddressDataRawResponse()); + fakeDataService = new FakeDataService(client); + await Assert.ThrowsExceptionAsync(async () => await fakeDataService.GetJsonDataRawResponse()); // replay cassette with bad expiration rules, should throw an exception because settings are bad advancedSettings = new AdvancedSettings @@ -364,7 +365,7 @@ public void TestFakeDataServiceClient() { var client = TestUtils.GetSimpleClient("test_fake_data_service_client", Mode.Bypass); - var fakeDataService = new FakeJsonDataService(client); + var fakeDataService = new FakeDataService(client); Assert.IsNotNull(fakeDataService.Client); } @@ -379,7 +380,7 @@ public async Task TestIgnoreElementsFailMatch() // record baseline request first var client = HttpClients.NewHttpClient(cassette, Mode.Record); - var _ = await client.PostAsync(FakeDataService.GetPreparedIPAddressDataUrl("json"), bodyData1); + var _ = await client.PostAsync(FakeDataService.JsonDataUrl, bodyData1); // try to replay the request with different body data client = HttpClients.NewHttpClient(cassette, Mode.Replay, new AdvancedSettings @@ -388,7 +389,7 @@ public async Task TestIgnoreElementsFailMatch() }); // should fail since we're strictly in replay mode and there's no exact match - await Assert.ThrowsExceptionAsync(async () => await client.PostAsync(FakeDataService.GetPreparedIPAddressDataUrl("json"), bodyData2)); + await Assert.ThrowsExceptionAsync(async () => await client.PostAsync(FakeDataService.JsonDataUrl, bodyData2)); } [TestMethod] @@ -402,7 +403,7 @@ public async Task TestCustomMatchRule() // record baseline request first var client = HttpClients.NewHttpClient(cassette, Mode.Record); - var _ = await client.PostAsync(FakeDataService.GetPreparedIPAddressDataUrl("json"), bodyData1); + var _ = await client.PostAsync(FakeDataService.JsonDataUrl, bodyData1); // try to replay the request with no custom match rule client = HttpClients.NewHttpClient(cassette, Mode.Replay, new AdvancedSettings() @@ -411,7 +412,7 @@ public async Task TestCustomMatchRule() }); // should pass since it passes the default match rules - await client.PostAsync(FakeDataService.GetPreparedIPAddressDataUrl("json"), bodyData1); + await client.PostAsync(FakeDataService.JsonDataUrl, bodyData1); // try to replay the request with a custom match rule client = HttpClients.NewHttpClient(cassette, Mode.Replay, new AdvancedSettings @@ -420,7 +421,7 @@ public async Task TestCustomMatchRule() }); // should fail since the custom match rule always returns false and there's never a match - await Assert.ThrowsExceptionAsync(async () => await client.PostAsync(FakeDataService.GetPreparedIPAddressDataUrl("json"), bodyData2)); + await Assert.ThrowsExceptionAsync(async () => await client.PostAsync(FakeDataService.JsonDataUrl, bodyData2)); } [TestMethod] @@ -434,12 +435,12 @@ public async Task TestIgnoreElementsPassMatch() // record baseline request first var client = HttpClients.NewHttpClient(cassette, Mode.Record); - var _ = await client.PostAsync(FakeDataService.GetPreparedIPAddressDataUrl("json"), bodyData1); + var _ = await client.PostAsync(FakeDataService.JsonDataUrl, bodyData1); // try to replay the request with different body data, but ignoring the differences var ignoreElements = new List { - new CensorElement("name", false) + new KeyCensorElement("name", false) }; client = HttpClients.NewHttpClient(cassette, Mode.Replay, new AdvancedSettings { @@ -447,7 +448,7 @@ public async Task TestIgnoreElementsPassMatch() }); // should succeed since we're ignoring the differences - var response = await client.PostAsync(FakeDataService.GetPreparedIPAddressDataUrl("json"), bodyData2); + var response = await client.PostAsync(FakeDataService.JsonDataUrl, bodyData2); Assert.IsNotNull(response); Assert.IsTrue(Utilities.ResponseCameFromRecording(response)); } @@ -485,11 +486,11 @@ public async Task TestInteractionElements() cassette.Erase(); // Erase cassette before recording var client = HttpClients.NewHttpClient(cassette, Mode.Record); - var fakeDataService = new FakeJsonDataService(client); + var fakeDataService = new FakeDataService(client); // Most elements of a VCR request are black-boxed, so we can't test them here. // Instead, we can get the recreated HttpResponseMessage and check the details. - var response = await fakeDataService.GetIPAddressDataRawResponse(); + var response = await fakeDataService.GetJsonDataRawResponse(); Assert.IsNotNull(response); } @@ -501,14 +502,14 @@ public async Task TestMatchSettings() // record cassette first var client = HttpClients.NewHttpClient(cassette, Mode.Record); - var fakeDataService = new FakeJsonDataService(client); - var _ = await fakeDataService.GetIPAddressDataRawResponse(); + var fakeDataService = new FakeDataService(client); + var _ = await fakeDataService.GetJsonDataRawResponse(); // replay cassette with default match rules, should find a match client = HttpClients.NewHttpClient(cassette, Mode.Replay); client.DefaultRequestHeaders.Add("X-Custom-Header", "custom-value"); // add custom header to request, shouldn't matter when matching by default rules - fakeDataService = new FakeJsonDataService(client); - var response = await fakeDataService.GetIPAddressDataRawResponse(); + fakeDataService = new FakeDataService(client); + var response = await fakeDataService.GetJsonDataRawResponse(); Assert.IsNotNull(response); // replay cassette with custom match rules, should not find a match because request is different (throw exception) @@ -518,8 +519,8 @@ public async Task TestMatchSettings() }; client = HttpClients.NewHttpClient(cassette, Mode.Replay, advancedSettings); client.DefaultRequestHeaders.Add("X-Custom-Header", "custom-value"); // add custom header to request, causing a match failure when matching by everything - fakeDataService = new FakeJsonDataService(client); - await Assert.ThrowsExceptionAsync(async () => await fakeDataService.GetIPAddressDataRawResponse()); + fakeDataService = new FakeDataService(client); + await Assert.ThrowsExceptionAsync(async () => await fakeDataService.GetJsonDataRawResponse()); } [TestMethod] @@ -531,14 +532,14 @@ public async Task TestHeadersFailMatch() // record cassette first var client = HttpClients.NewHttpClient(cassette, Mode.Record); client.DefaultRequestHeaders.Add("X-Custom-Header", "custom-value1"); - var fakeDataService = new FakeJsonDataService(client); - var _ = await fakeDataService.GetIPAddressDataRawResponse(); + var fakeDataService = new FakeDataService(client); + var _ = await fakeDataService.GetJsonDataRawResponse(); // replay cassette with default match rules, should find a match client = HttpClients.NewHttpClient(cassette, Mode.Replay); // no custom header to request, shouldn't matter when matching by default rules - fakeDataService = new FakeJsonDataService(client); - var response = await fakeDataService.GetIPAddressDataRawResponse(); + fakeDataService = new FakeDataService(client); + var response = await fakeDataService.GetJsonDataRawResponse(); Assert.IsNotNull(response); // replay cassette with custom match rules, should not find a match because header value is different (throw exception) @@ -548,8 +549,8 @@ public async Task TestHeadersFailMatch() }; client = HttpClients.NewHttpClient(cassette, Mode.Replay, advancedSettings); client.DefaultRequestHeaders.Add("X-Custom-Header", "custom-value2"); // add header with different value to request - fakeDataService = new FakeJsonDataService(client); - await Assert.ThrowsExceptionAsync(async () => await fakeDataService.GetIPAddressDataRawResponse()); + fakeDataService = new FakeDataService(client); + await Assert.ThrowsExceptionAsync(async () => await fakeDataService.GetJsonDataRawResponse()); } [TestMethod] @@ -561,8 +562,8 @@ public async Task TestMissingHeaderFailMatch() // record cassette first var client = HttpClients.NewHttpClient(cassette, Mode.Record); client.DefaultRequestHeaders.Add("X-Custom-Header", "custom-value"); - var fakeDataService = new FakeJsonDataService(client); - var _ = await fakeDataService.GetIPAddressDataRawResponse(); + var fakeDataService = new FakeDataService(client); + var _ = await fakeDataService.GetJsonDataRawResponse(); // replay cassette with custom match rules, should not find a match because header is missing (throw exception) var advancedSettings = new AdvancedSettings @@ -570,8 +571,8 @@ public async Task TestMissingHeaderFailMatch() MatchRules = new MatchRules().ByHeader("X-Custom-Header") }; client = HttpClients.NewHttpClient(cassette, Mode.Replay, advancedSettings); - fakeDataService = new FakeJsonDataService(client); - await Assert.ThrowsExceptionAsync(async () => await fakeDataService.GetIPAddressDataRawResponse()); + fakeDataService = new FakeDataService(client); + await Assert.ThrowsExceptionAsync(async () => await fakeDataService.GetJsonDataRawResponse()); } [TestMethod] @@ -584,8 +585,8 @@ public async Task TestHeadersPassMatch() var client = HttpClients.NewHttpClient(cassette, Mode.Record); client.DefaultRequestHeaders.Add("X-Custom-Header1", "custom-value1"); // add custom header to request client.DefaultRequestHeaders.Add("X-Custom-Header2", "custom-value2"); - var fakeDataService = new FakeJsonDataService(client); - var _ = await fakeDataService.GetIPAddressDataRawResponse(); + var fakeDataService = new FakeDataService(client); + var _ = await fakeDataService.GetJsonDataRawResponse(); // replay cassette with custom match rules var advancedSettings = new AdvancedSettings @@ -594,8 +595,8 @@ public async Task TestHeadersPassMatch() }; client = HttpClients.NewHttpClient(cassette, Mode.Replay, advancedSettings); client.DefaultRequestHeaders.Add("X-Custom-Header1", "custom-value1"); - fakeDataService = new FakeJsonDataService(client); - var response = await fakeDataService.GetIPAddressDataRawResponse(); + fakeDataService = new FakeDataService(client); + var response = await fakeDataService.GetJsonDataRawResponse(); // should succeed since X-Custom-Header1 header value equals Assert.IsNotNull(response); @@ -612,7 +613,14 @@ public async Task TestNestedCensoring() // set up advanced settings const string censorString = "censored-by-test"; var censors = new Censors(censorString); - censors.CensorBodyElementsByKeys(new List { "nested_dict_1_1_1", "nested_dict_2_2", "nested_array", "null_key" }); + var bodyCensors = new List + { + new KeyCensorElement("nested_dict_1_1_1", false), + new KeyCensorElement("nested_dict_2_2", false), + new KeyCensorElement("nested_array", false), + new KeyCensorElement("null_key", false), + }; + censors.CensorBodyElements(bodyCensors); var advancedSettings = new AdvancedSettings { Censors = censors @@ -658,44 +666,16 @@ public async Task TestStrictRequestMatching() Assert.IsTrue(Utilities.ResponseCameFromRecording(response)); } - [Ignore] - [TestMethod] - public async Task TestXmlCensoring() - { - var cassette = TestUtils.GetCassette("test_xml_censors"); - cassette.Erase(); // Erase cassette before recording - - // set up advanced settings - const string censorString = "censored-by-test"; - var advancedSettings = new AdvancedSettings - { - Censors = new Censors(censorString).CensorBodyElementsByKeys(new List { "Table" }) - }; - - // record cassette with advanced settings first - var client = HttpClients.NewHttpClient(cassette, Mode.Record, advancedSettings); - var fakeDataService = new FakeXmlDataService(client); - var _ = await fakeDataService.GetIPAddressDataRawResponse(); - - // now replay cassette - client = HttpClients.NewHttpClient(cassette, Mode.Replay, advancedSettings); - fakeDataService = new FakeXmlDataService(client); - _ = await fakeDataService.GetIPAddressDataRawResponse(); - - // TODO: Test is failing because the response is not being censored. - // have to manually check cassette for the censored string in the response body - } - - private static async Task GetIPAddressDataRequest(Cassette cassette, Mode mode) + private static async Task GetJsonDataRequest(Cassette cassette, Mode mode) { var client = HttpClients.NewHttpClient(cassette, mode, new AdvancedSettings { - MatchRules = MatchRules.DefaultStrict + MatchRules = MatchRules.DefaultStrict, }); - var fakeDataService = new FakeJsonDataService(client); + var fakeDataService = new FakeDataService(client); - return await fakeDataService.GetIPAddressData(); + return await fakeDataService.GetJsonData(); } } } diff --git a/EasyVCR.Tests/EasyVCR.Tests.csproj b/EasyVCR.Tests/EasyVCR.Tests.csproj index 3f5edb0..5ae0560 100644 --- a/EasyVCR.Tests/EasyVCR.Tests.csproj +++ b/EasyVCR.Tests/EasyVCR.Tests.csproj @@ -3,7 +3,7 @@ net462;netstandard2.0;netcoreapp3.1;net5.0;net6.0;net7.0 - 8 + 9 Library diff --git a/EasyVCR.Tests/FakeDataService.cs b/EasyVCR.Tests/FakeDataService.cs index 0248265..1a31df6 100644 --- a/EasyVCR.Tests/FakeDataService.cs +++ b/EasyVCR.Tests/FakeDataService.cs @@ -17,10 +17,9 @@ public class IPAddressData #endregion } - public abstract class FakeDataService + public class FakeDataService { private readonly EasyVCRHttpClient? _client; - private readonly string? _format; private readonly VCR? _vcr; public EasyVCRHttpClient Client @@ -35,39 +34,69 @@ public EasyVCRHttpClient Client } } - protected FakeDataService(string format, VCR vcr) + public FakeDataService(VCR vcr) { - _format = format; _vcr = vcr; } - protected FakeDataService(string format, EasyVCRHttpClient client) + public FakeDataService(EasyVCRHttpClient client) { - _format = format; _client = client; } - public async Task GetIPAddressData() + public static string JsonDataUrl => "https://www.reddit.com/r/ProgrammerHumor.json"; + + public static string XmlDataUrl => "https://www.reddit.com/r/ProgrammerHumor.rss"; + + public static string HtmlDataUrl => "https://www.reddit.com/r/ProgrammerHumor"; + + public static string RawDataUrl => "https://raw.githubusercontent.com/nwithan8/UGAArchive/main/README.md"; + + public async Task GetJsonDataRawResponse() { - var response = await GetIPAddressDataRawResponse(); - return Convert(await response.Content.ReadAsStringAsync()); + Client.DefaultRequestHeaders.Add("User-Agent", "EasyVCR"); // reddit requires a user agent + return await Client.GetAsync(JsonDataUrl); } - public async Task GetIPAddressDataRawResponse() + public async Task GetJsonData() { - return await Client.GetAsync(GetPreparedIPAddressDataUrl(_format)); + var response = await GetJsonDataRawResponse(); + return await response.Content.ReadAsStringAsync(); } - protected abstract IPAddressData Convert(string responseBody); + public async Task GetXmlDataRawResponse() + { + Client.DefaultRequestHeaders.Add("User-Agent", "EasyVCR"); // reddit requires a user agent + return await Client.GetAsync(XmlDataUrl); + } + + public async Task GetXmlData() + { + var response = await GetXmlDataRawResponse(); + return await response.Content.ReadAsStringAsync(); + } + + public async Task GetHtmlDataRawResponse() + { + Client.DefaultRequestHeaders.Add("User-Agent", "EasyVCR"); // reddit requires a user agent + return await Client.GetAsync(HtmlDataUrl); + } + + public async Task GetHtmlData() + { + var response = await GetHtmlDataRawResponse(); + return await response.Content.ReadAsStringAsync(); + } - public static string GetPreparedIPAddressDataUrl(string? format) + public async Task GetRawDataRawResponse() { - return $"{GetIPAddressDataUrl()}?format={format}"; + return await Client.GetAsync(RawDataUrl); } - public static string GetIPAddressDataUrl() + public async Task GetRawData() { - return "https://api.ipify.org/"; + var response = await GetRawDataRawResponse(); + return await response.Content.ReadAsStringAsync(); } } } diff --git a/EasyVCR.Tests/FakeJsonDataServiceImpl.cs b/EasyVCR.Tests/FakeJsonDataServiceImpl.cs deleted file mode 100644 index b8886e2..0000000 --- a/EasyVCR.Tests/FakeJsonDataServiceImpl.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System.Net.Http; - -// ReSharper disable UnusedMember.Global - -namespace EasyVCR.Tests -{ - public class FakeJsonDataService : FakeDataService - { - public FakeJsonDataService(VCR vcr) : base("json", vcr) - { - } - - public FakeJsonDataService(EasyVCRHttpClient client) : base("json", client) - { - } - - protected override IPAddressData Convert(string responseBody) - { - return InternalUtilities.JSON.Serialization.ConvertJsonToObject(responseBody); - } - } - - public class FakeXmlDataService : FakeDataService - { - public FakeXmlDataService(VCR vcr) : base("xml", vcr) - { - } - - public FakeXmlDataService(EasyVCRHttpClient client) : base("xml", client) - { - } - - protected override IPAddressData Convert(string responseBody) - { - return InternalUtilities.XML.Serialization.ConvertXmlToObject(responseBody); - } - } -} diff --git a/EasyVCR.Tests/Sample.cs b/EasyVCR.Tests/Sample.cs index 6340db4..d07c367 100644 --- a/EasyVCR.Tests/Sample.cs +++ b/EasyVCR.Tests/Sample.cs @@ -43,14 +43,22 @@ public async Task AdvancedVCRExample() { var bodyElementsToIgnoreDuringMatch = new List { - new CensorElement("name", true), - new CensorElement("phone", false) + new KeyCensorElement("name", true), + new KeyCensorElement("phone", false), + }; + var headerCensors = new List + { + new("X-My-Header", true), + }; + var queryParameterCensors = new List + { + new("api_key", false), }; var advancedSettings = new AdvancedSettings { MatchRules = new MatchRules().ByBody(bodyElementsToIgnoreDuringMatch).ByHeader("X-My-Header"), // Match recorded requests by body and a specific header - Censors = new Censors("redacted").CensorHeadersByKeys(new List { "Header-To-Hide" }).CensorQueryParametersByKeys(new List { "api_key" }), // Redact a specific header and query parameter - ManualDelay = 1000 // Simulate a delay of 1 second + Censors = new Censors("redacted").CensorHeaders(headerCensors).CensorQueryParameters(queryParameterCensors), // Redact a specific header and query parameter + ManualDelay = 1000, // Simulate a delay of 1 second }; var order = new CassetteOrder.None(); // elements of each request in a cassette will not be ordered any particular way var vcr = new VCR(advancedSettings); diff --git a/EasyVCR.Tests/VCRTest.cs b/EasyVCR.Tests/VCRTest.cs index 4b00f11..db7df96 100644 --- a/EasyVCR.Tests/VCRTest.cs +++ b/EasyVCR.Tests/VCRTest.cs @@ -18,9 +18,13 @@ public async Task TestAdvancedSettings() // refer to ClientTest.cs for individual per-settings tests const string censorString = "censored-by-test"; + var headerCensors = new List + { + new("Date", true), + }; var advancedSettings = new AdvancedSettings { - Censors = new Censors(censorString).CensorHeadersByKeys(new List { "Date" }) + Censors = new Censors(censorString).CensorHeaders(headerCensors), }; var vcr = new VCR(advancedSettings); @@ -36,16 +40,16 @@ public async Task TestAdvancedSettings() // record first vcr.Record(); var client = vcr.Client; - var fakeDataService = new FakeJsonDataService(client); - var _ = await fakeDataService.GetIPAddressData(); + var fakeDataService = new FakeDataService(client); + var _ = await fakeDataService.GetJsonDataRawResponse(); // now replay and confirm that the censor is applied vcr.Replay(); // changing the VCR settings won't affect a client after it's been grabbed from the VCR // so, we need to re-grab the VCR client and re-create the FakeDataService client = vcr.Client; - fakeDataService = new FakeJsonDataService(client); - var response = await fakeDataService.GetIPAddressDataRawResponse(); + fakeDataService = new FakeDataService(client); + var response = await fakeDataService.GetJsonDataRawResponse(); Assert.IsNotNull(response); Assert.IsTrue(response.Headers.Contains("Date")); var censoredHeader = response.Headers.GetValues("Date").FirstOrDefault(); @@ -105,7 +109,7 @@ public void TestClientHandOff() vcr.Insert(cassette); // test that we can still control the VCR even after it's been handed off to the service using it - var fakeDataService = new FakeJsonDataService(vcr); + var fakeDataService = new FakeDataService(vcr); // Client should come from VCR, which has a client because it has a cassette. Assert.IsNotNull(fakeDataService.Client); @@ -142,9 +146,9 @@ public async Task TestErase() vcr.Insert(cassette); // record a request to a cassette - var fakeDataService = new FakeJsonDataService(vcr); - var summary = await fakeDataService.GetIPAddressData(); - Assert.IsNotNull(summary); + var fakeDataService = new FakeDataService(vcr); + var responseBody = await fakeDataService.GetJsonData(); + Assert.IsNotNull(responseBody); Assert.IsTrue(cassette.NumInteractions > 0); // erase the cassette @@ -183,11 +187,10 @@ public async Task TestRecord() var cassette = TestUtils.GetCassette("test_vcr_record"); var vcr = TestUtils.GetSimpleVCR(Mode.Record); vcr.Insert(cassette); - var fakeDataService = new FakeJsonDataService(vcr); + var fakeDataService = new FakeDataService(vcr); - var summary = await fakeDataService.GetIPAddressData(); - Assert.IsNotNull(summary); - Assert.IsNotNull(summary.IPAddress); + var responseBody = await fakeDataService.GetJsonData(); + Assert.IsNotNull(responseBody); Assert.IsTrue(cassette.NumInteractions > 0); } @@ -197,21 +200,21 @@ public async Task TestReplay() var cassette = TestUtils.GetCassette("test_vcr_replay"); var vcr = TestUtils.GetSimpleVCR(Mode.Record); vcr.Insert(cassette); - var fakeDataService = new FakeJsonDataService(vcr); + var fakeDataService = new FakeDataService(vcr); // record first - var _ = await fakeDataService.GetIPAddressData(); + var _ = await fakeDataService.GetJsonDataRawResponse(); Assert.IsTrue(cassette.NumInteractions > 0); // make sure we recorded something // now replay vcr.Replay(); - var summary = await fakeDataService.GetIPAddressData(); - Assert.IsNotNull(summary); + var responseBody = await fakeDataService.GetJsonData(); + Assert.IsNotNull(responseBody); // double check by erasing the cassette and trying to replay vcr.Erase(); // should throw an exception because there's no matching interaction now - await Assert.ThrowsExceptionAsync(async () => await fakeDataService.GetIPAddressData()); + await Assert.ThrowsExceptionAsync(async () => await fakeDataService.GetJsonDataRawResponse()); } [TestMethod] @@ -220,11 +223,10 @@ public async Task TestRequest() var cassette = TestUtils.GetCassette("test_vcr_record"); var vcr = TestUtils.GetSimpleVCR(Mode.Bypass); vcr.Insert(cassette); - var fakeDataService = new FakeJsonDataService(vcr); + var fakeDataService = new FakeDataService(vcr); - var summary = await fakeDataService.GetIPAddressData(); - Assert.IsNotNull(summary); - Assert.IsNotNull(summary.IPAddress); + var responseBody = await fakeDataService.GetJsonData(); + Assert.IsNotNull(responseBody); } } } diff --git a/EasyVCR/CensorElement.cs b/EasyVCR/CensorElement.cs index fdc0bc1..b619147 100644 --- a/EasyVCR/CensorElement.cs +++ b/EasyVCR/CensorElement.cs @@ -3,23 +3,27 @@ namespace EasyVCR { - public class CensorElement + /// + /// The base class for censor elements. + /// + public abstract class CensorElement { /// /// Whether the name is case-sensitive. /// protected bool CaseSensitive { get; } + /// - /// Name of the element to censor. + /// Value to look for. /// protected string Value { get; } /// /// Constructor for a new censor element. /// - /// Name of the element to censor. - /// Whether the name is case-sensitive. - public CensorElement(string value, bool caseSensitive) + /// Value to censor. + /// Whether the value is case-sensitive. + protected CensorElement(string value, bool caseSensitive) { Value = value; CaseSensitive = caseSensitive; @@ -28,14 +32,81 @@ public CensorElement(string value, bool caseSensitive) /// /// Checks whether the provided element matches this censor element, accounting for case sensitivity. /// - /// The name to check. + /// The value to check. + /// The key to check. + /// True if the element matches, false otherwise. + internal abstract bool Matches(string value, string? key = null); + } + + /// + /// A censor element, used to define a raw value that should be censored. + /// + public class TextCensorElement : CensorElement + { + /// + /// Constructor for a new text censor element. + /// + /// The raw text value of the element to censor. + /// Whether the value is case-sensitive. + public TextCensorElement(string value, bool caseSensitive) : base(value, caseSensitive) + { + } + + /// + /// Checks whether the provided element matches this censor element, accounting for case sensitivity. + /// + /// The value to check. + /// The key to check. + /// True if the element matches, false otherwise. + internal override bool Matches(string value, string? key = null) + { + // we only care about the value here + return CaseSensitive ? Value.Equals(value) : Value.Equals(value, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Replace the provided value with the provided replacement if it matches this censor element. + /// Otherwise, return the value as-is. + /// + /// Value to replace. + /// Replacement for the value. + /// The value with the replacement inserted if it matches this censor element, otherwise the value as-is. + internal string MatchAndReplaceAsNeeded(string value, string replacement) + { + return Matches(value) ? replacement : value; + } + } + + /// + /// A censor element, used to define a key-value pair that should be censored. + /// + public class KeyCensorElement : CensorElement + { + /// + /// Constructor for a new key censor element. + /// + /// Key of which to censor the corresponding value. + /// Whether the key is case-sensitive. + public KeyCensorElement(string key, bool caseSensitive) : base(key, caseSensitive) + { + } + + /// + /// Checks whether the provided element matches this censor element, accounting for case sensitivity. + /// + /// The value to check. + /// The key to check. /// True if the element matches, false otherwise. - internal virtual bool Matches(string key) + internal override bool Matches(string value, string? key = null) { + // we only care about the key here return CaseSensitive ? Value.Equals(key) : Value.Equals(key, StringComparison.OrdinalIgnoreCase); } } + /// + /// A censor element, used to define a regex pattern that should be censored. + /// public class RegexCensorElement : CensorElement { /// @@ -75,9 +146,11 @@ internal string MatchAndReplaceAsNeeded(string value, string replacement) /// Checks whether the provided element matches this censor element, accounting for case sensitivity. /// /// The value to check. + /// The key to check. /// True if the element matches, false otherwise. - internal override bool Matches(string value) + internal override bool Matches(string value, string? key = null) { + // we only care about the value here var options = RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture | RegexOptions.Singleline; if (!CaseSensitive) { diff --git a/EasyVCR/Censors.cs b/EasyVCR/Censors.cs index c7658ea..6af0992 100644 --- a/EasyVCR/Censors.cs +++ b/EasyVCR/Censors.cs @@ -36,9 +36,15 @@ public static Censors DefaultSensitive get { var censors = new Censors(); - censors.CensorHeadersByKeys(Defaults.CredentialHeadersToHide); - censors.CensorQueryParametersByKeys(Defaults.CredentialParametersToHide); - censors.CensorBodyElementsByKeys(Defaults.CredentialParametersToHide); + + var headerCensors = Defaults.CredentialHeadersToHide.Select(header => new KeyCensorElement(header, false)).ToList(); + censors.CensorHeaders(headerCensors); + + var queryParamCensors = Defaults.CredentialParametersToHide.Select(queryParam => new KeyCensorElement(queryParam, false)).ToList(); + censors.CensorQueryParameters(queryParamCensors); + + var bodyElementCensors = Defaults.CredentialParametersToHide.Select(bodyElement => new KeyCensorElement(bodyElement, false)).ToList(); + censors.CensorBodyElements(bodyElementCensors); return censors; } @@ -60,7 +66,13 @@ public Censors(string? censorString = null) /// /// Add a rule to censor specified body elements. /// - /// List of body elements to censor. + /// + /// List of body elements to censor.
+ /// JSON: Use or
. Any will be ignored.
+ /// XML: Use or
. Any will be ignored.
+ /// HTML: Use or
. Any will be ignored.
+ /// Plain text: Use or . Any will be ignored.
+ /// /// The current Censor object. public Censors CensorBodyElements(IEnumerable elements) { @@ -68,78 +80,29 @@ public Censors CensorBodyElements(IEnumerable elements) return this; } - /// - /// Add a rule to censor specified body elements by their keys. - /// - /// List of keys of body elements to censor. - /// Whether to match case sensitively. - /// - public Censors CensorBodyElementsByKeys(List elementKeys, bool caseSensitive = false) - { - foreach (var key in elementKeys) - { - _bodyElementsToCensor.Add(new CensorElement(key, caseSensitive)); - } - - return this; - } - /// /// Add a rule to censor specified headers. /// Note: This will censor the header keys in both the request and response. /// /// List of headers to censor. /// The current Censor object. - public Censors CensorHeaders(IEnumerable headers) + public Censors CensorHeaders(IEnumerable headers) { _headersToCensor.AddRange(headers); return this; } - /// - /// Add a rule to censor specified headers by their keys. - /// Note: This will censor the header keys in both the request and response. - /// - /// List of keys of header to censor. - /// Whether to match case sensitively. - /// The current Censor object. - public Censors CensorHeadersByKeys(List headerKeys, bool caseSensitive = false) - { - foreach (var key in headerKeys) - { - _headersToCensor.Add(new CensorElement(key, caseSensitive)); - } - - return this; - } - /// /// Add a rule to censor specified query parameters. /// /// List of query parameters to censor. /// The current Censor object. - public Censors CensorQueryParameters(IEnumerable elements) + public Censors CensorQueryParameters(IEnumerable elements) { _queryParamsToCensor.AddRange(elements); return this; } - /// - /// Add a rule to censor specified query parameters by their keys. - /// - /// List of keys of query parameters to censor. - /// Whether to match case sensitively. - /// The current Censor object. - public Censors CensorQueryParametersByKeys(List parameterKeys, bool caseSensitive = false) - { - foreach (var key in parameterKeys) - { - _queryParamsToCensor.Add(new CensorElement(key, caseSensitive)); - } - - return this; - } - /// /// Add a rule to censor specified path elements. /// @@ -151,22 +114,6 @@ public Censors CensorPathElements(IEnumerable elements) return this; } - /// - /// Add a rule to censor specified path elements by their patterns. - /// - /// List of patterns of path elements to censor. - /// Whether to match case sensitively. - /// The current Censor object. - public Censors CensorPathElementsByPatterns(List patterns, bool caseSensitive = false) - { - foreach (var pattern in patterns) - { - _pathElementsToCensor.Add(new RegexCensorElement(pattern, caseSensitive)); - } - - return this; - } - /// /// Censor the appropriate body parameters. /// @@ -196,9 +143,9 @@ internal string ApplyBodyParametersCensors(string body, ContentType? contentType { case ContentType.Text: case ContentType.Html: - return body; // We can't censor plaintext bodies or HTML bodies. + return CensorTextData(body, _censorText, _bodyElementsToCensor); case ContentType.Xml: - return body; // XML parsing is not supported yet, so we can't censor XML bodies. + return CensorXmlData(body, _censorText, _bodyElementsToCensor); case ContentType.Json: return CensorJsonData(body, _censorText, _bodyElementsToCensor); default: @@ -225,7 +172,7 @@ internal IDictionary ApplyHeaderCensors(IDictionary header.Key, header => ElementShouldBeCensored(header.Key, _headersToCensor) ? _censorText : header.Value); + return _headersToCensor.Count == 0 ? headers : headers.ToDictionary(header => header.Key, header => ElementShouldBeCensored(foundValue: header.Value, foundKey: header.Key, _headersToCensor) ? _censorText : header.Value); } /// @@ -293,6 +240,7 @@ internal IDictionary ApplyHeaderCensors(IDictionary ApplyHeaderCensors(IDictionary ApplyHeaderCensors(IDictionary + /// Apply censors to a raw text string. + /// + /// Raw text string to apply censors to. + /// Text to use to replace censored elements. + /// List of elements to censor. + /// A censored raw text string. + public static string CensorTextData(string data, string censorText, IReadOnlyCollection elementsToCensor) + { + if (elementsToCensor.Count == 0) + { + // short circuit if there are no censors to apply + return data; + } + + var censoredData = data; + foreach (var censorElement in elementsToCensor) + { + switch (censorElement) + { + // we cannot process KeyCensorElements in raw text/html + case KeyCensorElement _: + continue; + case RegexCensorElement regexCensorElement: + censoredData = regexCensorElement.MatchAndReplaceAsNeeded(censoredData, censorText); + break; + case TextCensorElement textCensorElement: + censoredData = textCensorElement.MatchAndReplaceAsNeeded(censoredData, censorText); + break; + } + } + + return censoredData; + } + /// /// Apply censors to a JSON string. /// /// JSON string to apply censors to. - /// Test to use to replace censored elements. + /// Text to use to replace censored elements. /// List of elements to censor. /// A censored JSON string. public static string CensorJsonData(string data, string censorText, IReadOnlyCollection elementsToCensors) @@ -368,7 +353,7 @@ public static string CensorJsonData(string data, string censorText, IReadOnlyCol try { var jsonDictionary = JsonSerialization.ConvertJsonToObject>(data); - var censoredJsonDictionary = ApplyDataCensors(jsonDictionary, censorText, elementsToCensors); + var censoredJsonDictionary = ApplyJsonXmlDataCensors(jsonDictionary, censorText, elementsToCensors); return JsonSerialization.ConvertObjectToJson(censoredJsonDictionary); } catch (Exception) @@ -377,7 +362,7 @@ public static string CensorJsonData(string data, string censorText, IReadOnlyCol try { var jsonList = JsonSerialization.ConvertJsonToObject>(data); - var censoredJsonList = ApplyDataCensors(jsonList, censorText, elementsToCensors); + var censoredJsonList = ApplyJsonXmlDataCensors(jsonList, censorText, elementsToCensors); return JsonSerialization.ConvertObjectToJson(censoredJsonList); } catch @@ -392,7 +377,7 @@ public static string CensorJsonData(string data, string censorText, IReadOnlyCol /// Apply censors to an XML string. /// /// XML string to apply censors to. - /// Test to use to replace censored elements. + /// Text to use to replace censored elements. /// List of elements to censor. /// A censored XML string. public static string CensorXmlData(string data, string censorText, IReadOnlyCollection elementsToCensors) @@ -400,7 +385,7 @@ public static string CensorXmlData(string data, string censorText, IReadOnlyColl try { var xmlDictionary = XmlSerialization.ConvertXmlToObject>(data); - var censoredXmlDictionary = ApplyDataCensors(xmlDictionary, censorText, elementsToCensors); + var censoredXmlDictionary = ApplyJsonXmlDataCensors(xmlDictionary, censorText, elementsToCensors); return XmlSerialization.ConvertObjectToXml(censoredXmlDictionary); } catch (Exception) @@ -409,7 +394,7 @@ public static string CensorXmlData(string data, string censorText, IReadOnlyColl try { var xmlList = XmlSerialization.ConvertXmlToObject>(data); - var censoredXmlList = ApplyDataCensors(xmlList, censorText, elementsToCensors); + var censoredXmlList = ApplyJsonXmlDataCensors(xmlList, censorText, elementsToCensors); return XmlSerialization.ConvertObjectToXml(censoredXmlList); } catch @@ -427,7 +412,7 @@ public static string CensorXmlData(string data, string censorText, IReadOnlyColl /// Test to use to replace censored elements. /// List of elements to censor. /// A censored list of elements. - private static List ApplyDataCensors(List list, string censorText, IReadOnlyCollection elementsToCensors) + private static List ApplyJsonXmlDataCensors(List list, string censorText, IReadOnlyCollection elementsToCensors) { if (list.Count == 0) // short circuit if there are no body parameters @@ -446,7 +431,7 @@ private static List ApplyDataCensors(List list, string censorTex } else { - var censoredEntryDict = ApplyDataCensors(entryDict, censorText, elementsToCensors); + var censoredEntryDict = ApplyJsonXmlDataCensors(entryDict, censorText, elementsToCensors); censoredList.Add(censoredEntryDict); } } @@ -460,7 +445,7 @@ private static List ApplyDataCensors(List list, string censorTex } else { - var censoredEntryList = ApplyDataCensors(entryList, censorText, elementsToCensors); + var censoredEntryList = ApplyJsonXmlDataCensors(entryList, censorText, elementsToCensors); censoredList.Add(censoredEntryList); } } @@ -481,18 +466,18 @@ private static List ApplyDataCensors(List list, string censorTex /// Test to use to replace censored elements. /// List of elements to censor. /// A censored dictionary of elements. - private static Dictionary ApplyDataCensors(Dictionary dictionary, string censorText, IReadOnlyCollection elementsToCensors) + private static Dictionary ApplyJsonXmlDataCensors(Dictionary dictionary, string censorText, IReadOnlyCollection elementsToCensors) { if (dictionary.Count == 0) - // short circuit if there are no body parameters + // short circuit if there is no data to censor return dictionary; var censoredBodyDictionary = new Dictionary(); - foreach (var key in dictionary.Keys) + foreach (var elem in dictionary) { - if (ElementShouldBeCensored(key, elementsToCensors)) + if (ElementShouldBeCensored(foundValue: elem.Value, foundKey: elem.Key, elementsToCensors)) { - var value = dictionary[key]; + var value = dictionary[elem.Key]; if (value == null) { // don't need to worry about censoring something that's null (don't replace null with the censor string) @@ -502,45 +487,45 @@ private static Dictionary ApplyDataCensors(Dictionary()); + censoredBodyDictionary.Add(elem.Key, new Dictionary()); } else if (Utilities.IsJsonArray(value)) { // replace with empty array - censoredBodyDictionary.Add(key, new List()); + censoredBodyDictionary.Add(elem.Key, new List()); } else { // replace with censor text - censoredBodyDictionary.Add(key, censorText); + censoredBodyDictionary.Add(elem.Key, censorText); } } else { - var value = dictionary[key]; + var value = dictionary[elem.Key]; if (Utilities.IsJsonDictionary(value)) { // recursively censor inner dictionaries - var valueDict = ((JObject)dictionary[key]).ToObject>(); + var valueDict = ((JObject)dictionary[elem.Key]).ToObject>(); if (valueDict != null) { // change the value if can be parsed as a dictionary (otherwise, skip censoring) - value = ApplyDataCensors(valueDict, censorText, elementsToCensors); + value = ApplyJsonXmlDataCensors(valueDict, censorText, elementsToCensors); } } else if (Utilities.IsJsonArray(value)) { // recursively censor list elements - var valueList = ((JArray)dictionary[key]).ToObject>(); + var valueList = ((JArray)dictionary[elem.Key]).ToObject>(); if (valueList != null) { - value = ApplyDataCensors(valueList, censorText, elementsToCensors); + value = ApplyJsonXmlDataCensors(valueList, censorText, elementsToCensors); } } - censoredBodyDictionary.Add(key, value); + censoredBodyDictionary.Add(elem.Key, value); } } @@ -550,12 +535,19 @@ private static Dictionary ApplyDataCensors(Dictionary /// Check if a JSON element should be censored. /// - /// The key of the JSON element to evaluate. + /// The value of the element to evaluate. + /// The key of the element to evaluate. /// A list of elements to censor. /// True if the JSON value should be censored, false otherwise. - private static bool ElementShouldBeCensored(string foundKey, IReadOnlyCollection elementsToCensor) + private static bool ElementShouldBeCensored(object? foundValue, string foundKey, IReadOnlyCollection elementsToCensor) { - return elementsToCensor.Count != 0 && elementsToCensor.Any(element => element.Matches(foundKey)); + if (!(foundValue is string)) + { + // short circuit if the value is not a string + return false; + } + + return elementsToCensor.Count != 0 && elementsToCensor.Any(element => element.Matches(value: (string)foundValue, key: foundKey)); } /// diff --git a/README.md b/README.md index d48b87d..cf3b633 100644 --- a/README.md +++ b/README.md @@ -80,9 +80,18 @@ using EasyVCR; var cassette = new Cassette("path/to/cassettes", "my_cassette"); -var censors = new Censors().CensorHeadersByKeys(new List { "Authorization" }) // Hide the Authorization header -censors.CensorBodyElementsByKeys(new List { new CensorElement("table", true) }); // Hide the table element (case sensitive) in the request and response body -censors.CensorPathElementsByPatterns(new List { ".*\\d{4}.*" }); // Hide any path element that contains 4 digits +var headerCensors = new List { + new("Authorization", false), // Hide the Authorization header +}; +var bodyCensors = new List { + new("table", true), // Hide the table element (case sensitive) in the request and response body +}; +var pathCensors = new List { + new(".*\\d{4}.*"), // Hide any path element that contains 4 digits +}; +var censors = new Censors().CensorHeaders(headerCensors) + .CensorBodyElements(bodyCensors) + .CensorPathElements(pathCensors); var advancedOptions = new AdvancedOptions() { @@ -216,9 +225,12 @@ request made using the VCR's HttpClient. ```csharp using EasyVCR; +var queryParameterCensors = new List { + new("api_key", true), // Hide the api_key query parameter +}; var advancedSettings = new AdvancedSettings { - Censors = new Censors().CensorQueryParametersByKeys(new List { "api_key" }) // hide the api_key query parameter + Censors = new Censors().CensorQueryParameters(queryParameterCensors) }; // Create a VCR with the advanced settings applied