From c597728040cc696b1e46c2d89dfdf079a6cbfcbe Mon Sep 17 00:00:00 2001 From: Scott Beddall <45376673+scbedd@users.noreply.github.com> Date: Sun, 25 Aug 2024 11:35:04 -0700 Subject: [PATCH] Compare json object values instead of byte streams when matching bodies that are content-type `json` (#8860) --- .../SanitizerTests.cs | 12 +- .../Common/JsonComparer.cs | 157 ++++++++++++++++++ .../Common/RecordMatcher.cs | 59 +++++-- .../Common/RecordSession.cs | 2 +- 4 files changed, 208 insertions(+), 22 deletions(-) create mode 100644 tools/test-proxy/Azure.Sdk.Tools.TestProxy/Common/JsonComparer.cs diff --git a/tools/test-proxy/Azure.Sdk.Tools.TestProxy.Tests/SanitizerTests.cs b/tools/test-proxy/Azure.Sdk.Tools.TestProxy.Tests/SanitizerTests.cs index 47b322f9c1d..e06fdaa01a8 100644 --- a/tools/test-proxy/Azure.Sdk.Tools.TestProxy.Tests/SanitizerTests.cs +++ b/tools/test-proxy/Azure.Sdk.Tools.TestProxy.Tests/SanitizerTests.cs @@ -8,7 +8,6 @@ using System.IO; using System.Linq; using System.Reflection; -using System.Runtime.CompilerServices; using System.Text; using System.Text.RegularExpressions; using System.Threading.Tasks; @@ -689,8 +688,10 @@ public async void GenStringSanitizerQuietExitForAllHttpComponents() Assert.Equal(0, matcher.CompareHeaderDictionaries(targetUntouchedEntry.Request.Headers, targetEntry.Request.Headers, new HashSet(), new HashSet())); Assert.Equal(0, matcher.CompareHeaderDictionaries(targetUntouchedEntry.Response.Headers, targetEntry.Response.Headers, new HashSet(), new HashSet())); - Assert.Equal(0, matcher.CompareBodies(targetUntouchedEntry.Request.Body, targetEntry.Request.Body)); - Assert.Equal(0, matcher.CompareBodies(targetUntouchedEntry.Response.Body, targetEntry.Response.Body)); + + targetUntouchedEntry.Request.TryGetContentType(out var contentType); + Assert.Equal(0, matcher.CompareBodies(targetUntouchedEntry.Request.Body, targetEntry.Request.Body, contentType)); + Assert.Equal(0, matcher.CompareBodies(targetUntouchedEntry.Response.Body, targetEntry.Response.Body, contentType)); Assert.Equal(targetUntouchedEntry.RequestUri, targetEntry.RequestUri); } @@ -769,8 +770,9 @@ public async void BodyStringSanitizerQuietlyExits(string targetValue, string rep await session.Session.Sanitize(sanitizer); var resultBodyValue = Encoding.UTF8.GetString(targetEntry.Request.Body); - Assert.Equal(0, matcher.CompareBodies(targetUntouchedEntry.Request.Body, targetEntry.Request.Body)); - Assert.Equal(0, matcher.CompareBodies(targetUntouchedEntry.Response.Body, targetEntry.Response.Body)); + targetUntouchedEntry.Request.TryGetContentType(out var contentType); + Assert.Equal(0, matcher.CompareBodies(targetUntouchedEntry.Request.Body, targetEntry.Request.Body, contentType)); + Assert.Equal(0, matcher.CompareBodies(targetUntouchedEntry.Response.Body, targetEntry.Response.Body, contentType)); } [Fact] diff --git a/tools/test-proxy/Azure.Sdk.Tools.TestProxy/Common/JsonComparer.cs b/tools/test-proxy/Azure.Sdk.Tools.TestProxy/Common/JsonComparer.cs new file mode 100644 index 00000000000..2e237ec83df --- /dev/null +++ b/tools/test-proxy/Azure.Sdk.Tools.TestProxy/Common/JsonComparer.cs @@ -0,0 +1,157 @@ +using System; +using System.Collections.Generic; +using System.Linq.Expressions; +using System.Text; +using System.Text.Json; + +namespace Azure.Sdk.Tools.TestProxy.Common +{ + public class JsonComparer + { + public static List CompareJson(byte[] json1, byte[] json2) + { + var differences = new List(); + JsonDocument doc1; + JsonDocument doc2; + + // Deserialize the byte arrays to JsonDocument + try + { + doc1 = JsonDocument.Parse(json1); + } + catch(Exception ex) + { + differences.Add($"Unable to parse the request json body. Content \"{Encoding.UTF8.GetString(json1)}.\" Exception: {ex.Message}"); + return differences; + } + + try + { + doc2 = JsonDocument.Parse(json2); + } + + catch (Exception ex) + { + differences.Add($"Unable to parse the record json body. Content \"{Encoding.UTF8.GetString(json2)}.\" Exception: {ex.Message}"); + return differences; + } + + CompareElements(doc1.RootElement, doc2.RootElement, differences, ""); + + return differences; + } + + private static void CompareElements(JsonElement element1, JsonElement element2, List differences, string path) + { + if (element1.ValueKind != element2.ValueKind) + { + differences.Add($"{path}: Request and record have different types."); + return; + } + + switch (element1.ValueKind) + { + case JsonValueKind.Object: + { + var properties1 = element1.EnumerateObject(); + var properties2 = element2.EnumerateObject(); + + var propDict1 = new Dictionary(); + var propDict2 = new Dictionary(); + + foreach (var prop in properties1) + propDict1[prop.Name] = prop.Value; + + foreach (var prop in properties2) + propDict2[prop.Name] = prop.Value; + + foreach (var key in propDict1.Keys) + { + if (propDict2.ContainsKey(key)) + { + CompareElements(propDict1[key], propDict2[key], differences, $"{path}.{key}"); + } + else + { + differences.Add($"{path}.{key}: Missing in request JSON"); + } + } + + foreach (var key in propDict2.Keys) + { + if (!propDict1.ContainsKey(key)) + { + differences.Add($"{path}.{key}: Missing in record JSON"); + } + } + + break; + } + case JsonValueKind.Array: + { + var array1 = element1.EnumerateArray(); + var array2 = element2.EnumerateArray(); + + int index = 0; + var enum1 = array1.GetEnumerator(); + var enum2 = array2.GetEnumerator(); + + while (enum1.MoveNext() && enum2.MoveNext()) + { + CompareElements(enum1.Current, enum2.Current, differences, $"{path}[{index}]"); + index++; + } + + while (enum1.MoveNext()) + { + differences.Add($"{path}[{index}]: Extra element in request JSON"); + index++; + } + + while (enum2.MoveNext()) + { + differences.Add($"{path}[{index}]: Extra element in record JSON"); + index++; + } + + break; + } + case JsonValueKind.String: + { + if (element1.GetString() != element2.GetString()) + { + differences.Add($"{path}: \"{element1.GetString()}\" != \"{element2.GetString()}\""); + } + break; + } + case JsonValueKind.Number: + { + if (element1.GetDecimal() != element2.GetDecimal()) + { + differences.Add($"{path}: {element1.GetDecimal()} != {element2.GetDecimal()}"); + } + break; + } + case JsonValueKind.True: + case JsonValueKind.False: + { + if (element1.GetBoolean() != element2.GetBoolean()) + { + differences.Add($"{path}: {element1.GetBoolean()} != {element2.GetBoolean()}"); + } + break; + } + case JsonValueKind.Null: + { + // Both are null, nothing to compare + break; + } + default: + { + differences.Add($"{path}: Unhandled value kind {element1.ValueKind}"); + break; + } + } + } + } +} diff --git a/tools/test-proxy/Azure.Sdk.Tools.TestProxy/Common/RecordMatcher.cs b/tools/test-proxy/Azure.Sdk.Tools.TestProxy/Common/RecordMatcher.cs index aa8bc59dbe0..bec41ef8e63 100644 --- a/tools/test-proxy/Azure.Sdk.Tools.TestProxy/Common/RecordMatcher.cs +++ b/tools/test-proxy/Azure.Sdk.Tools.TestProxy/Common/RecordMatcher.cs @@ -112,7 +112,10 @@ public virtual RecordEntry FindMatch(RecordEntry request, IList ent if (!entry.IsTrack1Recording) { score += CompareHeaderDictionaries(request.Request.Headers, entry.Request.Headers, IgnoredHeaders, ExcludeHeaders); - score += CompareBodies(request.Request.Body, entry.Request.Body); + + request.Request.TryGetContentType(out var contentType); + + score += CompareBodies(request.Request.Body, entry.Request.Body, descriptionBuilder: null, contentType: contentType); } if (score == 0) @@ -130,7 +133,7 @@ public virtual RecordEntry FindMatch(RecordEntry request, IList ent throw new TestRecordingMismatchException(GenerateException(request, bestScoreEntry, entries)); } - public virtual int CompareBodies(byte[] requestBody, byte[] recordBody, StringBuilder descriptionBuilder = null) + public virtual int CompareBodies(byte[] requestBody, byte[] recordBody, string contentType, StringBuilder descriptionBuilder = null) { if (!_compareBodies) { @@ -154,27 +157,50 @@ public virtual int CompareBodies(byte[] requestBody, byte[] recordBody, StringBu return 1; } + if (!requestBody.SequenceEqual(recordBody)) { - if (descriptionBuilder != null) + // we just failed sequence equality, before erroring, lets check if we're a json body and check for property equality + if (!string.IsNullOrWhiteSpace(contentType) && contentType.Contains("json")) { - var minLength = Math.Min(requestBody.Length, recordBody.Length); - int i; - for (i = 0; i < minLength - 1; i++) + var jsonDifferences = JsonComparer.CompareJson(requestBody, recordBody); + + if (jsonDifferences.Count > 0) { - if (requestBody[i] != recordBody[i]) + + if (descriptionBuilder != null) { - break; + descriptionBuilder.AppendLine($"There are differences between request and recordentry bodies:"); + foreach (var jsonDifference in jsonDifferences) + { + descriptionBuilder.AppendLine(jsonDifference); + } } + + return 1; } - descriptionBuilder.AppendLine($"Request and record bodies do not match at index {i}:"); - var before = Math.Max(0, i - 10); - var afterRequest = Math.Min(i + 20, requestBody.Length); - var afterResponse = Math.Min(i + 20, recordBody.Length); - descriptionBuilder.AppendLine($" request: \"{Encoding.UTF8.GetString(requestBody, before, afterRequest - before)}\""); - descriptionBuilder.AppendLine($" record: \"{Encoding.UTF8.GetString(recordBody, before, afterResponse - before)}\""); + } + else { + if (descriptionBuilder != null) + { + var minLength = Math.Min(requestBody.Length, recordBody.Length); + int i; + for (i = 0; i < minLength - 1; i++) + { + if (requestBody[i] != recordBody[i]) + { + break; + } + } + descriptionBuilder.AppendLine($"Request and record bodies do not match at index {i}:"); + var before = Math.Max(0, i - 10); + var afterRequest = Math.Min(i + 20, requestBody.Length); + var afterResponse = Math.Min(i + 20, recordBody.Length); + descriptionBuilder.AppendLine($" request: \"{Encoding.UTF8.GetString(requestBody, before, afterRequest - before)}\""); + descriptionBuilder.AppendLine($" record: \"{Encoding.UTF8.GetString(recordBody, before, afterResponse - before)}\""); + } + return 1; } - return 1; } return 0; @@ -250,7 +276,8 @@ private string GenerateException(RecordEntry request, RecordEntry bestScoreEntry builder.AppendLine("Body differences:"); - CompareBodies(request.Request.Body, bestScoreEntry.Request.Body, builder); + request.Request.TryGetContentType(out var contentType); + CompareBodies(request.Request.Body, bestScoreEntry.Request.Body, contentType, descriptionBuilder: builder); return builder.ToString(); } diff --git a/tools/test-proxy/Azure.Sdk.Tools.TestProxy/Common/RecordSession.cs b/tools/test-proxy/Azure.Sdk.Tools.TestProxy/Common/RecordSession.cs index a104e4083ce..ba51b7d355d 100644 --- a/tools/test-proxy/Azure.Sdk.Tools.TestProxy/Common/RecordSession.cs +++ b/tools/test-proxy/Azure.Sdk.Tools.TestProxy/Common/RecordSession.cs @@ -102,10 +102,10 @@ public RecordEntry Lookup(RecordEntry requestEntry, RecordMatcher matcher, IEnum { sanitizer.Sanitize(requestEntry); } + // normalize request body with STJ using relaxed escaping to match behavior when Deserializing from session files RecordEntry.NormalizeJsonBody(requestEntry.Request); - RecordEntry entry = matcher.FindMatch(requestEntry, Entries); if (remove) {