diff --git a/Microsoft.Azure.Cosmos.Encryption/src/EncryptionProcessor.cs b/Microsoft.Azure.Cosmos.Encryption/src/EncryptionProcessor.cs index af42fe338b..74aea001b4 100644 --- a/Microsoft.Azure.Cosmos.Encryption/src/EncryptionProcessor.cs +++ b/Microsoft.Azure.Cosmos.Encryption/src/EncryptionProcessor.cs @@ -23,7 +23,7 @@ internal static class EncryptionProcessor // UTF-8 Encoding private static readonly SqlVarCharSerializer SqlVarcharSerializer = new SqlVarCharSerializer(size: -1, codePageCharacterEncoding: 65001); - private enum TypeMarker : byte + internal enum TypeMarker : byte { Null = 1, // not used Boolean = 2, @@ -222,7 +222,7 @@ await EncryptJTokenAsync( return EncryptionProcessor.BaseSerializer.ToStream(encryptedPropertyValue); } - private static (TypeMarker, byte[]) Serialize(JToken propertyValue) + internal static (TypeMarker, byte[]) Serialize(JToken propertyValue) { return propertyValue.Type switch { @@ -234,7 +234,7 @@ private static (TypeMarker, byte[]) Serialize(JToken propertyValue) }; } - private static JToken DeserializeAndAddProperty( + internal static JToken DeserializeAndAddProperty( byte[] serializedBytes, TypeMarker typeMarker) { @@ -248,11 +248,11 @@ private static JToken DeserializeAndAddProperty( }; } - private static async Task EncryptJTokenAsync( - JToken jTokenToEncrypt, - EncryptionSettingForProperty encryptionSettingForProperty, - bool shouldEscape, - CancellationToken cancellationToken) + internal static async Task EncryptJTokenAsync( + JToken jTokenToEncrypt, + EncryptionSettingForProperty encryptionSettingForProperty, + bool shouldEscape, + CancellationToken cancellationToken) { // Top Level can be an Object if (jTokenToEncrypt.Type == JTokenType.Object) @@ -292,6 +292,63 @@ await EncryptJTokenAsync( return; } + internal static async Task DecryptJTokenAsync( + JToken jTokenToDecrypt, + EncryptionSettingForProperty encryptionSettingForProperty, + bool isEscaped, + CancellationToken cancellationToken) + { + if (jTokenToDecrypt.Type == JTokenType.Object) + { + foreach (JProperty jProperty in jTokenToDecrypt.Children()) + { + await DecryptJTokenAsync( + jProperty.Value, + encryptionSettingForProperty, + isEscaped, + cancellationToken); + } + } + else if (jTokenToDecrypt.Type == JTokenType.Array) + { + if (jTokenToDecrypt.Children().Any()) + { + for (int i = 0; i < jTokenToDecrypt.Count(); i++) + { + await DecryptJTokenAsync( + jTokenToDecrypt[i], + encryptionSettingForProperty, + isEscaped, + cancellationToken); + } + } + } + else + { + jTokenToDecrypt.Replace(await DecryptAndDeserializeValueAsync( + jTokenToDecrypt, + encryptionSettingForProperty, + isEscaped, + cancellationToken)); + } + } + + internal static string ConvertToBase64UriSafeString(byte[] bytesToProcess) + { + string base64String = Convert.ToBase64String(bytesToProcess); + + // Base 64 Encoding with URL and Filename Safe Alphabet https://datatracker.ietf.org/doc/html/rfc4648#section-5 + // https://docs.microsoft.com/en-us/azure/cosmos-db/concepts-limits#per-item-limits, due to base64 conversion and encryption + // the permissible size of the property will further reduce. + return new StringBuilder(base64String, base64String.Length).Replace("/", "_").Replace("+", "-").ToString(); + } + + internal static byte[] ConvertFromBase64UriSafeString(string uriSafeBase64String) + { + StringBuilder fromUriSafeBase64String = new StringBuilder(uriSafeBase64String, uriSafeBase64String.Length).Replace("_", "/").Replace("-", "+"); + return Convert.FromBase64String(fromUriSafeBase64String.ToString()); + } + private static async Task SerializeAndEncryptValueAsync( JToken jTokenToEncrypt, EncryptionSettingForProperty encryptionSettingForProperty, @@ -337,10 +394,10 @@ private static async Task SerializeAndEncryptValueAsync( } private static async Task DecryptAndDeserializeValueAsync( - JToken jToken, - EncryptionSettingForProperty encryptionSettingForProperty, - bool isEscaped, - CancellationToken cancellationToken) + JToken jToken, + EncryptionSettingForProperty encryptionSettingForProperty, + bool isEscaped, + CancellationToken cancellationToken) { byte[] cipherTextWithTypeMarker = null; @@ -379,47 +436,6 @@ private static async Task DecryptAndDeserializeValueAsync( (TypeMarker)cipherTextWithTypeMarker[0]); } - private static async Task DecryptJTokenAsync( - JToken jTokenToDecrypt, - EncryptionSettingForProperty encryptionSettingForProperty, - bool isEscaped, - CancellationToken cancellationToken) - { - if (jTokenToDecrypt.Type == JTokenType.Object) - { - foreach (JProperty jProperty in jTokenToDecrypt.Children()) - { - await DecryptJTokenAsync( - jProperty.Value, - encryptionSettingForProperty, - isEscaped, - cancellationToken); - } - } - else if (jTokenToDecrypt.Type == JTokenType.Array) - { - if (jTokenToDecrypt.Children().Any()) - { - for (int i = 0; i < jTokenToDecrypt.Count(); i++) - { - await DecryptJTokenAsync( - jTokenToDecrypt[i], - encryptionSettingForProperty, - isEscaped, - cancellationToken); - } - } - } - else - { - jTokenToDecrypt.Replace(await DecryptAndDeserializeValueAsync( - jTokenToDecrypt, - encryptionSettingForProperty, - isEscaped, - cancellationToken)); - } - } - private static async Task DecryptObjectAsync( JObject document, EncryptionSettings encryptionSettings, @@ -471,21 +487,5 @@ private static JObject RetrieveItem( return itemJObj; } - - private static string ConvertToBase64UriSafeString(byte[] bytesToProcess) - { - string base64String = Convert.ToBase64String(bytesToProcess); - - // Base 64 Encoding with URL and Filename Safe Alphabet https://datatracker.ietf.org/doc/html/rfc4648#section-5 - // https://docs.microsoft.com/en-us/azure/cosmos-db/concepts-limits#per-item-limits, due to base64 conversion and encryption - // the permissible size of the property will further reduce. - return new StringBuilder(base64String, base64String.Length).Replace("/", "_").Replace("+", "-").ToString(); - } - - private static byte[] ConvertFromBase64UriSafeString(string uriSafeBase64String) - { - StringBuilder fromUriSafeBase64String = new StringBuilder(uriSafeBase64String, uriSafeBase64String.Length).Replace("_", "/").Replace("-", "+"); - return Convert.FromBase64String(fromUriSafeBase64String.ToString()); - } } } \ No newline at end of file diff --git a/Microsoft.Azure.Cosmos.Encryption/src/EncryptionSettingForProperty.cs b/Microsoft.Azure.Cosmos.Encryption/src/EncryptionSettingForProperty.cs index fea4f12ff1..431349c88d 100644 --- a/Microsoft.Azure.Cosmos.Encryption/src/EncryptionSettingForProperty.cs +++ b/Microsoft.Azure.Cosmos.Encryption/src/EncryptionSettingForProperty.cs @@ -17,6 +17,11 @@ internal sealed class EncryptionSettingForProperty private readonly EncryptionContainer encryptionContainer; + // Test-only hook: when provided, BuildEncryptionAlgorithmForSettingAsync returns this instance + // instead of constructing one via key fetching/unwrapping. This is internal and used only by tests + // through InternalsVisibleTo. + private readonly Microsoft.Data.Encryption.Cryptography.AeadAes256CbcHmac256EncryptionAlgorithm injectedAlgorithm; + public EncryptionSettingForProperty( string clientEncryptionKeyId, Data.Encryption.Cryptography.EncryptionType encryptionType, @@ -29,12 +34,32 @@ public EncryptionSettingForProperty( this.databaseRid = string.IsNullOrEmpty(databaseRid) ? throw new ArgumentNullException(nameof(databaseRid)) : databaseRid; } + // Internal constructor for tests to inject a ready algorithm to enable end-to-end unit testing + // without standing up key providers. Other parameters remain for traceability but are not used + // when an injected algorithm is supplied. + internal EncryptionSettingForProperty( + string clientEncryptionKeyId, + Data.Encryption.Cryptography.EncryptionType encryptionType, + EncryptionContainer encryptionContainer, + string databaseRid, + AeadAes256CbcHmac256EncryptionAlgorithm injectedAlgorithm) + : this(clientEncryptionKeyId, encryptionType, encryptionContainer, databaseRid) + { + this.injectedAlgorithm = injectedAlgorithm ?? throw new ArgumentNullException(nameof(injectedAlgorithm)); + } + public string ClientEncryptionKeyId { get; } public Data.Encryption.Cryptography.EncryptionType EncryptionType { get; } public async Task BuildEncryptionAlgorithmForSettingAsync(CancellationToken cancellationToken) { + // Return the injected algorithm if provided (test-only path) + if (this.injectedAlgorithm != null) + { + return this.injectedAlgorithm; + } + ClientEncryptionKeyProperties clientEncryptionKeyProperties = await this.encryptionContainer.EncryptionCosmosClient.GetClientEncryptionKeyPropertiesAsync( clientEncryptionKeyId: this.ClientEncryptionKeyId, encryptionContainer: this.encryptionContainer, diff --git a/Microsoft.Azure.Cosmos.Encryption/src/EncryptionSettings.cs b/Microsoft.Azure.Cosmos.Encryption/src/EncryptionSettings.cs index 6f8ebf18b1..972d898b3d 100644 --- a/Microsoft.Azure.Cosmos.Encryption/src/EncryptionSettings.cs +++ b/Microsoft.Azure.Cosmos.Encryption/src/EncryptionSettings.cs @@ -16,7 +16,7 @@ internal sealed class EncryptionSettings { private readonly Dictionary encryptionSettingsDictByPropertyName; - private EncryptionSettings(string containerRidValue, IReadOnlyList partitionKeyPath) + internal EncryptionSettings(string containerRidValue, IReadOnlyList partitionKeyPath) { this.ContainerRidValue = containerRidValue; this.PartitionKeyPaths = partitionKeyPath; @@ -38,7 +38,6 @@ public static Task CreateAsync(EncryptionContainer encryptio public EncryptionSettingForProperty GetEncryptionSettingForProperty(string propertyName) { this.encryptionSettingsDictByPropertyName.TryGetValue(propertyName, out EncryptionSettingForProperty encryptionSettingsForProperty); - return encryptionSettingsForProperty; } @@ -51,6 +50,13 @@ public void SetRequestHeaders(RequestOptions requestOptions) }; } + internal void SetEncryptionSettingForProperty( + string propertyName, + EncryptionSettingForProperty encryptionSettingsForProperty) + { + this.encryptionSettingsDictByPropertyName[propertyName] = encryptionSettingsForProperty; + } + private static Data.Encryption.Cryptography.EncryptionType GetEncryptionTypeForProperty(ClientEncryptionIncludedPath clientEncryptionIncludedPath) { return clientEncryptionIncludedPath.EncryptionType switch @@ -135,12 +141,5 @@ await encryptionContainer.EncryptionCosmosClient.GetClientEncryptionKeyPropertie return encryptionSettings; } - - private void SetEncryptionSettingForProperty( - string propertyName, - EncryptionSettingForProperty encryptionSettingsForProperty) - { - this.encryptionSettingsDictByPropertyName[propertyName] = encryptionSettingsForProperty; - } } } \ No newline at end of file diff --git a/Microsoft.Azure.Cosmos.Encryption/tests/Microsoft.Azure.Cosmos.Encryption.Tests/EncryptionContainerPatchTests.cs b/Microsoft.Azure.Cosmos.Encryption/tests/Microsoft.Azure.Cosmos.Encryption.Tests/EncryptionContainerPatchTests.cs new file mode 100644 index 0000000000..66df07678f --- /dev/null +++ b/Microsoft.Azure.Cosmos.Encryption/tests/Microsoft.Azure.Cosmos.Encryption.Tests/EncryptionContainerPatchTests.cs @@ -0,0 +1,78 @@ +//------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +//------------------------------------------------------------ + +namespace Microsoft.Azure.Cosmos.Encryption.Tests +{ + using System; + using System.Collections.Generic; + using System.Runtime.Serialization; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class EncryptionContainerPatchTests + { + private static T CreateUninitialized() where T : class + { + return (T)FormatterServices.GetUninitializedObject(typeof(T)); + } + + private static EncryptionSettings CreateEncryptionSettingsWithEncryptedProperty(string propertyName, EncryptionContainer container) + { + if (container == null) throw new ArgumentNullException(nameof(container)); + + EncryptionSettings settings = new EncryptionSettings("rid", new List { "/id" }); + EncryptionSettingForProperty forProperty = new EncryptionSettingForProperty( + clientEncryptionKeyId: "cek1", + encryptionType: Microsoft.Data.Encryption.Cryptography.EncryptionType.Randomized, + encryptionContainer: container, + databaseRid: "dbRid"); + + settings.SetEncryptionSettingForProperty(propertyName, forProperty); + return settings; + } + + [TestMethod] + public async Task EncryptPatchOperationsAsync_Increment_On_Encrypted_Path_Throws() + { + // Arrange + EncryptionContainer container = CreateUninitialized(); + EncryptionSettings settings = CreateEncryptionSettingsWithEncryptedProperty("Sensitive", container); + List ops = new List + { + PatchOperation.Increment("/Sensitive", 1) + }; + + EncryptionDiagnosticsContext diag = new EncryptionDiagnosticsContext(); + + // Act + Assert + InvalidOperationException ex = await Assert.ThrowsExceptionAsync( + async () => await container.EncryptPatchOperationsAsync(ops, settings, diag, CancellationToken.None)); + + StringAssert.Contains(ex.Message, "Increment patch operation is not allowed for encrypted path"); + StringAssert.Contains(ex.Message, "/Sensitive"); + } + + [TestMethod] + public async Task EncryptPatchOperationsAsync_Increment_On_NonEncrypted_Path_Passes_Through() + { + // Arrange: No encrypted settings for this path + EncryptionContainer container = CreateUninitialized(); + EncryptionSettings settings = CreateEncryptionSettingsWithEncryptedProperty("Sensitive", container); // different property than used below + + PatchOperation op = PatchOperation.Increment("/Plain", 2); + List ops = new List { op }; + EncryptionDiagnosticsContext diag = new EncryptionDiagnosticsContext(); + + // Act + List result = await container.EncryptPatchOperationsAsync(ops, settings, diag, CancellationToken.None); + + // Assert: operation should be passed through unchanged + Assert.AreEqual(1, result.Count); + Assert.AreSame(op, result[0]); + Assert.AreEqual(PatchOperationType.Increment, result[0].OperationType); + } + } +} diff --git a/Microsoft.Azure.Cosmos.Encryption/tests/Microsoft.Azure.Cosmos.Encryption.Tests/EncryptionDiagnosticsTests.cs b/Microsoft.Azure.Cosmos.Encryption/tests/Microsoft.Azure.Cosmos.Encryption.Tests/EncryptionDiagnosticsTests.cs new file mode 100644 index 0000000000..6d3b33cff1 --- /dev/null +++ b/Microsoft.Azure.Cosmos.Encryption/tests/Microsoft.Azure.Cosmos.Encryption.Tests/EncryptionDiagnosticsTests.cs @@ -0,0 +1,90 @@ +//------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +//------------------------------------------------------------ + +namespace Microsoft.Azure.Cosmos.Encryption.Tests +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Runtime.Serialization; + using System.Text; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + using Newtonsoft.Json.Linq; + + [TestClass] + public class EncryptionDiagnosticsTests + { + private static T CreateUninitialized() where T : class + => (T)FormatterServices.GetUninitializedObject(typeof(T)); + + private static EncryptionSettings CreateEncryptionSettingsWithEncryptedProperty(string propertyName, EncryptionContainer container) + { + if (container == null) throw new ArgumentNullException(nameof(container)); + + var settings = new EncryptionSettings("rid", new List { "/id" }); + var forProperty = new EncryptionSettingForProperty( + clientEncryptionKeyId: "cek1", + encryptionType: Microsoft.Data.Encryption.Cryptography.EncryptionType.Randomized, + encryptionContainer: container, + databaseRid: "dbRid"); + settings.SetEncryptionSettingForProperty(propertyName, forProperty); + return settings; + } + + private static MemoryStream ToStream(string json) => new MemoryStream(Encoding.UTF8.GetBytes(json)); + + [TestMethod] + public async Task Diagnostics_Encrypt_EndCalled_With_Expected_Count() + { + // Arrange: property exists and is null, so algorithm is not invoked but count still increments + var json = "{\"id\":\"1\",\"Sensitive\":null}"; + using var input = ToStream(json); + + var container = CreateUninitialized(); + var settings = CreateEncryptionSettingsWithEncryptedProperty("Sensitive", container); + var diag = new EncryptionDiagnosticsContext(); + + // Act + using Stream result = await EncryptionProcessor.EncryptAsync(input, settings, diag, CancellationToken.None); + + // Assert + Assert.IsNotNull(result); + + // Verify diagnostics captured properties count + JToken countToken = diag.EncryptContent[Constants.DiagnosticsPropertiesEncryptedCount]; + Assert.IsNotNull(countToken, "Encrypt diagnostics should include properties encrypted count."); + Assert.AreEqual(1, countToken.Value()); + + Assert.IsNotNull(diag.EncryptContent[Constants.DiagnosticsStartTime]); + Assert.IsNotNull(diag.EncryptContent[Constants.DiagnosticsDuration]); + } + + [TestMethod] + public async Task Diagnostics_Decrypt_EndCalled_With_Expected_Count() + { + // Arrange: property exists and is null; decrypt path still visits and counts it + var json = "{\"id\":\"1\",\"Sensitive\":null}"; + using var input = ToStream(json); // MemoryStream is seekable (DEBUG assert satisfied) + + var container = CreateUninitialized(); + var settings = CreateEncryptionSettingsWithEncryptedProperty("Sensitive", container); + var diag = new EncryptionDiagnosticsContext(); + + // Act + using Stream result = await EncryptionProcessor.DecryptAsync(input, settings, diag, CancellationToken.None); + + // Assert + Assert.IsNotNull(result); + + JToken countToken = diag.DecryptContent[Constants.DiagnosticsPropertiesDecryptedCount]; + Assert.IsNotNull(countToken, "Decrypt diagnostics should include properties decrypted count."); + Assert.AreEqual(1, countToken.Value()); + + Assert.IsNotNull(diag.DecryptContent[Constants.DiagnosticsStartTime]); + Assert.IsNotNull(diag.DecryptContent[Constants.DiagnosticsDuration]); + } + } +} diff --git a/Microsoft.Azure.Cosmos.Encryption/tests/Microsoft.Azure.Cosmos.Encryption.Tests/EncryptionProcessorTests.CoreFunctionality.cs b/Microsoft.Azure.Cosmos.Encryption/tests/Microsoft.Azure.Cosmos.Encryption.Tests/EncryptionProcessorTests.CoreFunctionality.cs new file mode 100644 index 0000000000..74e11038fb --- /dev/null +++ b/Microsoft.Azure.Cosmos.Encryption/tests/Microsoft.Azure.Cosmos.Encryption.Tests/EncryptionProcessorTests.CoreFunctionality.cs @@ -0,0 +1,306 @@ +//------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +//------------------------------------------------------------ + +namespace Microsoft.Azure.Cosmos.Encryption.Tests +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Text; + using System.Threading; + using System.Threading.Tasks; + using Mde = Microsoft.Data.Encryption.Cryptography; + using Microsoft.VisualStudio.TestTools.UnitTesting; + using Newtonsoft.Json.Linq; + using TrackingStream = Microsoft.Azure.Cosmos.Encryption.Tests.TestHelpers.StreamTestHelpers.TrackingStream; + + /// + /// Core functionality tests for EncryptionProcessor including end-to-end encryption/decryption, + /// stream handling, JSON traversal, and main processing flows. + /// + public partial class EncryptionProcessorTests + { + #region End-to-End Tests + + // Removed no-op placeholder test. + + [TestMethod] + public async Task EndToEnd_EncryptDecrypt_RoundTrip_Primitives_And_Arrays_And_Objects() + { + Mde.AeadAes256CbcHmac256EncryptionAlgorithm algorithm = CreateDeterministicAlgorithm(); + + // Configure two properties for encryption: one primitive/array mix, one nested object + EncryptionSettings settings = new EncryptionSettings("rid", new List { "/id" }); + EncryptionContainer container = (EncryptionContainer)System.Runtime.Serialization.FormatterServices.GetUninitializedObject(typeof(EncryptionContainer)); + + EncryptionSettingForProperty cfg1 = new EncryptionSettingForProperty("cek1", Mde.EncryptionType.Deterministic, container, "dbRid", algorithm); + EncryptionSettingForProperty cfg2 = new EncryptionSettingForProperty("cek2", Mde.EncryptionType.Deterministic, container, "dbRid", algorithm); + + settings.SetEncryptionSettingForProperty("Secret", cfg1); + settings.SetEncryptionSettingForProperty("Nested", cfg2); + + string json = @"{ + ""id"": ""document-id"", + ""Secret"": { ""a"": 1, ""b"": true, ""c"": [ ""x"", 2, false, null, 3.14 ] }, + ""Nested"": { ""inner"": ""value"", ""arr"": [ { ""q"": 42 }, null ] }, + ""Plain"": 123 + }"; + + using (Stream input = ToStream(json)) + { + EncryptionDiagnosticsContext diagEnc = new EncryptionDiagnosticsContext(); + Stream encrypted = await EncryptionProcessor.EncryptAsync(input, settings, diagEnc, CancellationToken.None); + + // Ensure diagnostics counted both properties + Assert.AreEqual(2, diagEnc.EncryptContent[Constants.DiagnosticsPropertiesEncryptedCount].Value()); + + // Decrypt + EncryptionDiagnosticsContext diagDec = new EncryptionDiagnosticsContext(); + Stream decrypted = await EncryptionProcessor.DecryptAsync(encrypted, settings, diagDec, CancellationToken.None); + Assert.AreEqual(2, diagDec.DecryptContent[Constants.DiagnosticsPropertiesDecryptedCount].Value()); + + // Validate round-trip equality + JObject original = JObject.Parse(json); + JObject roundtripped = EncryptionProcessor.BaseSerializer.FromStream(decrypted); + Assert.IsTrue(JToken.DeepEquals(original, roundtripped), "Document should round-trip after encrypt/decrypt."); + } + } + + [TestMethod] + public async Task EndToEnd_EncryptDecrypt_Id_ShouldEscape_And_RoundTrip() + { + Mde.AeadAes256CbcHmac256EncryptionAlgorithm algorithm = CreateDeterministicAlgorithm(); + EncryptionSettings settings = CreateSettingsWithInjected("id", algorithm); + + string id = "id/with+special?chars#and\\slashes"; + // Build the JSON via JObject to ensure proper escaping. + JObject doc = new JObject + { + ["id"] = id, + ["p"] = 1 + }; + + using (Stream input = EncryptionProcessor.BaseSerializer.ToStream(doc)) + { + Stream encrypted = await EncryptionProcessor.EncryptAsync(input, settings, operationDiagnostics: null, cancellationToken: CancellationToken.None); + + // Inspect encrypted form to ensure id does not contain forbidden characters + JObject encryptedDoc = EncryptionProcessor.BaseSerializer.FromStream(encrypted); + string encId = encryptedDoc.Value("id"); + Assert.IsNotNull(encId); + Assert.IsFalse(encId.Contains('/')); + Assert.IsFalse(encId.Contains('+')); + Assert.IsFalse(encId.Contains('?')); + Assert.IsFalse(encId.Contains('#')); + Assert.IsFalse(encId.Contains('\\')); + + // Decrypt and verify original id restored + Stream decrypted = await EncryptionProcessor.DecryptAsync(EncryptionProcessor.BaseSerializer.ToStream(encryptedDoc), settings, operationDiagnostics: null, CancellationToken.None); + JObject roundtripped = EncryptionProcessor.BaseSerializer.FromStream(decrypted); + Assert.AreEqual(id, roundtripped.Value("id")); + } + } + + #endregion + + #region Stream Handling Tests + + [TestMethod] + public async Task StreamHandling_EncryptAsync_Disposes_Input_And_Returns_New_Stream() + { + // Arrange + TrackingStream input = new TrackingStream(ToStream("{\"id\":\"abc\",\"p\":1}")); + EncryptionSettings settings = CreateSettingsWithNoProperties(); + + // Act + Stream result = await EncryptionProcessor.EncryptAsync(input, settings, operationDiagnostics: null, cancellationToken: CancellationToken.None); + + // Assert + Assert.IsNotNull(result); + Assert.AreNotSame(input, result); // Should be a different stream + Assert.IsTrue(input.Disposed, "Input stream should be disposed"); + Assert.IsTrue(result.CanRead); + + // Verify content + string content = ReadToEnd(result); + Assert.AreEqual("{\"id\":\"abc\",\"p\":1}", content); + } + + [TestMethod] + public async Task StreamHandling_DecryptAsync_Disposes_Input_And_Returns_New_Stream() + { + // Arrange + TrackingStream input = new TrackingStream(ToStream("{\"id\":\"abc\",\"p\":1}")); + EncryptionSettings settings = CreateSettingsWithNoProperties(); + + // Act + Stream result = await EncryptionProcessor.DecryptAsync(input, settings, operationDiagnostics: null, cancellationToken: CancellationToken.None); + + // Assert + Assert.IsNotNull(result); + Assert.AreNotSame(input, result); // Should be a different stream + Assert.IsTrue(input.Disposed, "Input stream should be disposed"); + Assert.IsTrue(result.CanRead); + + // Verify content + string content = ReadToEnd(result); + Assert.AreEqual("{\"id\":\"abc\",\"p\":1}", content); + } + + [TestMethod] + public async Task StreamHandling_EncryptDecrypt_NoPropertiesToEncrypt_ReturnsPassthrough() + { + EncryptionSettings settings = CreateSettingsWithNoProperties(); + string originalJson = "{\"id\":\"test\",\"data\":\"value\",\"array\":[1,2,3]}"; + + using (Stream input = ToStream(originalJson)) + { + Stream encrypted = await EncryptionProcessor.EncryptAsync(input, settings, operationDiagnostics: null, cancellationToken: CancellationToken.None); + + string encryptedJson = ReadToEnd(encrypted); + + using (Stream encryptedInput = ToStream(encryptedJson)) + { + Stream decrypted = await EncryptionProcessor.DecryptAsync(encryptedInput, settings, operationDiagnostics: null, cancellationToken: CancellationToken.None); + + string decryptedJson = ReadToEnd(decrypted); + + Assert.AreEqual(originalJson, decryptedJson); + } + } + } + + #endregion + + #region JSON Traversal Tests + + private static JToken MakeNestedNullGraph() + { + return JToken.Parse("{ \"a\": null, \"b\": [ null, null, { \"c\": null } ], \"d\": { \"e\": [ null ] } }"); + } + + [TestMethod] + public async Task Traversal_EncryptJTokenAsync_Traverses_Nested_ObjectArray_WithNullLeaves_NoCrypto() + { + JToken token = MakeNestedNullGraph(); + // encryptionSettingForProperty: null is okay because null leaves short-circuit before usage + await EncryptionProcessor.EncryptJTokenAsync(token, encryptionSettingForProperty: null, shouldEscape: false, cancellationToken: CancellationToken.None); + + // Remains structurally the same (all null leaves) + Assert.IsTrue(JToken.DeepEquals(token, MakeNestedNullGraph())); + } + + [TestMethod] + public async Task Traversal_DecryptJTokenAsync_Traverses_Nested_ObjectArray_WithNullLeaves_NoCrypto() + { + JToken token = MakeNestedNullGraph(); + // isEscaped = true to exercise that branch (as if property == "id") + await EncryptionProcessor.DecryptJTokenAsync(token, encryptionSettingForProperty: null, isEscaped: true, cancellationToken: CancellationToken.None); + + Assert.IsTrue(JToken.DeepEquals(token, MakeNestedNullGraph())); + } + + [TestMethod] + public async Task Traversal_EncryptJTokenAsync_ShouldEscapeTrue_SubtreeTraversal_NoCrypto_WithStringIdPresent() + { + // Document has a string id, but we traverse only the 'sub' subtree which has null leaves. + JObject doc = JObject.Parse("{ \"id\": \"abc\", \"sub\": { \"a\": null, \"b\": [ null, { \"c\": null } ] } }"); + + // Take the subtree token to avoid touching the top-level id string. + JToken subtree = doc["sub"]; + await EncryptionProcessor.EncryptJTokenAsync(subtree, encryptionSettingForProperty: null, shouldEscape: true, cancellationToken: CancellationToken.None); + + // The subtree should remain unchanged, and the id property should remain unchanged too. + Assert.AreEqual("abc", doc.Value("id")); + Assert.IsTrue(JToken.DeepEquals(subtree, JToken.Parse("{ \"a\": null, \"b\": [ null, { \"c\": null } ] }"))); + } + + #endregion + + #region Numeric Round-Trip Tests + + [TestMethod] + public async Task Primitives_RoundTrip_Long_Min_Max_Negative_Zero() + { + Mde.AeadAes256CbcHmac256EncryptionAlgorithm algorithm = CreateDeterministicAlgorithm(); + EncryptionSettings settings = CreateSettingsWithInjected("n", algorithm); + + long[] values = new long[] { long.MinValue, -1L, 0L, 1L, long.MaxValue }; + + foreach (long v in values) + { + JObject doc = new JObject { ["id"] = "1", ["n"] = v }; + using System.IO.Stream enc = await EncryptionProcessor.EncryptAsync(EncryptionProcessor.BaseSerializer.ToStream(doc), settings, operationDiagnostics: null, cancellationToken: CancellationToken.None); + using System.IO.Stream dec = await EncryptionProcessor.DecryptAsync(enc, settings, operationDiagnostics: null, cancellationToken: CancellationToken.None); + JObject round = EncryptionProcessor.BaseSerializer.FromStream(dec); + Assert.AreEqual(v, round.Value("n")); + } + } + + [TestMethod] + public async Task Primitives_RoundTrip_Double_Extremes_Or_Disallow_NaN_Infinity() + { + Mde.AeadAes256CbcHmac256EncryptionAlgorithm algorithm = CreateDeterministicAlgorithm(); + EncryptionSettings settings = CreateSettingsWithInjected("d", algorithm); + + double[] values = new double[] { double.MinValue, -1.23e308, -1.0, 0.0, 1.0, 1.79e308, double.MaxValue }; + + foreach (double v in values) + { + JObject doc = new JObject { ["id"] = "1", ["d"] = v }; + using System.IO.Stream enc = await EncryptionProcessor.EncryptAsync(EncryptionProcessor.BaseSerializer.ToStream(doc), settings, operationDiagnostics: null, cancellationToken: CancellationToken.None); + using System.IO.Stream dec = await EncryptionProcessor.DecryptAsync(enc, settings, operationDiagnostics: null, cancellationToken: CancellationToken.None); + JObject round = EncryptionProcessor.BaseSerializer.FromStream(dec); + Assert.AreEqual(v, round.Value("d"), 0.0); + } + + // Disallow NaN / Infinity + Exception ex; + ex = null; + try { EncryptionProcessor.Serialize(new JValue(double.NaN)); } + catch (Exception e) { ex = e; } + Assert.IsNotNull(ex); + + ex = null; + try { EncryptionProcessor.Serialize(new JValue(double.PositiveInfinity)); } + catch (Exception e) { ex = e; } + Assert.IsNotNull(ex); + + ex = null; + try { EncryptionProcessor.Serialize(new JValue(double.NegativeInfinity)); } + catch (Exception e) { ex = e; } + Assert.IsNotNull(ex); + } + + #endregion + + #region Diagnostics Tests + + [TestMethod] + public async Task Diagnostics_EncryptDecrypt_MultipleProperties_Increments_Counts() + { + Mde.AeadAes256CbcHmac256EncryptionAlgorithm algorithm = CreateDeterministicAlgorithm(); + EncryptionSettings settings = new EncryptionSettings("rid", new List { "/id" }); + EncryptionContainer container = (EncryptionContainer)System.Runtime.Serialization.FormatterServices.GetUninitializedObject(typeof(EncryptionContainer)); + settings.SetEncryptionSettingForProperty("A", new EncryptionSettingForProperty("cekA", Mde.EncryptionType.Deterministic, container, "dbRid", algorithm)); + settings.SetEncryptionSettingForProperty("B", new EncryptionSettingForProperty("cekB", Mde.EncryptionType.Deterministic, container, "dbRid", algorithm)); + settings.SetEncryptionSettingForProperty("C", new EncryptionSettingForProperty("cekC", Mde.EncryptionType.Deterministic, container, "dbRid", algorithm)); + + JObject doc = new JObject { ["id"] = "1", ["A"] = 1, ["B"] = "x", ["C"] = true }; + + EncryptionDiagnosticsContext diagEnc = new EncryptionDiagnosticsContext(); + using System.IO.Stream enc = await EncryptionProcessor.EncryptAsync(EncryptionProcessor.BaseSerializer.ToStream(doc), settings, diagEnc, CancellationToken.None); + Assert.AreEqual(3, diagEnc.EncryptContent[Constants.DiagnosticsPropertiesEncryptedCount].Value()); + + EncryptionDiagnosticsContext diagDec = new EncryptionDiagnosticsContext(); + using System.IO.Stream dec = await EncryptionProcessor.DecryptAsync(enc, settings, diagDec, CancellationToken.None); + Assert.AreEqual(3, diagDec.DecryptContent[Constants.DiagnosticsPropertiesDecryptedCount].Value()); + + JObject round = EncryptionProcessor.BaseSerializer.FromStream(dec); + Assert.IsTrue(JToken.DeepEquals(doc, round)); + } + + #endregion + } +} diff --git a/Microsoft.Azure.Cosmos.Encryption/tests/Microsoft.Azure.Cosmos.Encryption.Tests/EncryptionProcessorTests.Cryptography.cs b/Microsoft.Azure.Cosmos.Encryption/tests/Microsoft.Azure.Cosmos.Encryption.Tests/EncryptionProcessorTests.Cryptography.cs new file mode 100644 index 0000000000..0d11590583 --- /dev/null +++ b/Microsoft.Azure.Cosmos.Encryption/tests/Microsoft.Azure.Cosmos.Encryption.Tests/EncryptionProcessorTests.Cryptography.cs @@ -0,0 +1,184 @@ +//------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +//------------------------------------------------------------ + +namespace Microsoft.Azure.Cosmos.Encryption.Tests +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Text; + using System.Threading; + using System.Threading.Tasks; + using Mde = Microsoft.Data.Encryption.Cryptography; + using Microsoft.VisualStudio.TestTools.UnitTesting; + using Newtonsoft.Json.Linq; + using Microsoft.Azure.Cosmos.Encryption.Tests.TestHelpers; + + /// + /// Algorithm and cryptography tests for EncryptionProcessor including randomized algorithms, + /// different encryption modes, and cryptographic behavior verification. + /// + public partial class EncryptionProcessorTests + { + #region Randomized Algorithm Tests + + private static Mde.AeadAes256CbcHmac256EncryptionAlgorithm CreateRandomizedAlgorithm() => TestCryptoHelpers.CreateAlgorithm(Mde.EncryptionType.Randomized); + + private static EncryptionSettings CreateRandomizedSettings(string propertyName, Mde.AeadAes256CbcHmac256EncryptionAlgorithm algo) + { + var settings = new EncryptionSettings("rid", new List { "/id" }); + var container = (EncryptionContainer)System.Runtime.Serialization.FormatterServices.GetUninitializedObject(typeof(EncryptionContainer)); + settings.SetEncryptionSettingForProperty(propertyName, new EncryptionSettingForProperty("cek1", Mde.EncryptionType.Randomized, container, "dbRid", algo)); + return settings; + } + + [TestMethod] + public async Task Cryptography_Randomized_Encrypt_Twice_DifferentCipher_SameDecrypt() + { + var algo = CreateRandomizedAlgorithm(); + var settings = CreateRandomizedSettings("Secret", algo); + + JObject doc = new JObject { ["id"] = "1", ["Secret"] = new JObject { ["a"] = 1, ["b"] = "x" } }; + + using System.IO.Stream s1 = EncryptionProcessor.BaseSerializer.ToStream(doc); + using System.IO.Stream e1 = await EncryptionProcessor.EncryptAsync(s1, settings, operationDiagnostics: null, cancellationToken: CancellationToken.None); + JObject enc1 = EncryptionProcessor.BaseSerializer.FromStream(e1); + + using System.IO.Stream s2 = EncryptionProcessor.BaseSerializer.ToStream(doc); + using System.IO.Stream e2 = await EncryptionProcessor.EncryptAsync(s2, settings, operationDiagnostics: null, cancellationToken: CancellationToken.None); + JObject enc2 = EncryptionProcessor.BaseSerializer.FromStream(e2); + + // Ciphertexts under randomized encryption should differ + Assert.IsFalse(JToken.DeepEquals(enc1["Secret"], enc2["Secret"])); + + // Both decrypt back to the original + using System.IO.Stream d1 = await EncryptionProcessor.DecryptAsync(EncryptionProcessor.BaseSerializer.ToStream(enc1), settings, operationDiagnostics: null, cancellationToken: CancellationToken.None); + using System.IO.Stream d2 = await EncryptionProcessor.DecryptAsync(EncryptionProcessor.BaseSerializer.ToStream(enc2), settings, operationDiagnostics: null, cancellationToken: CancellationToken.None); + JObject r1 = EncryptionProcessor.BaseSerializer.FromStream(d1); + JObject r2 = EncryptionProcessor.BaseSerializer.FromStream(d2); + Assert.IsTrue(JToken.DeepEquals(doc, r1)); + Assert.IsTrue(JToken.DeepEquals(doc, r2)); + } + + #endregion + + #region Deterministic vs Randomized Comparison Tests + + [TestMethod] + public async Task Cryptography_Deterministic_Encrypt_Twice_SameCipher() + { + var detAlgo = CreateDeterministicAlgorithm(); + var settings = CreateSettingsWithInjected("Secret", detAlgo); + + JObject doc = new JObject { ["id"] = "1", ["Secret"] = "consistent value" }; + + using System.IO.Stream s1 = EncryptionProcessor.BaseSerializer.ToStream(doc); + using System.IO.Stream e1 = await EncryptionProcessor.EncryptAsync(s1, settings, operationDiagnostics: null, cancellationToken: CancellationToken.None); + JObject enc1 = EncryptionProcessor.BaseSerializer.FromStream(e1); + + using System.IO.Stream s2 = EncryptionProcessor.BaseSerializer.ToStream(doc); + using System.IO.Stream e2 = await EncryptionProcessor.EncryptAsync(s2, settings, operationDiagnostics: null, cancellationToken: CancellationToken.None); + JObject enc2 = EncryptionProcessor.BaseSerializer.FromStream(e2); + + // Ciphertexts under deterministic encryption should be identical + Assert.IsTrue(JToken.DeepEquals(enc1["Secret"], enc2["Secret"])); + + // Both decrypt back to the original + using System.IO.Stream d1 = await EncryptionProcessor.DecryptAsync(EncryptionProcessor.BaseSerializer.ToStream(enc1), settings, operationDiagnostics: null, cancellationToken: CancellationToken.None); + using System.IO.Stream d2 = await EncryptionProcessor.DecryptAsync(EncryptionProcessor.BaseSerializer.ToStream(enc2), settings, operationDiagnostics: null, cancellationToken: CancellationToken.None); + JObject r1 = EncryptionProcessor.BaseSerializer.FromStream(d1); + JObject r2 = EncryptionProcessor.BaseSerializer.FromStream(d2); + Assert.IsTrue(JToken.DeepEquals(doc, r1)); + Assert.IsTrue(JToken.DeepEquals(doc, r2)); + } + + #endregion + + #region Different Encryption Modes Tests + + [TestMethod] + public async Task Cryptography_MixedEncryptionTypes_SingleDocument() + { + var detAlgo = CreateDeterministicAlgorithm(); + var randAlgo = CreateRandomizedAlgorithm(); + + var settings = new EncryptionSettings("rid", new List { "/id" }); + var container = (EncryptionContainer)System.Runtime.Serialization.FormatterServices.GetUninitializedObject(typeof(EncryptionContainer)); + + // Configure one property as deterministic, another as randomized + settings.SetEncryptionSettingForProperty("DeterministicProp", new EncryptionSettingForProperty("cek1", Mde.EncryptionType.Deterministic, container, "dbRid", detAlgo)); + settings.SetEncryptionSettingForProperty("RandomizedProp", new EncryptionSettingForProperty("cek2", Mde.EncryptionType.Randomized, container, "dbRid", randAlgo)); + + JObject doc = new JObject + { + ["id"] = "1", + ["DeterministicProp"] = "searchable value", + ["RandomizedProp"] = "secure value", + ["PlainProp"] = "unencrypted value" + }; + + using System.IO.Stream encrypted = await EncryptionProcessor.EncryptAsync(EncryptionProcessor.BaseSerializer.ToStream(doc), settings, operationDiagnostics: null, cancellationToken: CancellationToken.None); + using System.IO.Stream decrypted = await EncryptionProcessor.DecryptAsync(encrypted, settings, operationDiagnostics: null, cancellationToken: CancellationToken.None); + + JObject result = EncryptionProcessor.BaseSerializer.FromStream(decrypted); + Assert.IsTrue(JToken.DeepEquals(doc, result)); + } + + #endregion + + #region Key Management Tests + + [TestMethod] + public async Task Cryptography_DifferentKeys_SameAlgorithm() + { + var algo1 = CreateDeterministicAlgorithm(); + var algo2 = CreateDeterministicAlgorithm(); // Different key internally + + var settings1 = CreateSettingsWithInjected("Secret", algo1); + var settings2 = CreateSettingsWithInjected("Secret", algo2); + + JObject doc = new JObject { ["id"] = "1", ["Secret"] = "shared value" }; + + // Encrypt with first key + using System.IO.Stream s1 = EncryptionProcessor.BaseSerializer.ToStream(doc); + using System.IO.Stream e1 = await EncryptionProcessor.EncryptAsync(s1, settings1, operationDiagnostics: null, cancellationToken: CancellationToken.None); + JObject enc1 = EncryptionProcessor.BaseSerializer.FromStream(e1); + + // Encrypt with second key + using System.IO.Stream s2 = EncryptionProcessor.BaseSerializer.ToStream(doc); + using System.IO.Stream e2 = await EncryptionProcessor.EncryptAsync(s2, settings2, operationDiagnostics: null, cancellationToken: CancellationToken.None); + JObject enc2 = EncryptionProcessor.BaseSerializer.FromStream(e2); + + // Different keys should produce different ciphertexts (even with deterministic encryption) + Assert.IsFalse(JToken.DeepEquals(enc1["Secret"], enc2["Secret"])); + + // Each decrypts correctly with its own key + using System.IO.Stream d1 = await EncryptionProcessor.DecryptAsync(EncryptionProcessor.BaseSerializer.ToStream(enc1), settings1, operationDiagnostics: null, cancellationToken: CancellationToken.None); + using System.IO.Stream d2 = await EncryptionProcessor.DecryptAsync(EncryptionProcessor.BaseSerializer.ToStream(enc2), settings2, operationDiagnostics: null, cancellationToken: CancellationToken.None); + JObject r1 = EncryptionProcessor.BaseSerializer.FromStream(d1); + JObject r2 = EncryptionProcessor.BaseSerializer.FromStream(d2); + Assert.IsTrue(JToken.DeepEquals(doc, r1)); + Assert.IsTrue(JToken.DeepEquals(doc, r2)); + } + + #endregion + + #region Algorithm Behavior Tests + + [TestMethod] + public void Cryptography_AlgorithmProperties_ValidConfiguration() + { + var detAlgo = CreateDeterministicAlgorithm(); + var randAlgo = CreateRandomizedAlgorithm(); + + Assert.IsNotNull(detAlgo); + Assert.IsNotNull(randAlgo); + + // These algorithms should be configured correctly for their respective encryption types + // Additional property checks would require access to internal algorithm state + } + + #endregion + } +} diff --git a/Microsoft.Azure.Cosmos.Encryption/tests/Microsoft.Azure.Cosmos.Encryption.Tests/EncryptionProcessorTests.DataFormatEncoding.cs b/Microsoft.Azure.Cosmos.Encryption/tests/Microsoft.Azure.Cosmos.Encryption.Tests/EncryptionProcessorTests.DataFormatEncoding.cs new file mode 100644 index 0000000000..a03d244ff3 --- /dev/null +++ b/Microsoft.Azure.Cosmos.Encryption/tests/Microsoft.Azure.Cosmos.Encryption.Tests/EncryptionProcessorTests.DataFormatEncoding.cs @@ -0,0 +1,391 @@ +//------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +//------------------------------------------------------------ + +namespace Microsoft.Azure.Cosmos.Encryption.Tests +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + using System.Text; + using System.Threading; + using System.Threading.Tasks; + using Mde = Microsoft.Data.Encryption.Cryptography; + using Microsoft.VisualStudio.TestTools.UnitTesting; + using Newtonsoft.Json.Linq; + using Microsoft.Azure.Cosmos.Encryption.Tests.TestHelpers; + + /// + /// Data format and encoding tests for EncryptionProcessor including ID escaping, + /// Unicode handling, feed responses, feed shapes, and value stream encryption. + /// + public partial class EncryptionProcessorTests + { + #region ID Escaping Tests + + [TestMethod] + public void DataFormat_Base64_UriSafe_Roundtrip_With_Url_Problematic_Chars() + { + // bytes that produce '+' and '/' in standard Base64 + // Build input with a wide byte distribution + byte[] input = Enumerable.Range(0, 256).Select(i => (byte)i).ToArray(); + + string uriSafe = EncryptionProcessor.ConvertToBase64UriSafeString(input); + + // Assert it contains neither '+' nor '/' + Assert.IsFalse(uriSafe.Contains('+')); + Assert.IsFalse(uriSafe.Contains('/')); + + byte[] roundtrip = EncryptionProcessor.ConvertFromBase64UriSafeString(uriSafe); + CollectionAssert.AreEqual(input, roundtrip, "URI-safe Base64 conversion should be lossless."); + } + + [TestMethod] + public void DataFormat_Base64_UriSafe_Does_Not_Pad_With_Whitespace() + { + byte[] input = Encoding.UTF8.GetBytes("some id with / and + and ? #"); + string uriSafe = EncryptionProcessor.ConvertToBase64UriSafeString(input); + + // Sanity: No whitespace + Assert.IsFalse(uriSafe.Any(char.IsWhiteSpace)); + + // Roundtrip + byte[] roundtrip = EncryptionProcessor.ConvertFromBase64UriSafeString(uriSafe); + CollectionAssert.AreEqual(input, roundtrip); + } + + #endregion + + #region Unicode Handling Tests + + [TestMethod] + public async Task DataFormat_EncryptDecrypt_Id_With_Unicode_And_ProblemChars_RoundTrips() + { + string id = "id/漢字+emoji😀?hash#back\\slash"; + JObject doc = new JObject { ["id"] = id, ["p"] = 1 }; + EncryptionSettings settings = CreateSettings("id", Algo()); + + using System.IO.Stream enc = await EncryptionProcessor.EncryptAsync(EncryptionProcessor.BaseSerializer.ToStream(doc), settings, operationDiagnostics: null, cancellationToken: CancellationToken.None); + JObject encDoc = EncryptionProcessor.BaseSerializer.FromStream(enc); + string encId = encDoc.Value("id"); + Assert.IsFalse(encId.Contains('/')); + Assert.IsFalse(encId.Contains('+')); + Assert.IsFalse(encId.Contains('?')); + Assert.IsFalse(encId.Contains('#')); + Assert.IsFalse(encId.Contains('\\')); + + using System.IO.Stream dec = await EncryptionProcessor.DecryptAsync(EncryptionProcessor.BaseSerializer.ToStream(encDoc), settings, operationDiagnostics: null, cancellationToken: CancellationToken.None); + JObject round = EncryptionProcessor.BaseSerializer.FromStream(dec); + Assert.AreEqual(id, round.Value("id")); + } + + [TestMethod] + public async Task DataFormat_EncryptDecrypt_Id_Large_MixedUnicode_RoundTrips() + { + // Build a large, mixed-unicode id string + StringBuilder sb = new StringBuilder(); + string chunk = "😀漢字🌍🔥/+#?\\"; + for (int i = 0; i < 500; i++) sb.Append(chunk); // length ~3500 chars + string id = sb.ToString(); + + JObject doc = new JObject { ["id"] = id, ["p"] = 1 }; + EncryptionSettings settings = CreateSettings("id", Algo()); + + using System.IO.Stream enc = await EncryptionProcessor.EncryptAsync(EncryptionProcessor.BaseSerializer.ToStream(doc), settings, operationDiagnostics: null, cancellationToken: CancellationToken.None); + JObject encDoc = EncryptionProcessor.BaseSerializer.FromStream(enc); + string encId = encDoc.Value("id"); + Assert.IsFalse(encId.Contains('/')); + Assert.IsFalse(encId.Contains('+')); + Assert.IsFalse(encId.Contains('?')); + Assert.IsFalse(encId.Contains('#')); + Assert.IsFalse(encId.Contains('\\')); + + using System.IO.Stream dec = await EncryptionProcessor.DecryptAsync(EncryptionProcessor.BaseSerializer.ToStream(encDoc), settings, operationDiagnostics: null, cancellationToken: CancellationToken.None); + JObject round = EncryptionProcessor.BaseSerializer.FromStream(dec); + Assert.AreEqual(id, round.Value("id")); + } + + #endregion + + #region Feed Response Tests + + [TestMethod] + public async Task DataFormat_DeserializeAndDecryptResponseAsync_Throws_When_Documents_NotArrayOrMissing() + { + // Arrange: Ensure PropertiesToEncrypt.Any() == true so we don't early-return + EncryptionSettings settings = new EncryptionSettings("rid", new List { "/id" }); + EncryptionContainer container = (EncryptionContainer)System.Runtime.Serialization.FormatterServices.GetUninitializedObject(typeof(EncryptionContainer)); + settings.SetEncryptionSettingForProperty( + "Sensitive", + new EncryptionSettingForProperty( + clientEncryptionKeyId: "cek1", + encryptionType: Mde.EncryptionType.Randomized, + encryptionContainer: container, + databaseRid: "dbRid")); + + // Case 1: Missing Documents property + using (System.IO.MemoryStream s1 = ToStream("{ \"_count\": 0 }")) + { + await Assert.ThrowsExceptionAsync( + () => EncryptionProcessor.DeserializeAndDecryptResponseAsync(s1, settings, operationDiagnostics: null, cancellationToken: CancellationToken.None)); + } + + // Case 2: Documents is not an array (object) + using (System.IO.MemoryStream s2 = ToStream("{ \"_count\": 1, \"Documents\": { \"id\": \"1\" } }")) + { + await Assert.ThrowsExceptionAsync( + () => EncryptionProcessor.DeserializeAndDecryptResponseAsync(s2, settings, operationDiagnostics: null, cancellationToken: CancellationToken.None)); + } + + // Case 3: Documents is not an array (string) + using (System.IO.MemoryStream s3 = ToStream("{ \"_count\": 1, \"Documents\": \"oops\" }")) + { + await Assert.ThrowsExceptionAsync( + () => EncryptionProcessor.DeserializeAndDecryptResponseAsync(s3, settings, operationDiagnostics: null, cancellationToken: CancellationToken.None)); + } + } + + [TestMethod] + public async Task DataFormat_DeserializeAndDecryptResponseAsync_EmptyDocumentsArray_NoOp() + { + string responseJson = "{ \"_count\": 0, \"Documents\": [] }"; + using (System.IO.MemoryStream stream = ToStream(responseJson)) + { + EncryptionSettings settings = CreateSettingsWithNoProperties(); + + // With no properties to encrypt, method should return input as-is + using System.IO.Stream result = await EncryptionProcessor.DeserializeAndDecryptResponseAsync(stream, settings, operationDiagnostics: null, cancellationToken: CancellationToken.None); + + // Expect same content structure + JObject original = JObject.Parse(responseJson); + JObject roundtrip = EncryptionProcessor.BaseSerializer.FromStream(result); + Assert.IsTrue(JToken.DeepEquals(original, roundtrip)); + } + } + + [TestMethod] + public async Task DataFormat_DeserializeAndDecryptResponseAsync_EmptyDocumentsArray_WithConfiguredProps_NoOpButParses() + { + string responseJson = "{ \"_count\": 0, \"Documents\": [] }"; + using (System.IO.MemoryStream stream = ToStream(responseJson)) + { + // Create settings with a mapping; no documents -> no-op + EncryptionSettings settings = new EncryptionSettings("rid", new List { "/id" }); + EncryptionContainer container = (EncryptionContainer)System.Runtime.Serialization.FormatterServices.GetUninitializedObject(typeof(EncryptionContainer)); + settings.SetEncryptionSettingForProperty( + "sensitive", + new EncryptionSettingForProperty( + clientEncryptionKeyId: "cek1", + encryptionType: Mde.EncryptionType.Randomized, + encryptionContainer: container, + databaseRid: "dbRid")); + + using System.IO.Stream result = await EncryptionProcessor.DeserializeAndDecryptResponseAsync(stream, settings, operationDiagnostics: null, cancellationToken: CancellationToken.None); + + JObject original = JObject.Parse(responseJson); + JObject roundtrip = EncryptionProcessor.BaseSerializer.FromStream(result); + Assert.IsTrue(JToken.DeepEquals(original, roundtrip)); + } + } + + [TestMethod] + public async Task DataFormat_DeserializeAndDecryptResponseAsync_MixedDocuments_AggregatesDiagnosticsOnlyForObjects() + { + // Documents array with: object (has Sensitive: null), number, string, object (no Sensitive) + string responseJson = "{\n \"_count\": 4,\n \"Documents\": [\n { \"id\": \"1\", \"Sensitive\": null },\n 42,\n \"hello\",\n { \"id\": \"2\", \"Other\": true }\n ]\n}"; + + using (System.IO.MemoryStream stream = ToStream(responseJson)) + { + // Build EncryptionSettings with a mapping for Sensitive; null value avoids crypto path. + EncryptionSettings settings = new EncryptionSettings("rid", new List { "/id" }); + EncryptionContainer container = (EncryptionContainer)System.Runtime.Serialization.FormatterServices.GetUninitializedObject(typeof(EncryptionContainer)); + EncryptionSettingForProperty forProperty = new EncryptionSettingForProperty( + clientEncryptionKeyId: "cek1", + encryptionType: Mde.EncryptionType.Randomized, + encryptionContainer: container, + databaseRid: "dbRid"); + settings.SetEncryptionSettingForProperty("Sensitive", forProperty); + + EncryptionDiagnosticsContext diag = new EncryptionDiagnosticsContext(); + + using System.IO.Stream result = await EncryptionProcessor.DeserializeAndDecryptResponseAsync(stream, settings, diag, CancellationToken.None); + + // Only the first object contains the configured property; expect count == 1 + Assert.IsNotNull(diag.DecryptContent); + Assert.AreEqual(1, diag.DecryptContent[Constants.DiagnosticsPropertiesDecryptedCount].Value()); + + // Shape should remain intact. + JObject roundtrip = EncryptionProcessor.BaseSerializer.FromStream(result); + Assert.IsTrue(JToken.DeepEquals(JObject.Parse(responseJson), roundtrip)); + } + } + + [TestMethod] + public async Task DataFormat_FeedResponse_MultipleDocs_Aggregates_Count() + { + // Arrange: two documents with the configured property present + string responseJson = "{\n \"_count\": 2,\n \"Documents\": [\n { \"id\": \"1\", \"Sensitive\": null },\n { \"id\": \"2\", \"Sensitive\": null }\n ]\n}"; + + using (System.IO.MemoryStream stream = ToStream(responseJson)) + { + EncryptionSettings settings = new EncryptionSettings("rid", new List { "/id" }); + EncryptionContainer container = (EncryptionContainer)System.Runtime.Serialization.FormatterServices.GetUninitializedObject(typeof(EncryptionContainer)); + EncryptionSettingForProperty forProperty = new EncryptionSettingForProperty( + clientEncryptionKeyId: "cek1", + encryptionType: Mde.EncryptionType.Randomized, + encryptionContainer: container, + databaseRid: "dbRid"); + settings.SetEncryptionSettingForProperty("Sensitive", forProperty); + + EncryptionDiagnosticsContext diag = new EncryptionDiagnosticsContext(); + using System.IO.Stream result = await EncryptionProcessor.DeserializeAndDecryptResponseAsync(stream, settings, diag, CancellationToken.None); + + // Both objects have the property present -> count should be 2 + Assert.IsNotNull(diag.DecryptContent); + Assert.AreEqual(2, diag.DecryptContent[Constants.DiagnosticsPropertiesDecryptedCount].Value()); + + // Shape preserved + JObject roundtrip = EncryptionProcessor.BaseSerializer.FromStream(result); + Assert.IsTrue(JToken.DeepEquals(JObject.Parse(responseJson), roundtrip)); + } + } + + #endregion + + #region Feed Shape Tests + + [TestMethod] + public async Task DataFormat_ProcessFeedResponse_MaintainsStructure() + { + // Test that feed response processing maintains the overall structure + string feedJson = @"{ + ""_rid"": ""abc"", + ""Documents"": [ + {""id"": ""doc1"", ""data"": ""value1""}, + {""id"": ""doc2"", ""data"": ""value2""} + ], + ""_count"": 2 + }"; + + using (System.IO.MemoryStream stream = ToStream(feedJson)) + { + EncryptionSettings settings = CreateSettingsWithNoProperties(); + + using System.IO.Stream result = await EncryptionProcessor.DeserializeAndDecryptResponseAsync(stream, settings, operationDiagnostics: null, cancellationToken: CancellationToken.None); + + JObject original = JObject.Parse(feedJson); + JObject processed = EncryptionProcessor.BaseSerializer.FromStream(result); + + Assert.IsTrue(JToken.DeepEquals(original, processed)); + Assert.AreEqual(2, processed["_count"].Value()); + Assert.AreEqual("abc", processed["_rid"].Value()); + } + } + + #endregion + + #region Value Stream Encryption Tests + + [TestMethod] + public async Task DataFormat_EncryptValueStream_Scalar_String_ShouldEscapeFalse_RoundTrip() + { + Mde.AeadAes256CbcHmac256EncryptionAlgorithm algo = Algo(); + // Create settings just to reuse its configured property settings + EncryptionSettings settings = CreateSettings("s", algo); + EncryptionSettingForProperty propSetting = settings.GetEncryptionSettingForProperty("s"); + + using (System.IO.MemoryStream valueStream = ToStream("\"hello\"")) + { + using System.IO.Stream enc = await EncryptionProcessor.EncryptValueStreamAsync(valueStream, propSetting, shouldEscape: false, cancellationToken: CancellationToken.None); + JToken encryptedToken = EncryptionProcessor.BaseSerializer.FromStream(enc); + + // Decrypt via wrapper + JObject wrapper = new JObject { ["s"] = encryptedToken }; + await EncryptionProcessor.DecryptJTokenAsync(wrapper["s"], propSetting, isEscaped: false, cancellationToken: CancellationToken.None); + Assert.AreEqual("hello", wrapper.Value("s")); + } + } + + [TestMethod] + public async Task DataFormat_EncryptValueStream_Scalar_String_ShouldEscapeTrue_RoundTrip() + { + Mde.AeadAes256CbcHmac256EncryptionAlgorithm algo = Algo(); + EncryptionSettings settings = CreateSettings("s", algo); + EncryptionSettingForProperty propSetting = settings.GetEncryptionSettingForProperty("s"); + + using (System.IO.MemoryStream valueStream = ToStream("\"id/with+chars?#\"")) // JSON string: id/with+chars?# + { + using System.IO.Stream enc = await EncryptionProcessor.EncryptValueStreamAsync(valueStream, propSetting, shouldEscape: true, cancellationToken: CancellationToken.None); + JToken encryptedToken = EncryptionProcessor.BaseSerializer.FromStream(enc); + + // Encrypted token must be a URI-safe base64 string + string cipher = encryptedToken.Value(); + Assert.IsFalse(cipher.Contains('/')); + Assert.IsFalse(cipher.Contains('+')); + Assert.IsFalse(cipher.Contains('?')); + Assert.IsFalse(cipher.Contains('#')); + Assert.IsFalse(cipher.Contains('\\')); + + // Decrypt via wrapper + JObject wrapper = new JObject { ["s"] = encryptedToken }; + await EncryptionProcessor.DecryptJTokenAsync(wrapper["s"], propSetting, isEscaped: true, cancellationToken: CancellationToken.None); + Assert.AreEqual("id/with+chars?#", wrapper.Value("s")); + } + } + + [TestMethod] + public async Task DataFormat_EncryptValueStream_Object_Traverse_Encrypts_Leaves_RoundTrip() + { + Mde.AeadAes256CbcHmac256EncryptionAlgorithm algo = Algo(); + EncryptionSettings settings = CreateSettings("s", algo); + EncryptionSettingForProperty propSetting = settings.GetEncryptionSettingForProperty("s"); + + string payload = "{\"a\":1,\"b\":\"x\",\"c\":null,\"d\":[true,2,\"y\",null]}"; + using (System.IO.Stream enc = await EncryptionProcessor.EncryptValueStreamAsync(ToStream(payload), propSetting, shouldEscape: false, cancellationToken: CancellationToken.None)) + { + JToken encryptedToken = EncryptionProcessor.BaseSerializer.FromStream(enc); + + JObject wrapper = new JObject { ["s"] = encryptedToken }; + await EncryptionProcessor.DecryptJTokenAsync(wrapper["s"], propSetting, isEscaped: false, cancellationToken: CancellationToken.None); + + Assert.IsTrue(JToken.DeepEquals(JObject.Parse(payload), wrapper["s"])); + } + } + + [TestMethod] + public async Task DataFormat_EncryptValueStream_ShouldEscapeTrue_With_NonStringLeaf_Throws() + { + Mde.AeadAes256CbcHmac256EncryptionAlgorithm algo = Algo(); + EncryptionSettings settings = CreateSettings("s", algo); + EncryptionSettingForProperty propSetting = settings.GetEncryptionSettingForProperty("s"); + + // Object contains non-string leaf (1) and shouldEscape=true should fail + using (System.IO.MemoryStream valueStream = ToStream("{\"a\":1,\"b\":\"x\"}")) + { + ArgumentException ex = await Assert.ThrowsExceptionAsync( + () => EncryptionProcessor.EncryptValueStreamAsync(valueStream, propSetting, shouldEscape: true, cancellationToken: CancellationToken.None)); + StringAssert.Contains(ex.Message, "value to escape has to be string type"); + } + } + + [TestMethod] + public async Task DataFormat_EncryptValueStream_NullArgs_Throw() + { + Mde.AeadAes256CbcHmac256EncryptionAlgorithm algo = Algo(); + EncryptionSettings settings = CreateSettings("s", algo); + EncryptionSettingForProperty propSetting = settings.GetEncryptionSettingForProperty("s"); + + await Assert.ThrowsExceptionAsync( + () => EncryptionProcessor.EncryptValueStreamAsync(null, propSetting, shouldEscape: false, cancellationToken: CancellationToken.None)); + + using (System.IO.MemoryStream valueStream = ToStream("\"x\"")) + { + await Assert.ThrowsExceptionAsync( + () => EncryptionProcessor.EncryptValueStreamAsync(valueStream, encryptionSettingForProperty: null, shouldEscape: false, cancellationToken: CancellationToken.None)); + } + } + + #endregion + } +} diff --git a/Microsoft.Azure.Cosmos.Encryption/tests/Microsoft.Azure.Cosmos.Encryption.Tests/EncryptionProcessorTests.EdgeCases.cs b/Microsoft.Azure.Cosmos.Encryption/tests/Microsoft.Azure.Cosmos.Encryption.Tests/EncryptionProcessorTests.EdgeCases.cs new file mode 100644 index 0000000000..e4a4ecd77e --- /dev/null +++ b/Microsoft.Azure.Cosmos.Encryption/tests/Microsoft.Azure.Cosmos.Encryption.Tests/EncryptionProcessorTests.EdgeCases.cs @@ -0,0 +1,231 @@ +//------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +//------------------------------------------------------------ + +namespace Microsoft.Azure.Cosmos.Encryption.Tests +{ + using System; + using System.Collections.Generic; + using System.Numerics; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + using Mde = Microsoft.Data.Encryption.Cryptography; + using Newtonsoft.Json.Linq; + using Microsoft.Azure.Cosmos.Encryption.Tests.TestHelpers; + + /// + /// Edge cases and reliability tests for EncryptionProcessor including depth handling, + /// overflow scenarios, no-op operations, null crypto paths, and diagnostics edge cases. + /// + public partial class EncryptionProcessorTests + { + #region Depth Handling Tests + + private static string DeepJson(int depth) + { + // Build nested {"o": {"o": ... {"v": "x"} }} + JObject cur = new JObject { ["v"] = "x" }; + for (int i = 0; i < depth; i++) + { + cur = new JObject { ["o"] = cur }; + } + return cur.ToString(Newtonsoft.Json.Formatting.None); + } + + [TestMethod] + public async Task EdgeCases_EncryptDecrypt_MaxDepthMinusOne_Succeeds() + { + // Base serializer uses MaxDepth = 64; we generate a depth somewhat below that to avoid parser issues + string json = $"{{\"id\":\"d\",\"Secret\":{DeepJson(30)} }}"; // 30 nested levels + EncryptionSettings settings = CreateSettings("Secret", Algo()); + + using System.IO.Stream enc = await EncryptionProcessor.EncryptAsync(EncryptionProcessor.BaseSerializer.ToStream(JObject.Parse(json)), settings, operationDiagnostics: null, cancellationToken: CancellationToken.None); + using System.IO.Stream dec = await EncryptionProcessor.DecryptAsync(enc, settings, operationDiagnostics: null, cancellationToken: CancellationToken.None); + JObject round = EncryptionProcessor.BaseSerializer.FromStream(dec); + Assert.AreEqual("x", round.SelectToken("$.Secret..v").Value()); + } + + [TestMethod] + public async Task EdgeCases_EncryptDecrypt_NearMaxDepth_Succeeds() + { + // Push close to MaxDepth (64). Using ~60 nested objects keeps us under the cap considering root and wrappers. + JObject deep = JObject.Parse(DeepJson(60)); + JObject doc = new JObject { ["id"] = "deep", ["Secret"] = deep }; + EncryptionSettings settings = CreateSettings("Secret", Algo()); + + using System.IO.Stream enc = await EncryptionProcessor.EncryptAsync(EncryptionProcessor.BaseSerializer.ToStream(doc), settings, operationDiagnostics: null, cancellationToken: CancellationToken.None); + using System.IO.Stream dec = await EncryptionProcessor.DecryptAsync(enc, settings, operationDiagnostics: null, cancellationToken: CancellationToken.None); + JObject round = EncryptionProcessor.BaseSerializer.FromStream(dec); + Assert.AreEqual("x", round.SelectToken("$.Secret..v").Value()); + } + + #endregion + + #region Overflow Tests + + [TestMethod] + public void EdgeCases_Serialize_BigInteger_Throws() + { + // Current implementation does not support BigInteger and will attempt to coerce to long. + // Verify this results in an exception (OverflowException in the current path). + BigInteger tooLarge = new BigInteger(long.MaxValue) + 1; + JToken token = new JValue(tooLarge); + + try + { + EncryptionProcessor.Serialize(token); + Assert.Fail("Expected an exception when serializing BigInteger, but none was thrown."); + } + catch (Exception ex) + { + // Be tolerant to implementation detail: either OverflowException (from ToObject) + // or InvalidOperationException if validation changes upstream. + Assert.IsTrue( + ex is OverflowException || ex is InvalidOperationException, + $"Expected OverflowException or InvalidOperationException, but got {ex.GetType()}: {ex.Message}"); + } + } + + #endregion + + #region No-Op Decryption Tests + + private static EncryptionSettings CreateSettingsEmpty() + { + return new EncryptionSettings("rid", new List { "/id" }); + } + + private static EncryptionSettings CreateSettingsWithNullMapping(params string[] properties) + { + // Configure real mappings for properties so PropertiesToEncrypt contains them, + // allowing decrypt traversal without modifying internals. + EncryptionSettings settings = CreateSettingsEmpty(); + EncryptionContainer container = (EncryptionContainer)System.Runtime.Serialization.FormatterServices.GetUninitializedObject(typeof(EncryptionContainer)); + foreach (string p in properties) + { + EncryptionSettingForProperty forProperty = new EncryptionSettingForProperty( + clientEncryptionKeyId: "cek1", + encryptionType: Mde.EncryptionType.Randomized, + encryptionContainer: container, + databaseRid: "dbRid"); + settings.SetEncryptionSettingForProperty(p, forProperty); + } + + return settings; + } + + [TestMethod] + public async Task EdgeCases_Decrypt_JObject_NoPropertiesConfigured_ReturnsSameAndZeroCount() + { + JObject doc = JObject.Parse("{ \"id\": \"1\", \"name\": \"n\" }"); + EncryptionSettings settings = CreateSettingsEmpty(); + + (JObject result, int count) = await EncryptionProcessor.DecryptAsync(doc, settings, CancellationToken.None); + + Assert.AreSame(doc, result); + Assert.AreEqual(0, count); + } + + [TestMethod] + public async Task EdgeCases_Decrypt_JObject_WithPropertiesConfigured_ButNoneCiphertext_ReturnsSameAndZeroCount() + { + // Configure a property for encryption that is NOT present in the document, + // so no ciphertext is encountered and decrypt is a no-op with zero count. + JObject doc = JObject.Parse("{ \"id\": \"plaintext\", \"name\": \"n\" }"); + EncryptionSettings settings = CreateSettingsWithNullMapping("Secret"); // 'Secret' not in doc + + (JObject result, int count) = await EncryptionProcessor.DecryptAsync(doc, settings, CancellationToken.None); + + Assert.AreSame(doc, result); + Assert.AreEqual(0, count); + } + + #endregion + + #region Diagnostics Edge Case Tests + + [TestMethod] + public async Task EdgeCases_Diagnostics_EmptyDocument_NoProperties_ZeroCount() + { + EncryptionSettings settings = CreateSettingsWithNoProperties(); + string json = "{}"; + + using (System.IO.Stream input = ToStream(json)) + { + EncryptionDiagnosticsContext diagEnc = new EncryptionDiagnosticsContext(); + System.IO.Stream encrypted = await EncryptionProcessor.EncryptAsync(input, settings, diagEnc, CancellationToken.None); + + // Should have zero properties encrypted + Assert.AreEqual(0, diagEnc.EncryptContent[Constants.DiagnosticsPropertiesEncryptedCount].Value()); + + // Decrypt should also show zero + EncryptionDiagnosticsContext diagDec = new EncryptionDiagnosticsContext(); + System.IO.Stream decrypted = await EncryptionProcessor.DecryptAsync(encrypted, settings, diagDec, CancellationToken.None); + Assert.AreEqual(0, diagDec.DecryptContent[Constants.DiagnosticsPropertiesDecryptedCount].Value()); + } + } + + [TestMethod] + public async Task EdgeCases_Diagnostics_NullValues_DoNotCount() + { + EncryptionSettings settings = CreateSettingsWithInjected("nullProp", CreateDeterministicAlgorithm()); + + string json = "{\"id\":\"test\",\"nullProp\":null,\"other\":\"value\"}"; + + using (System.IO.Stream input = ToStream(json)) + { + EncryptionDiagnosticsContext diagEnc = new EncryptionDiagnosticsContext(); + System.IO.Stream encrypted = await EncryptionProcessor.EncryptAsync(input, settings, diagEnc, CancellationToken.None); + + // Current implementation increments the count when the property exists, even if value is null. + Assert.AreEqual(1, diagEnc.EncryptContent[Constants.DiagnosticsPropertiesEncryptedCount].Value()); + } + } + + #endregion + + #region Stream Edge Case Tests + + [TestMethod] + public async Task Streams_DecryptAsync_NullInput_ReturnsNull() + { + // Arrange + System.IO.Stream input = null; + EncryptionSettings settings = CreateSettingsWithNoProperties(); + + // Act + System.IO.Stream result = await EncryptionProcessor.DecryptAsync(input, settings, operationDiagnostics: null, cancellationToken: CancellationToken.None); + + // Assert + Assert.IsNull(result); + } + + [TestMethod] + public void Streams_BaseSerializer_ToStream_IsSeekable() + { + JObject obj = JObject.Parse("{ \"id\": \"1\" }"); + using (System.IO.Stream s = EncryptionProcessor.BaseSerializer.ToStream(obj)) + { + Assert.IsTrue(s.CanSeek, "BaseSerializer.ToStream should return a seekable stream."); + } + } + + [TestMethod] + public async Task Streams_EncryptAsync_ReturnsSeekableStream() + { + string json = "{\"id\":\"1\",\"p\":123}"; + EncryptionSettings settings = CreateSettingsWithNoProperties(); + using (System.IO.Stream input = ToStream(json)) + { + System.IO.Stream encrypted = await EncryptionProcessor.EncryptAsync(input, settings, operationDiagnostics: null, cancellationToken: CancellationToken.None); + using (encrypted) + { + Assert.IsTrue(encrypted.CanSeek, "EncryptAsync should return a seekable stream to satisfy downstream Debug.Assert invariants."); + } + } + } + + #endregion + } +} diff --git a/Microsoft.Azure.Cosmos.Encryption/tests/Microsoft.Azure.Cosmos.Encryption.Tests/EncryptionProcessorTests.Validation.cs b/Microsoft.Azure.Cosmos.Encryption/tests/Microsoft.Azure.Cosmos.Encryption.Tests/EncryptionProcessorTests.Validation.cs new file mode 100644 index 0000000000..43ce7e10ed --- /dev/null +++ b/Microsoft.Azure.Cosmos.Encryption/tests/Microsoft.Azure.Cosmos.Encryption.Tests/EncryptionProcessorTests.Validation.cs @@ -0,0 +1,258 @@ +//------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +//------------------------------------------------------------ + +namespace Microsoft.Azure.Cosmos.Encryption.Tests +{ + using System; + using System.IO; + using System.Text; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + using Newtonsoft.Json.Linq; + using Mde = Microsoft.Data.Encryption.Cryptography; + + /// + /// Validation tests for EncryptionProcessor including argument validation, + /// settings validation, unsupported types, and type markers. + /// + public partial class EncryptionProcessorTests + { + #region Argument Validation Tests + + [TestMethod] + public async Task Validation_EncryptAsync_NullInput_ThrowsArgumentNullException() + { + await Assert.ThrowsExceptionAsync( + () => EncryptionProcessor.EncryptAsync(input: null, encryptionSettings: CreateSettingsForId(), operationDiagnostics: null, cancellationToken: CancellationToken.None), "input"); + } + + [TestMethod] + public async Task Validation_EncryptAsync_NullSettings_ThrowsOrFailsPredictably() + { + using System.IO.MemoryStream input = ToStream("{\"id\":\"1\"}"); + try + { + await EncryptionProcessor.EncryptAsync(input: input, encryptionSettings: null, operationDiagnostics: null, cancellationToken: CancellationToken.None); + Assert.Fail("Expected an exception when encryptionSettings is null."); + } + catch (NullReferenceException) + { + // Current implementation: NRE when accessing PropertiesToEncrypt; acceptable documented behavior for now. + } + catch (ArgumentNullException) + { + // Future improvement may throw ANE; accept either to avoid test fragility. + } + } + + [TestMethod] + public async Task Validation_EncryptAsync_IdNonStringWithShouldEscape_ThrowsArgumentException() + { + // Arrange: id is an integer, settings configured to encrypt 'id' which triggers shouldEscape + EncryptionSettings settings = CreateSettingsForId(); + using System.IO.MemoryStream input = ToStream("{\"id\": 42, \"p\": 1}"); + + // Act & Assert + ArgumentException ex = await Assert.ThrowsExceptionAsync( + () => EncryptionProcessor.EncryptAsync(input, settings, operationDiagnostics: null, cancellationToken: CancellationToken.None)); + StringAssert.Contains(ex.Message, "value to escape has to be string type"); + } + + #endregion + + #region Settings Validation Tests + + private static EncryptionSettings CreateSettingsWithMissingMapping(params string[] properties) + { + // Create settings and only declare PropertiesToEncrypt via real mappings, then remove them + // to simulate missing mapping when traversing documents. + EncryptionSettings settings = new EncryptionSettings("rid", new System.Collections.Generic.List { "/id" }); + EncryptionContainer container = (EncryptionContainer)System.Runtime.Serialization.FormatterServices.GetUninitializedObject(typeof(EncryptionContainer)); + foreach (string p in properties) + { + EncryptionSettingForProperty forProperty = new EncryptionSettingForProperty( + clientEncryptionKeyId: "cek1", + encryptionType: Mde.EncryptionType.Randomized, + encryptionContainer: container, + databaseRid: "dbRid"); + settings.SetEncryptionSettingForProperty(p, forProperty); + } + + // Now clear the mapping dictionary via reflection to simulate Keys present but value missing. + System.Reflection.FieldInfo dictField = typeof(EncryptionSettings).GetField("encryptionSettingsDictByPropertyName", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!; + System.Collections.Generic.Dictionary dict = (System.Collections.Generic.Dictionary)dictField.GetValue(settings); + foreach (string p in properties) + { + dict[p] = null; + } + + return settings; + } + + [TestMethod] + public async Task Validation_EncryptAsync_PropertyWithoutSetting_Throws_And_DoesNotDisposeInput() + { + // Arrange: The item contains property 'foo', settings list 'foo' for encryption, but no mapping is configured. + using System.IO.MemoryStream input = ToStream("{\"id\":\"1\",\"foo\":123}"); + EncryptionSettings settings = CreateSettingsWithMissingMapping("foo"); + + // Act + try + { + await EncryptionProcessor.EncryptAsync(input, settings, operationDiagnostics: null, cancellationToken: CancellationToken.None); + Assert.Fail("Expected ArgumentException due to missing EncryptionSettingForProperty mapping."); + } + catch (ArgumentException ex) + { + StringAssert.Contains(ex.Message, "Invalid Encryption Setting for the Property:foo"); + } + + // Assert: The input was fully consumed. Some serializers may dispose the stream during read. + try + { + Assert.AreEqual(input.Length, input.Position, "Input stream position should be at end after failure."); + } + catch (ObjectDisposedException) + { + // Acceptable: FromStream may dispose the input stream after reading. + } + } + + [TestMethod] + public async Task Validation_DecryptAsync_PropertyWithoutSetting_Throws_And_DoesNotDisposeInput() + { + // Arrange: The document contains property 'bar', settings list 'bar' for encryption, but no mapping is configured. + using System.IO.MemoryStream input = ToStream("{\"id\":\"1\",\"bar\":\"someValue\"}"); + EncryptionSettings settings = CreateSettingsWithMissingMapping("bar"); + + // Act + try + { + await EncryptionProcessor.DecryptAsync(input, settings, operationDiagnostics: null, cancellationToken: CancellationToken.None); + Assert.Fail("Expected ArgumentException due to missing EncryptionSettingForProperty mapping."); + } + catch (ArgumentException ex) + { + StringAssert.Contains(ex.Message, "Invalid Encryption Setting for Property:bar"); + } + + // Assert: Input should NOT be disposed, and since it was fully read, position should be at end. + Assert.IsTrue(input.CanRead, "Input stream should not be disposed on failure."); + Assert.AreEqual(input.Length, input.Position, "Input stream position should be at end after failure."); + } + + #endregion + + #region Unsupported Types Tests + + [TestMethod] + public void Validation_Serialize_UnsupportedTypes_ShouldThrow_InvalidOperationException() + { + // Guid + Exception ex = null; + try { _ = EncryptionProcessor.Serialize(new JValue(Guid.NewGuid())); } + catch (Exception e) { ex = e; } + Assert.IsNotNull(ex); + Assert.IsInstanceOfType(ex, typeof(InvalidOperationException)); + + // Bytes + ex = null; + try { _ = EncryptionProcessor.Serialize(new JValue(new byte[] { 1, 2, 3, 4 })); } + catch (Exception e) { ex = e; } + Assert.IsNotNull(ex); + Assert.IsInstanceOfType(ex, typeof(InvalidOperationException)); + + // TimeSpan + ex = null; + try { _ = EncryptionProcessor.Serialize(new JValue(TimeSpan.FromMinutes(5))); } + catch (Exception e) { ex = e; } + Assert.IsNotNull(ex); + Assert.IsInstanceOfType(ex, typeof(InvalidOperationException)); + + // Uri (additional unsupported type) + ex = null; + try { _ = EncryptionProcessor.Serialize(new JValue(new Uri("https://example.com"))); } + catch (Exception e) { ex = e; } + Assert.IsNotNull(ex); + Assert.IsInstanceOfType(ex, typeof(InvalidOperationException)); + } + + [TestMethod] + public void Validation_Serialize_ShouldEscape_NonString_ShouldThrow_ArgumentException() + { + // shouldEscape path is enforced in SerializeAndEncryptValueAsync; use the public EncryptAsync with 'id' configured and non-string id. + EncryptionSettings settings = new EncryptionSettings("rid", new System.Collections.Generic.List { "/id" }); + EncryptionContainer container = (EncryptionContainer)System.Runtime.Serialization.FormatterServices.GetUninitializedObject(typeof(EncryptionContainer)); + EncryptionSettingForProperty forProperty = new EncryptionSettingForProperty( + clientEncryptionKeyId: "cek1", + encryptionType: Mde.EncryptionType.Deterministic, + encryptionContainer: container, + databaseRid: "dbRid"); + settings.SetEncryptionSettingForProperty("id", forProperty); + + using System.IO.MemoryStream s = new System.IO.MemoryStream(Encoding.UTF8.GetBytes("{\"id\":42}")); + ArgumentException ex = Assert.ThrowsExceptionAsync( + () => EncryptionProcessor.EncryptAsync(s, settings, operationDiagnostics: null, cancellationToken: default)).GetAwaiter().GetResult(); + StringAssert.Contains(ex.Message, "value to escape has to be string type"); + } + + #endregion + + #region Type Marker Tests + + + + [TestMethod] + public void TypeMarker_RoundTrips_For_All_Supported_Types() + { + // Boolean + (EncryptionProcessor.TypeMarker mBool, byte[] bBool) = EncryptionProcessor.Serialize(new JValue(true)); + Assert.AreEqual(EncryptionProcessor.TypeMarker.Boolean, mBool); + Assert.AreEqual(true, EncryptionProcessor.DeserializeAndAddProperty(bBool, mBool).Value()); + + // Double + (EncryptionProcessor.TypeMarker mDouble, byte[] bDouble) = EncryptionProcessor.Serialize(new JValue(3.14159)); + Assert.AreEqual(EncryptionProcessor.TypeMarker.Double, mDouble); + Assert.AreEqual(3.14159, EncryptionProcessor.DeserializeAndAddProperty(bDouble, mDouble).Value(), 0.0); + + // Long + (EncryptionProcessor.TypeMarker mLong, byte[] bLong) = EncryptionProcessor.Serialize(new JValue(42L)); + Assert.AreEqual(EncryptionProcessor.TypeMarker.Long, mLong); + Assert.AreEqual(42L, EncryptionProcessor.DeserializeAndAddProperty(bLong, mLong).Value()); + + // String + (EncryptionProcessor.TypeMarker mString, byte[] bString) = EncryptionProcessor.Serialize(new JValue("hello")); + Assert.AreEqual(EncryptionProcessor.TypeMarker.String, mString); + Assert.AreEqual("hello", EncryptionProcessor.DeserializeAndAddProperty(bString, mString).Value()); + } + + [TestMethod] + public async Task TypeMarker_Invalid_Or_Malformed_Cipher_Throws() + { + // Build real ciphertext first + Mde.AeadAes256CbcHmac256EncryptionAlgorithm algorithm = CreateDeterministicAlgorithm(); + EncryptionSettings settings = CreateSettingsWithInjected("p", algorithm); + EncryptionSettingForProperty propSetting = settings.GetEncryptionSettingForProperty("p"); + + // Encrypt a simple string with shouldEscape=false so we get byte[] token + using System.IO.Stream enc = await EncryptionProcessor.EncryptValueStreamAsync(ToStream("\"abc\""), propSetting, shouldEscape: false, cancellationToken: CancellationToken.None); + JToken token = EncryptionProcessor.BaseSerializer.FromStream(enc); + byte[] cipherWithMarker = token.ToObject(); + Assert.IsNotNull(cipherWithMarker); + Assert.IsTrue(cipherWithMarker.Length > 1); + + // Tamper the type marker to an invalid value (e.g., 0 which is not defined) + byte[] tampered = (byte[])cipherWithMarker.Clone(); + tampered[0] = 0; // invalid TypeMarker + + // Decrypt path should throw when DeserializeAndAddProperty sees invalid marker + JObject wrapper = new JObject { ["p"] = tampered }; + await Assert.ThrowsExceptionAsync( + () => EncryptionProcessor.DecryptJTokenAsync(wrapper["p"], propSetting, isEscaped: false, cancellationToken: CancellationToken.None)); + } + + #endregion + } +} diff --git a/Microsoft.Azure.Cosmos.Encryption/tests/Microsoft.Azure.Cosmos.Encryption.Tests/EncryptionProcessorTests.cs b/Microsoft.Azure.Cosmos.Encryption/tests/Microsoft.Azure.Cosmos.Encryption.Tests/EncryptionProcessorTests.cs new file mode 100644 index 0000000000..36d589db06 --- /dev/null +++ b/Microsoft.Azure.Cosmos.Encryption/tests/Microsoft.Azure.Cosmos.Encryption.Tests/EncryptionProcessorTests.cs @@ -0,0 +1,97 @@ +//------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +//------------------------------------------------------------ + +namespace Microsoft.Azure.Cosmos.Encryption.Tests +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Runtime.Serialization; + using System.Text; + using System.Threading; + using System.Threading.Tasks; + using Mde = Microsoft.Data.Encryption.Cryptography; + using Microsoft.VisualStudio.TestTools.UnitTesting; + using Newtonsoft.Json.Linq; + using Microsoft.Azure.Cosmos.Encryption.Tests.TestHelpers; + + /// + /// Comprehensive test suite for EncryptionProcessor functionality. + /// This class is split into multiple partial classes organized by test category for better maintainability. + /// + [TestClass] + public partial class EncryptionProcessorTests + { + #region Shared Test Utilities + + // Thin wrappers so existing partial classes can call these helpers. + protected static MemoryStream ToStream(string json) + { + return StreamTestHelpers.ToStream(json); + } + protected static string ReadToEnd(Stream s) + { + return StreamTestHelpers.ReadToEnd(s); + } + + private static EncryptionSettings CreateSettingsWithNoProperties() + { + // Use the internal constructor normally; leaving the mapping empty results in + // PropertiesToEncrypt being an empty enumeration (no encryption work performed). + return new EncryptionSettings("rid", new List { "/id" }); + } + + private static EncryptionSettings CreateSettingsForId() + { + EncryptionSettings settings = new EncryptionSettings("rid", new List { "/id" }); + // Use an uninitialized container; it won't be used in the failure paths these tests exercise. + EncryptionContainer container = (EncryptionContainer)FormatterServices.GetUninitializedObject(typeof(EncryptionContainer)); + EncryptionSettingForProperty forProperty = new EncryptionSettingForProperty( + clientEncryptionKeyId: "cek1", + encryptionType: Mde.EncryptionType.Deterministic, + encryptionContainer: container, + databaseRid: "dbRid"); + settings.SetEncryptionSettingForProperty("id", forProperty); + return settings; + } + + private static Mde.AeadAes256CbcHmac256EncryptionAlgorithm CreateDeterministicAlgorithm() + { + return TestCryptoHelpers.CreateAlgorithm(Mde.EncryptionType.Deterministic); + } + + private static EncryptionSettings CreateSettingsWithInjected(string propertyName, Mde.AeadAes256CbcHmac256EncryptionAlgorithm algorithm) + { + EncryptionSettings settings = new EncryptionSettings("rid", new List { "/id" }); + EncryptionContainer container = (EncryptionContainer)FormatterServices.GetUninitializedObject(typeof(EncryptionContainer)); + EncryptionSettingForProperty forProperty = new EncryptionSettingForProperty( + clientEncryptionKeyId: "cek1", + encryptionType: Mde.EncryptionType.Deterministic, + encryptionContainer: container, + databaseRid: "dbRid", + injectedAlgorithm: algorithm); + settings.SetEncryptionSettingForProperty(propertyName, forProperty); + return settings; + } + + private static EncryptionSettings CreateSettings(string prop, Mde.AeadAes256CbcHmac256EncryptionAlgorithm algo) + { + EncryptionSettings settings = new EncryptionSettings("rid", new List { "/id" }); + EncryptionContainer container = (EncryptionContainer)FormatterServices.GetUninitializedObject(typeof(EncryptionContainer)); + settings.SetEncryptionSettingForProperty(prop, new EncryptionSettingForProperty("cek1", Mde.EncryptionType.Deterministic, container, "dbRid", algo)); + return settings; + } + + private static Mde.AeadAes256CbcHmac256EncryptionAlgorithm Algo() + { + return TestCryptoHelpers.CreateAlgorithm(Mde.EncryptionType.Deterministic); + } + + // (Removed local KEK shim; rely on TestHelpers.TestCryptoHelpers instead.) + + #endregion + + // Documentation moved to XML comments and README. Removed no-op test. + } +} diff --git a/Microsoft.Azure.Cosmos.Encryption/tests/Microsoft.Azure.Cosmos.Encryption.Tests/Helpers/UnsafeAccessors.Net8.cs b/Microsoft.Azure.Cosmos.Encryption/tests/Microsoft.Azure.Cosmos.Encryption.Tests/Helpers/UnsafeAccessors.Net8.cs new file mode 100644 index 0000000000..e69de29bb2 diff --git a/Microsoft.Azure.Cosmos.Encryption/tests/Microsoft.Azure.Cosmos.Encryption.Tests/Microsoft.Azure.Cosmos.Encryption.Tests.csproj b/Microsoft.Azure.Cosmos.Encryption/tests/Microsoft.Azure.Cosmos.Encryption.Tests/Microsoft.Azure.Cosmos.Encryption.Tests.csproj index 7f03149a5b..b6f11003bf 100644 --- a/Microsoft.Azure.Cosmos.Encryption/tests/Microsoft.Azure.Cosmos.Encryption.Tests/Microsoft.Azure.Cosmos.Encryption.Tests.csproj +++ b/Microsoft.Azure.Cosmos.Encryption/tests/Microsoft.Azure.Cosmos.Encryption.Tests/Microsoft.Azure.Cosmos.Encryption.Tests.csproj @@ -21,6 +21,7 @@ + @@ -33,14 +34,6 @@ - - - - - - - - diff --git a/Microsoft.Azure.Cosmos.Encryption/tests/Microsoft.Azure.Cosmos.Encryption.Tests/TestHelpers/StreamTestHelpers.cs b/Microsoft.Azure.Cosmos.Encryption/tests/Microsoft.Azure.Cosmos.Encryption.Tests/TestHelpers/StreamTestHelpers.cs new file mode 100644 index 0000000000..ad6ebf1880 --- /dev/null +++ b/Microsoft.Azure.Cosmos.Encryption/tests/Microsoft.Azure.Cosmos.Encryption.Tests/TestHelpers/StreamTestHelpers.cs @@ -0,0 +1,79 @@ +//------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +//------------------------------------------------------------ + +namespace Microsoft.Azure.Cosmos.Encryption.Tests.TestHelpers +{ + using System; + using System.IO; + using System.Text; + + public static class StreamTestHelpers + { + public static MemoryStream ToStream(string json) + { + if (json is null) + { + throw new ArgumentNullException(nameof(json)); + } + + return new MemoryStream(Encoding.UTF8.GetBytes(json), writable: false); + } + + public static string ReadToEnd(Stream s) + { + if (s == null) + { + throw new ArgumentNullException(nameof(s)); + } + + if (s.CanSeek) + { + s.Position = 0; + using var sr = new StreamReader(s, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, bufferSize: 1024, leaveOpen: true); + return sr.ReadToEnd(); + } + + using var buffer = new MemoryStream(); + s.CopyTo(buffer); + buffer.Position = 0; + using var sr2 = new StreamReader(buffer, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, bufferSize: 1024, leaveOpen: false); + return sr2.ReadToEnd(); + } + + // Test-only wrapper to verify disposal explicitly + public sealed class TrackingStream : Stream + { + private readonly Stream inner; + + public TrackingStream(Stream inner) + { + this.inner = inner ?? throw new ArgumentNullException(nameof(inner)); + } + + public bool Disposed { get; private set; } + + public override bool CanRead => this.inner.CanRead; + public override bool CanSeek => this.inner.CanSeek; + public override bool CanWrite => this.inner.CanWrite; + public override long Length => this.inner.Length; + public override long Position { get => this.inner.Position; set => this.inner.Position = value; } + + public override void Flush() => this.inner.Flush(); + public override int Read(byte[] buffer, int offset, int count) => this.inner.Read(buffer, offset, count); + public override long Seek(long offset, SeekOrigin origin) => this.inner.Seek(offset, origin); + public override void SetLength(long value) => this.inner.SetLength(value); + public override void Write(byte[] buffer, int offset, int count) => this.inner.Write(buffer, offset, count); + + protected override void Dispose(bool disposing) + { + if (disposing && !this.Disposed) + { + this.Disposed = true; + this.inner.Dispose(); + } + base.Dispose(disposing); + } + } + } +} diff --git a/Microsoft.Azure.Cosmos.Encryption/tests/Microsoft.Azure.Cosmos.Encryption.Tests/TestHelpers/TestCryptoHelpers.cs b/Microsoft.Azure.Cosmos.Encryption/tests/Microsoft.Azure.Cosmos.Encryption.Tests/TestHelpers/TestCryptoHelpers.cs new file mode 100644 index 0000000000..3004f5f800 --- /dev/null +++ b/Microsoft.Azure.Cosmos.Encryption/tests/Microsoft.Azure.Cosmos.Encryption.Tests/TestHelpers/TestCryptoHelpers.cs @@ -0,0 +1,53 @@ +//------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +//------------------------------------------------------------ + +namespace Microsoft.Azure.Cosmos.Encryption.Tests.TestHelpers +{ + using System.Collections.Generic; + using Mde = Microsoft.Data.Encryption.Cryptography; + + internal static class TestCryptoHelpers + { + public class DummyKeyEncryptionKey : Mde.KeyEncryptionKey + { + public DummyKeyEncryptionKey() : base(name: "testKek", path: "test://kek", keyStoreProvider: new DummyProvider()) { } + + private class DummyProvider : Mde.EncryptionKeyStoreProvider + { + public override string ProviderName => "testProvider"; + public override byte[] UnwrapKey(string encryptionKeyId, Mde.KeyEncryptionKeyAlgorithm algorithm, byte[] encryptedKey) => encryptedKey; + public override byte[] WrapKey(string encryptionKeyId, Mde.KeyEncryptionKeyAlgorithm algorithm, byte[] key) => key; + public override byte[] Sign(string encryptionKeyId, bool allowEnclaveComputations) => new byte[] { 1, 2, 3 }; + public override bool Verify(string encryptionKeyId, bool allowEnclaveComputations, byte[] signature) => true; + } + } + + public static Mde.AeadAes256CbcHmac256EncryptionAlgorithm CreateAlgorithm(Mde.EncryptionType type) + { + var kek = new DummyKeyEncryptionKey(); + var pdek = new Mde.ProtectedDataEncryptionKey("pdek-" + type.ToString().ToLowerInvariant(), kek); + return new Mde.AeadAes256CbcHmac256EncryptionAlgorithm(pdek, type); + } + + public static EncryptionSettings CreateSettingsWithInjected(string propertyName, Mde.EncryptionType type) + { + var algo = CreateAlgorithm(type); + return CreateSettingsWithInjected(propertyName, type, algo); + } + + public static EncryptionSettings CreateSettingsWithInjected(string propertyName, Mde.EncryptionType type, Mde.AeadAes256CbcHmac256EncryptionAlgorithm algo) + { + var settings = new EncryptionSettings("rid", new List { "/id" }); + var container = (EncryptionContainer)System.Runtime.Serialization.FormatterServices.GetUninitializedObject(typeof(EncryptionContainer)); + var forProperty = new EncryptionSettingForProperty( + clientEncryptionKeyId: "cek1", + encryptionType: type, + encryptionContainer: container, + databaseRid: "dbRid", + injectedAlgorithm: algo); + settings.SetEncryptionSettingForProperty(propertyName, forProperty); + return settings; + } + } +}