diff --git a/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/SecureConfigProvider.cs b/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/SecureConfigProvider.cs new file mode 100644 index 0000000..3c1a635 --- /dev/null +++ b/src/StevanFreeborn.Extensions.Configuration.Secure/Configuration/SecureConfigProvider.cs @@ -0,0 +1,87 @@ +using System.Globalization; +using System.Text.Json; + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +using StevanFreeborn.Extensions.Configuration.Secure.Cryptography; +using StevanFreeborn.Extensions.Configuration.Secure.Storage; + +namespace StevanFreeborn.Extensions.Configuration.Secure.Configuration; + +internal class SecureConfigProvider( + ISecureStorageProvider storageProvider, + ICryptoProvider cryptoProvider, + ILogger logger +) : ConfigurationProvider +{ + private readonly ISecureStorageProvider _storageProvider = storageProvider ?? throw new ArgumentNullException(nameof(storageProvider)); + private readonly ICryptoProvider _cryptoProvider = cryptoProvider ?? throw new ArgumentNullException(nameof(cryptoProvider)); + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(cryptoProvider)); + + public override void Load() + { + var encryptedData = _storageProvider.ReadAllAsync().GetAwaiter().GetResult(); + var flattenedData = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var kvp in encryptedData) + { + try + { + var decryptedJson = _cryptoProvider.Decrypt(kvp.Value); + + using var document = JsonDocument.Parse(decryptedJson); + FlattenJsonElement(flattenedData, document.RootElement, kvp.Key); + } +#pragma warning disable CA1031 // Do not catch general exception types + catch (Exception ex) +#pragma warning restore CA1031 // Do not catch general exception types + { + _logger.LogDecryptionFailure(ex, kvp.Key); + } + } + + Data = flattenedData; + } + + private static void FlattenJsonElement(IDictionary data, JsonElement element, string currentKey) + { + switch (element.ValueKind) + { + case JsonValueKind.Object: + foreach (var property in element.EnumerateObject()) + { + var newKey = string.IsNullOrEmpty(currentKey) ? property.Name : ConfigurationPath.Combine(currentKey, property.Name); + FlattenJsonElement(data, property.Value, newKey); + } + break; + case JsonValueKind.Array: + var index = 0; + foreach (var arrayElement in element.EnumerateArray()) + { + var newKey = ConfigurationPath.Combine(currentKey, index.ToString(CultureInfo.InvariantCulture)); + FlattenJsonElement(data, arrayElement, newKey); + index++; + } + break; + case JsonValueKind.String: + data[currentKey] = element.GetString(); + break; + case JsonValueKind.Number: + data[currentKey] = element.GetRawText(); + break; + case JsonValueKind.True: + data[currentKey] = "true"; + break; + case JsonValueKind.False: + data[currentKey] = "false"; + break; + case JsonValueKind.Null: + case JsonValueKind.Undefined: + default: + data[currentKey] = null; + break; + } + } +} + diff --git a/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Configuration/SecureConfigProviderTests.cs b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Configuration/SecureConfigProviderTests.cs new file mode 100644 index 0000000..cb3c152 --- /dev/null +++ b/tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/Unit/Configuration/SecureConfigProviderTests.cs @@ -0,0 +1,514 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +using Moq; + +using StevanFreeborn.Extensions.Configuration.Secure.Configuration; +using StevanFreeborn.Extensions.Configuration.Secure.Cryptography; +using StevanFreeborn.Extensions.Configuration.Secure.Storage; + +namespace StevanFreeborn.Extensions.Configuration.Secure.Tests.Unit.Configuration; + +public class SecureConfigProviderTests +{ + private readonly Mock _mockStorage = new(); + private readonly Mock _mockCrypto = new(); + private readonly Mock> _mockLogger = new(); + private readonly SecureConfigProvider _sut; + + public SecureConfigProviderTests() + { + _sut = new(_mockStorage.Object, _mockCrypto.Object, _mockLogger.Object); + } + + private static string Key(params string[] segments) => ConfigurationPath.Combine(segments); + + [Fact] + public void Load_WhenCalled_ItShouldReadDecryptAndFlattenJsonData() + { + var rootKey = "DatabaseOptions"; + var rawJson = @"{ ""Host"": ""localhost"", ""Port"": 5432 }"; + var encryptedString = "encrypted_payload"; + + var storedData = new Dictionary + { + { rootKey, encryptedString }, + }; + + _mockStorage.Setup(m => m.ReadAllAsync(It.IsAny())).ReturnsAsync(storedData); + _mockCrypto.Setup(m => m.Decrypt(encryptedString)).Returns(rawJson); + + _sut.Load(); + + _sut.TryGet(Key("DatabaseOptions", "Host"), out var hostVal).Should().BeTrue(); + hostVal.Should().Be("localhost"); + + _sut.TryGet(Key("DatabaseOptions", "Port"), out var portVal).Should().BeTrue(); + portVal.Should().Be("5432"); + } + + [Fact] + public void Load_WhenStorageIsEmpty_ItShouldNotThrow() + { + _mockStorage.Setup(s => s.ReadAllAsync(It.IsAny())).ReturnsAsync(new Dictionary()); + + var act = _sut.Load; + act.Should().NotThrow(); + } + + [Fact] + public void Load_WhenDecryptionFails_ItShouldLogErrorAndContinue() + { + var storedData = new Dictionary + { + { "ValidKey", "encrypted_valid" }, + { "BadKey", "encrypted_bad" }, + }; + + _mockLogger.Setup(m => m.IsEnabled(LogLevel.Error)).Returns(true); + _mockStorage.Setup(m => m.ReadAllAsync(It.IsAny())).ReturnsAsync(storedData); + _mockCrypto.Setup(m => m.Decrypt("encrypted_valid")).Returns(@"{ ""Name"": ""test"" }"); + _mockCrypto.Setup(m => m.Decrypt("encrypted_bad")).Throws(new InvalidOperationException("Decryption failed")); + + _sut.Load(); + + _sut.TryGet(Key("ValidKey", "Name"), out var val).Should().BeTrue(); + val.Should().Be("test"); + + _mockLogger.Verify(logger => logger.Log( + LogLevel.Error, + It.Is(id => id.Id == 3), + It.Is((state, type) => state.ToString()!.Contains("Failed to decrypt value for key")), + It.IsAny(), + It.IsAny>() + ), + Times.Once() + ); + } + + [Fact] + public void Load_WhenCalledWithDeeplyNestedObject_ItShouldFlattenCorrectly() + { + var rawJson = @"{ + ""Level1"": { + ""Level2"": { + ""Level3"": { + ""Value"": ""deep_value"" + } + } + } + }"; + + _mockStorage.Setup(m => m.ReadAllAsync(It.IsAny())).ReturnsAsync(new Dictionary { { "Root", "enc" } }); + _mockCrypto.Setup(m => m.Decrypt("enc")).Returns(rawJson); + + _sut.Load(); + + _sut.TryGet(Key("Root", "Level1", "Level2", "Level3", "Value"), out var val).Should().BeTrue(); + val.Should().Be("deep_value"); + } + + [Fact] + public void Load_WhenCalledWithArrayOfPrimitives_ItShouldFlattenWithIndices() + { + var rawJson = @"{ ""Tags"": [""alpha"", ""beta"", ""gamma""] }"; + + _mockStorage.Setup(m => m.ReadAllAsync(It.IsAny())).ReturnsAsync(new Dictionary { { "Config", "enc" } }); + _mockCrypto.Setup(m => m.Decrypt("enc")).Returns(rawJson); + + _sut.Load(); + + _sut.TryGet(Key("Config", "Tags", "0"), out var v0).Should().BeTrue(); + v0.Should().Be("alpha"); + + _sut.TryGet(Key("Config", "Tags", "1"), out var v1).Should().BeTrue(); + v1.Should().Be("beta"); + + _sut.TryGet(Key("Config", "Tags", "2"), out var v2).Should().BeTrue(); + v2.Should().Be("gamma"); + } + + [Fact] + public void Load_WhenCalledWithArrayOfObjects_ItShouldFlattenCorrectly() + { + var rawJson = @"{ + ""Servers"": [ + { ""Host"": ""srv1"", ""Port"": 8080 }, + { ""Host"": ""srv2"", ""Port"": 9090 } + ] + }"; + + _mockStorage.Setup(m => m.ReadAllAsync(It.IsAny())).ReturnsAsync(new Dictionary { { "App", "enc" } }); + _mockCrypto.Setup(m => m.Decrypt("enc")).Returns(rawJson); + + _sut.Load(); + + _sut.TryGet(Key("App", "Servers", "0", "Host"), out var h0).Should().BeTrue(); + h0.Should().Be("srv1"); + + _sut.TryGet(Key("App", "Servers", "0", "Port"), out var p0).Should().BeTrue(); + p0.Should().Be("8080"); + + _sut.TryGet(Key("App", "Servers", "1", "Host"), out var h1).Should().BeTrue(); + h1.Should().Be("srv2"); + + _sut.TryGet(Key("App", "Servers", "1", "Port"), out var p1).Should().BeTrue(); + p1.Should().Be("9090"); + } + + [Fact] + public void Load_WhenCalledWithNestedArrays_ItShouldFlattenCorrectly() + { + var rawJson = @"{ ""Matrix"": [[1, 2], [3, 4]] }"; + + _mockStorage.Setup(m => m.ReadAllAsync(It.IsAny())).ReturnsAsync(new Dictionary { { "Data", "enc" } }); + _mockCrypto.Setup(m => m.Decrypt("enc")).Returns(rawJson); + + _sut.Load(); + + _sut.TryGet(Key("Data", "Matrix", "0", "0"), out var v00).Should().BeTrue(); + v00.Should().Be("1"); + + _sut.TryGet(Key("Data", "Matrix", "0", "1"), out var v01).Should().BeTrue(); + v01.Should().Be("2"); + + _sut.TryGet(Key("Data", "Matrix", "1", "0"), out var v10).Should().BeTrue(); + v10.Should().Be("3"); + + _sut.TryGet(Key("Data", "Matrix", "1", "1"), out var v11).Should().BeTrue(); + v11.Should().Be("4"); + } + + [Fact] + public void Load_WhenCalledWithMixedArrayTypes_ItShouldFlattenCorrectly() + { + var rawJson = @"{ + ""Mixed"": [ + ""string_val"", + 42, + true, + null, + { ""Nested"": ""obj"" } + ] + }"; + + _mockStorage.Setup(m => m.ReadAllAsync(It.IsAny())).ReturnsAsync(new Dictionary { { "Cfg", "enc" } }); + _mockCrypto.Setup(m => m.Decrypt("enc")).Returns(rawJson); + + _sut.Load(); + + _sut.TryGet(Key("Cfg", "Mixed", "0"), out var s).Should().BeTrue(); + s.Should().Be("string_val"); + + _sut.TryGet(Key("Cfg", "Mixed", "1"), out var n).Should().BeTrue(); + n.Should().Be("42"); + + _sut.TryGet(Key("Cfg", "Mixed", "2"), out var b).Should().BeTrue(); + b.Should().Be("true"); + + _sut.TryGet(Key("Cfg", "Mixed", "3"), out var nl).Should().BeTrue(); + nl.Should().BeNull(); + + _sut.TryGet(Key("Cfg", "Mixed", "4", "Nested"), out var o).Should().BeTrue(); + o.Should().Be("obj"); + } + + [Fact] + public void Load_WhenCalledWithBooleanValues_ItShouldFlattenAsStrings() + { + var rawJson = @"{ ""Enabled"": true, ""Disabled"": false }"; + + _mockStorage.Setup(m => m.ReadAllAsync(It.IsAny())).ReturnsAsync(new Dictionary { { "Flags", "enc" } }); + _mockCrypto.Setup(m => m.Decrypt("enc")).Returns(rawJson); + + _sut.Load(); + + _sut.TryGet(Key("Flags", "Enabled"), out var en).Should().BeTrue(); + en.Should().Be("true"); + + _sut.TryGet(Key("Flags", "Disabled"), out var dis).Should().BeTrue(); + dis.Should().Be("false"); + } + + [Fact] + public void Load_WhenCalledWithNullValue_ItShouldFlattenAsNull() + { + var rawJson = @"{ ""NullableField"": null }"; + + _mockStorage.Setup(m => m.ReadAllAsync(It.IsAny())).ReturnsAsync(new Dictionary { { "Opts", "enc" } }); + _mockCrypto.Setup(m => m.Decrypt("enc")).Returns(rawJson); + + _sut.Load(); + + _sut.TryGet(Key("Opts", "NullableField"), out var val).Should().BeTrue(); + val.Should().BeNull(); + } + + [Fact] + public void Load_WhenCalledWithNumberFormats_ItShouldPreserveRawText() + { + var rawJson = @"{ + ""Integer"": 42, + ""Float"": 3.14, + ""Negative"": -7, + ""Scientific"": 1.5e10, + ""Zero"": 0 + }"; + + _mockStorage.Setup(m => m.ReadAllAsync(It.IsAny())).ReturnsAsync(new Dictionary { { "Nums", "enc" } }); + _mockCrypto.Setup(m => m.Decrypt("enc")).Returns(rawJson); + + _sut.Load(); + + _sut.TryGet(Key("Nums", "Integer"), out var i).Should().BeTrue(); + i.Should().Be("42"); + + _sut.TryGet(Key("Nums", "Float"), out var f).Should().BeTrue(); + f.Should().Be("3.14"); + + _sut.TryGet(Key("Nums", "Negative"), out var n).Should().BeTrue(); + n.Should().Be("-7"); + + _sut.TryGet(Key("Nums", "Scientific"), out var s).Should().BeTrue(); + s.Should().Be("1.5e10"); + + _sut.TryGet(Key("Nums", "Zero"), out var z).Should().BeTrue(); + z.Should().Be("0"); + } + + [Fact] + public void Load_WhenCalledWithEmptyObject_ItShouldNotAddKeys() + { + var rawJson = @"{ ""Empty"": {} }"; + + _mockStorage.Setup(m => m.ReadAllAsync(It.IsAny())).ReturnsAsync(new Dictionary { { "Cfg", "enc" } }); + _mockCrypto.Setup(m => m.Decrypt("enc")).Returns(rawJson); + + _sut.Load(); + + _sut.TryGet(Key("Cfg", "Empty"), out _).Should().BeFalse(); + } + + [Fact] + public void Load_WhenCalledWithEmptyArray_ItShouldNotAddKeys() + { + var rawJson = @"{ ""Empty"": [] }"; + + _mockStorage.Setup(m => m.ReadAllAsync(It.IsAny())).ReturnsAsync(new Dictionary { { "Cfg", "enc" } }); + _mockCrypto.Setup(m => m.Decrypt("enc")).Returns(rawJson); + + _sut.Load(); + + _sut.TryGet(Key("Cfg", "Empty"), out _).Should().BeFalse(); + } + + [Fact] + public void Load_WhenCalledWithEmptyString_ItShouldFlattenAsEmptyString() + { + var rawJson = @"{ ""Blank"": """" }"; + + _mockStorage.Setup(m => m.ReadAllAsync(It.IsAny())).ReturnsAsync(new Dictionary { { "Cfg", "enc" } }); + _mockCrypto.Setup(m => m.Decrypt("enc")).Returns(rawJson); + + _sut.Load(); + + _sut.TryGet(Key("Cfg", "Blank"), out var val).Should().BeTrue(); + val.Should().Be(""); + } + + [Fact] + public void Load_WhenCalledWithMultipleEncryptedKeys_ItShouldMergeData() + { + _mockStorage.Setup(m => m.ReadAllAsync(It.IsAny())).ReturnsAsync(new Dictionary + { + { "Db", "enc1" }, + { "Cache", "enc2" }, + }); + _mockCrypto.Setup(m => m.Decrypt("enc1")).Returns(@"{ ""Host"": ""db.local"" }"); + _mockCrypto.Setup(m => m.Decrypt("enc2")).Returns(@"{ ""Host"": ""cache.local"", ""Ttl"": 300 }"); + + _sut.Load(); + + _sut.TryGet(Key("Db", "Host"), out var dbHost).Should().BeTrue(); + dbHost.Should().Be("db.local"); + + _sut.TryGet(Key("Cache", "Host"), out var cacheHost).Should().BeTrue(); + cacheHost.Should().Be("cache.local"); + + _sut.TryGet(Key("Cache", "Ttl"), out var ttl).Should().BeTrue(); + ttl.Should().Be("300"); + } + + [Fact] + public void Load_WhenCalledWithCaseInsensitiveKeys_ItShouldRetrieveCorrectly() + { + var rawJson = @"{ ""MyKey"": ""value"" }"; + + _mockStorage.Setup(m => m.ReadAllAsync(It.IsAny())).ReturnsAsync(new Dictionary { { "Cfg", "enc" } }); + _mockCrypto.Setup(m => m.Decrypt("enc")).Returns(rawJson); + + _sut.Load(); + + _sut.TryGet(Key("cfg", "mykey"), out var val).Should().BeTrue(); + val.Should().Be("value"); + + _sut.TryGet(Key("CFG", "MYKEY"), out val).Should().BeTrue(); + val.Should().Be("value"); + } + + [Fact] + public void Load_WhenCalledWithComplexNestedStructure_ItShouldFlattenCorrectly() + { + var rawJson = @"{ + ""App"": { + ""Name"": ""MyApp"", + ""Version"": ""1.0.0"", + ""Features"": { + ""EnableLogging"": true, + ""LogLevel"": ""Debug"", + ""Targets"": [""Console"", ""File""] + }, + ""Database"": { + ""Primary"": { + ""ConnectionString"": ""Server=db1;Database=app"", + ""PoolSize"": 10, + ""Replicas"": [ + { ""Host"": ""replica1"", ""Port"": 5432, ""Active"": true }, + { ""Host"": ""replica2"", ""Port"": 5433, ""Active"": false } + ] + }, + ""ReadOnly"": { + ""ConnectionString"": ""Server=db2;Database=app_ro"", + ""PoolSize"": 5 + } + }, + ""Metadata"": null, + ""Tags"": [""prod"", ""v1"", { ""Region"": ""us-east"" }] + } + }"; + + _mockStorage.Setup(m => m.ReadAllAsync(It.IsAny())).ReturnsAsync(new Dictionary { { "Root", "enc" } }); + _mockCrypto.Setup(m => m.Decrypt("enc")).Returns(rawJson); + + _sut.Load(); + + _sut.TryGet(Key("Root", "App", "Name"), out var name).Should().BeTrue(); + name.Should().Be("MyApp"); + + _sut.TryGet(Key("Root", "App", "Version"), out var ver).Should().BeTrue(); + ver.Should().Be("1.0.0"); + + _sut.TryGet(Key("Root", "App", "Features", "EnableLogging"), out var log).Should().BeTrue(); + log.Should().Be("true"); + + _sut.TryGet(Key("Root", "App", "Features", "LogLevel"), out var lvl).Should().BeTrue(); + lvl.Should().Be("Debug"); + + _sut.TryGet(Key("Root", "App", "Features", "Targets", "0"), out var t0).Should().BeTrue(); + t0.Should().Be("Console"); + + _sut.TryGet(Key("Root", "App", "Features", "Targets", "1"), out var t1).Should().BeTrue(); + t1.Should().Be("File"); + + _sut.TryGet(Key("Root", "App", "Database", "Primary", "ConnectionString"), out var cs).Should().BeTrue(); + cs.Should().Be("Server=db1;Database=app"); + + _sut.TryGet(Key("Root", "App", "Database", "Primary", "PoolSize"), out var ps).Should().BeTrue(); + ps.Should().Be("10"); + + _sut.TryGet(Key("Root", "App", "Database", "Primary", "Replicas", "0", "Host"), out var rh0).Should().BeTrue(); + rh0.Should().Be("replica1"); + + _sut.TryGet(Key("Root", "App", "Database", "Primary", "Replicas", "0", "Port"), out var rp0).Should().BeTrue(); + rp0.Should().Be("5432"); + + _sut.TryGet(Key("Root", "App", "Database", "Primary", "Replicas", "0", "Active"), out var ra0).Should().BeTrue(); + ra0.Should().Be("true"); + + _sut.TryGet(Key("Root", "App", "Database", "Primary", "Replicas", "1", "Host"), out var rh1).Should().BeTrue(); + rh1.Should().Be("replica2"); + + _sut.TryGet(Key("Root", "App", "Database", "Primary", "Replicas", "1", "Active"), out var ra1).Should().BeTrue(); + ra1.Should().Be("false"); + + _sut.TryGet(Key("Root", "App", "Database", "ReadOnly", "ConnectionString"), out var csRo).Should().BeTrue(); + csRo.Should().Be("Server=db2;Database=app_ro"); + + _sut.TryGet(Key("Root", "App", "Database", "ReadOnly", "PoolSize"), out var psRo).Should().BeTrue(); + psRo.Should().Be("5"); + + _sut.TryGet(Key("Root", "App", "Metadata"), out var meta).Should().BeTrue(); + meta.Should().BeNull(); + + _sut.TryGet(Key("Root", "App", "Tags", "0"), out var tag0).Should().BeTrue(); + tag0.Should().Be("prod"); + + _sut.TryGet(Key("Root", "App", "Tags", "1"), out var tag1).Should().BeTrue(); + tag1.Should().Be("v1"); + + _sut.TryGet(Key("Root", "App", "Tags", "2", "Region"), out var region).Should().BeTrue(); + region.Should().Be("us-east"); + } + + [Fact] + public void Load_WhenCalledWithSpecialCharactersInStrings_ItShouldPreserveContent() + { + var rawJson = @"{ + ""Connection"": ""Server=localhost;Database=test;User=admin;Password=p@$$w0rd!"", + ""Path"": ""C:\\Program Files\\App\\config.json"", + ""JsonInString"": ""{\""inner\"": \""value\""}"", + ""Unicode"": ""Hello \u4e16\u754c"" + }"; + + _mockStorage.Setup(m => m.ReadAllAsync(It.IsAny())).ReturnsAsync(new Dictionary { { "Cfg", "enc" } }); + _mockCrypto.Setup(m => m.Decrypt("enc")).Returns(rawJson); + + _sut.Load(); + + _sut.TryGet(Key("Cfg", "Connection"), out var conn).Should().BeTrue(); + conn.Should().Be("Server=localhost;Database=test;User=admin;Password=p@$$w0rd!"); + + _sut.TryGet(Key("Cfg", "Path"), out var path).Should().BeTrue(); + path.Should().Be("C:\\Program Files\\App\\config.json"); + + _sut.TryGet(Key("Cfg", "JsonInString"), out var jis).Should().BeTrue(); + jis.Should().Be("{\"inner\": \"value\"}"); + + _sut.TryGet(Key("Cfg", "Unicode"), out var uni).Should().BeTrue(); + uni.Should().Be("Hello 世界"); + } + + [Fact] + public void Load_WhenCalledWithArrayOfEmptyObjects_ItShouldNotAddKeys() + { + var rawJson = @"{ ""Items"": [{}, {}] }"; + + _mockStorage.Setup(m => m.ReadAllAsync(It.IsAny())).ReturnsAsync(new Dictionary { { "Cfg", "enc" } }); + _mockCrypto.Setup(m => m.Decrypt("enc")).Returns(rawJson); + + _sut.Load(); + + _sut.TryGet(Key("Cfg", "Items", "0"), out _).Should().BeFalse(); + _sut.TryGet(Key("Cfg", "Items", "1"), out _).Should().BeFalse(); + } + + [Fact] + public void Load_WhenCalledWithInvalidJson_ItShouldLogErrorAndContinue() + { + _mockLogger.Setup(m => m.IsEnabled(LogLevel.Error)).Returns(true); + _mockStorage.Setup(m => m.ReadAllAsync(It.IsAny())).ReturnsAsync(new Dictionary { { "Bad", "enc" } }); + _mockCrypto.Setup(m => m.Decrypt("enc")).Returns("not valid json{{{"); + + _sut.Load(); + + _mockLogger.Verify(x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, _) => v.ToString()!.Contains("Bad")), + It.IsAny(), + It.IsAny>() + ), + Times.Once() + ); + } +}