Merge pull request 'feat: initial library implementation' (#1) from stevanfreeborn/feat/initial-implementation into main
This commit is contained in:
+9
-6
@@ -25,8 +25,8 @@ indent_size = 2
|
||||
#### Core EditorConfig Options ####
|
||||
|
||||
# Indentation and spacing
|
||||
indent_size = 4
|
||||
tab_width = 4
|
||||
indent_size = 2
|
||||
tab_width = 2
|
||||
|
||||
# New line preferences
|
||||
insert_final_newline = false
|
||||
@@ -70,7 +70,7 @@ dotnet_style_prefer_auto_properties = true:suggestion
|
||||
dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion
|
||||
dotnet_style_prefer_compound_assignment = true:suggestion
|
||||
dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion
|
||||
dotnet_style_prefer_conditional_expression_over_return = true:suggestion
|
||||
dotnet_style_prefer_conditional_expression_over_return = false:silent;
|
||||
dotnet_style_prefer_foreach_explicit_cast_in_source = when_strongly_typed:suggestion
|
||||
dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion
|
||||
dotnet_style_prefer_inferred_tuple_names = true:suggestion
|
||||
@@ -90,10 +90,13 @@ dotnet_remove_unnecessary_suppression_exclusions = none
|
||||
#### C# Coding Conventions ####
|
||||
[*.cs]
|
||||
|
||||
dotnet_diagnostic.IDE0058.severity = none
|
||||
dotnet_diagnostic.IDE0100.severity = none
|
||||
|
||||
# var preferences
|
||||
csharp_style_var_elsewhere = false:silent
|
||||
csharp_style_var_for_built_in_types = false:silent
|
||||
csharp_style_var_when_type_is_apparent = false:silent
|
||||
csharp_style_var_elsewhere = true:suggestion
|
||||
csharp_style_var_for_built_in_types = true:suggestion
|
||||
csharp_style_var_when_type_is_apparent = true:suggestion
|
||||
|
||||
# Expression-bodied members
|
||||
csharp_style_expression_bodied_accessors = true:silent
|
||||
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"cSpell.words": [
|
||||
"ioreg",
|
||||
"netstandard"
|
||||
]
|
||||
}
|
||||
@@ -1,2 +1,9 @@
|
||||
<Solution>
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/StevanFreeborn.Extensions.Configuration.Secure.Sample/StevanFreeborn.Extensions.Configuration.Secure.Sample.csproj" />
|
||||
<Project Path="src/StevanFreeborn.Extensions.Configuration.Secure/StevanFreeborn.Extensions.Configuration.Secure.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/StevanFreeborn.Extensions.Configuration.Secure.Tests/StevanFreeborn.Extensions.Configuration.Secure.Tests.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "10.0.201",
|
||||
"rollForward": "latestFeature"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace StevanFreeborn.Extensions.Configuration.Secure.Sample;
|
||||
|
||||
public sealed record ApiOptions
|
||||
{
|
||||
public string ApiKey { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StevanFreeborn.Extensions.Configuration.Secure.Sample;
|
||||
|
||||
[JsonSerializable(typeof(ApiOptions))]
|
||||
[JsonSerializable(typeof(DatabaseSettings))]
|
||||
[JsonSerializable(typeof(SmtpSettings))]
|
||||
internal partial class AppJsonContext : JsonSerializerContext
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace StevanFreeborn.Extensions.Configuration.Secure.Sample;
|
||||
|
||||
public sealed record DatabaseSettings
|
||||
{
|
||||
public string ConnectionString { get; init; } = string.Empty;
|
||||
public int Timeout { get; init; }
|
||||
public int RetryCount { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
using StevanFreeborn.Extensions.Configuration.Secure;
|
||||
using StevanFreeborn.Extensions.Configuration.Secure.Configuration;
|
||||
using StevanFreeborn.Extensions.Configuration.Secure.Cryptography;
|
||||
using StevanFreeborn.Extensions.Configuration.Secure.Sample;
|
||||
using StevanFreeborn.Extensions.Configuration.Secure.Storage;
|
||||
|
||||
Console.WriteLine("╔══════════════════════════════════════════════════════════╗");
|
||||
Console.WriteLine("║ StevanFreeborn.Extensions.Configuration.Secure Sample ║");
|
||||
Console.WriteLine("╚══════════════════════════════════════════════════════════╝");
|
||||
Console.WriteLine();
|
||||
|
||||
await Demo1_BasicFileStorageWithBase64Key();
|
||||
await Demo2_MachineIdKeyDerivation();
|
||||
await Demo3_HostAndIOptionsIntegration();
|
||||
await Demo4_ConfigurationProviderPattern();
|
||||
await Demo5_CustomStorageProvider();
|
||||
await Demo6_CustomKeyProvider();
|
||||
await Demo7_TypedAOTOverloads();
|
||||
|
||||
Console.WriteLine("All demos completed.");
|
||||
|
||||
static async Task Demo1_BasicFileStorageWithBase64Key()
|
||||
{
|
||||
PrintHeader("Demo 1: Basic File Storage with Base64 Key");
|
||||
|
||||
using var tempDir = new TempDirectory();
|
||||
var key = GenerateBase64Key();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddSecureConfig(builder =>
|
||||
{
|
||||
builder
|
||||
.WithBase64EncryptionKey(key)
|
||||
.WithAesCryptoProvider()
|
||||
.UseJsonFileStorage(new JsonStorageOptions
|
||||
{
|
||||
DirectoryPath = tempDir.Path,
|
||||
FileName = "demo1.json",
|
||||
})
|
||||
.AddJsonAotContext(AppJsonContext.Default);
|
||||
});
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
var secureConfig = provider.GetRequiredService<ISecureConfig>();
|
||||
|
||||
var dbSettings = new DatabaseSettings
|
||||
{
|
||||
ConnectionString = "Server=localhost;Database=MyDb;Trusted_Connection=True;",
|
||||
Timeout = 30,
|
||||
RetryCount = 3,
|
||||
};
|
||||
|
||||
Console.WriteLine(" Storing DatabaseSettings...");
|
||||
await secureConfig.SetAsync("Database", dbSettings);
|
||||
|
||||
var retrieved = await secureConfig.GetAsync<DatabaseSettings>("Database");
|
||||
Console.WriteLine($" Retrieved: ConnectionString={retrieved!.ConnectionString}");
|
||||
Console.WriteLine($" Retrieved: Timeout={retrieved.Timeout}, RetryCount={retrieved.RetryCount}");
|
||||
|
||||
var smtpSettings = new SmtpSettings
|
||||
{
|
||||
Host = "smtp.example.com",
|
||||
Port = 587,
|
||||
Username = "user@example.com",
|
||||
Password = "s3cretP@ssw0rd!",
|
||||
UseSsl = true,
|
||||
};
|
||||
|
||||
Console.WriteLine(" Storing SmtpSettings (sensitive data)...");
|
||||
await secureConfig.SetAsync("Smtp", smtpSettings);
|
||||
|
||||
var smtp = await secureConfig.GetAsync<SmtpSettings>("Smtp");
|
||||
Console.WriteLine($" Retrieved: Host={smtp!.Host}, Port={smtp.Port}, UseSsl={smtp.UseSsl}");
|
||||
Console.WriteLine($" Retrieved: Username={smtp.Username}, Password={smtp.Password}");
|
||||
|
||||
Console.WriteLine(" Deleting Smtp settings...");
|
||||
var deleted = await secureConfig.DeleteAsync("Smtp");
|
||||
Console.WriteLine($" Deleted: {deleted}");
|
||||
|
||||
var missing = await secureConfig.GetAsync<SmtpSettings>("Smtp");
|
||||
Console.WriteLine($" After delete: {(missing is null ? "null (as expected)" : "still present")}");
|
||||
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
static async Task Demo2_MachineIdKeyDerivation()
|
||||
{
|
||||
PrintHeader("Demo 2: Machine ID Key Derivation");
|
||||
|
||||
using var tempDir = new TempDirectory();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddSecureConfig(builder =>
|
||||
{
|
||||
builder
|
||||
.WithMachineIdKey()
|
||||
.WithAesCryptoProvider()
|
||||
.UseJsonFileStorage(new JsonStorageOptions
|
||||
{
|
||||
DirectoryPath = tempDir.Path,
|
||||
FileName = "demo2.json",
|
||||
})
|
||||
.AddJsonAotContext(AppJsonContext.Default);
|
||||
});
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
var secureConfig = provider.GetRequiredService<ISecureConfig>();
|
||||
|
||||
Console.WriteLine(" Storing config encrypted with machine-derived key...");
|
||||
await secureConfig.SetAsync("MachineLocked", new ApiOptions { ApiKey = "machine-specific-secret" });
|
||||
|
||||
var value = await secureConfig.GetAsync<ApiOptions>("MachineLocked");
|
||||
Console.WriteLine($" Retrieved on same machine: ApiKey={value!.ApiKey}");
|
||||
Console.WriteLine(" (This data would NOT be decryptable on a different machine)");
|
||||
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
static async Task Demo3_HostAndIOptionsIntegration()
|
||||
{
|
||||
PrintHeader("Demo 3: Host + IOptions / IOptionsSnapshot / IOptionsMonitor");
|
||||
|
||||
using var tempDir = new TempDirectory();
|
||||
var key = GenerateBase64Key();
|
||||
|
||||
Action<ISecureConfigBuilder> configure = builder =>
|
||||
{
|
||||
builder
|
||||
.WithBase64EncryptionKey(key)
|
||||
.WithAesCryptoProvider()
|
||||
.UseJsonFileStorage(new JsonStorageOptions
|
||||
{
|
||||
DirectoryPath = tempDir.Path,
|
||||
FileName = "demo3.json",
|
||||
})
|
||||
.AddJsonAotContext(AppJsonContext.Default);
|
||||
};
|
||||
|
||||
var host = await Host.CreateDefaultBuilder()
|
||||
.ConfigureAppConfiguration((_, b) => b.AddSecureConfig(configure))
|
||||
.ConfigureServices((ctx, s) =>
|
||||
{
|
||||
s.Configure<ApiOptions>(ctx.Configuration.GetSection(nameof(ApiOptions)));
|
||||
s.AddSecureConfig(configure);
|
||||
})
|
||||
.StartAsync();
|
||||
|
||||
var secureConfig = host.Services.GetRequiredService<ISecureConfig>();
|
||||
var options = host.Services.GetRequiredService<IOptions<ApiOptions>>();
|
||||
var monitor = host.Services.GetRequiredService<IOptionsMonitor<ApiOptions>>();
|
||||
var config = (IConfigurationRoot)host.Services.GetRequiredService<IConfiguration>();
|
||||
|
||||
Console.WriteLine(" Setting initial ApiOptions...");
|
||||
await secureConfig.SetAsync(nameof(ApiOptions), new ApiOptions { ApiKey = "initial-key-001" });
|
||||
config.Reload();
|
||||
|
||||
Console.WriteLine($" IOptions: {options.Value.ApiKey}");
|
||||
Console.WriteLine($" IOptionsMonitor: {monitor.CurrentValue.ApiKey}");
|
||||
|
||||
Console.WriteLine(" Updating ApiOptions and reloading config...");
|
||||
await secureConfig.SetAsync(nameof(ApiOptions), new ApiOptions { ApiKey = "updated-key-002" });
|
||||
config.Reload();
|
||||
|
||||
Console.WriteLine($" IOptions: {options.Value.ApiKey} (unchanged - singleton)");
|
||||
Console.WriteLine($" IOptionsMonitor: {monitor.CurrentValue.ApiKey} (updated)");
|
||||
|
||||
using (var scope1 = host.Services.CreateScope())
|
||||
{
|
||||
var snapshot1 = scope1.ServiceProvider.GetRequiredService<IOptionsSnapshot<ApiOptions>>();
|
||||
Console.WriteLine($" IOptionsSnapshot 1: {snapshot1.Value.ApiKey} (new scope, sees update)");
|
||||
}
|
||||
|
||||
await host.StopAsync();
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
static async Task Demo4_ConfigurationProviderPattern()
|
||||
{
|
||||
PrintHeader("Demo 4: IConfigurationBuilder Pattern (No DI)");
|
||||
|
||||
using var tempDir = new TempDirectory();
|
||||
var key = GenerateBase64Key();
|
||||
|
||||
var seedServices = new ServiceCollection();
|
||||
seedServices.AddSecureConfig(builder =>
|
||||
{
|
||||
builder
|
||||
.WithBase64EncryptionKey(key)
|
||||
.WithAesCryptoProvider()
|
||||
.UseJsonFileStorage(new JsonStorageOptions
|
||||
{
|
||||
DirectoryPath = tempDir.Path,
|
||||
FileName = "demo4.json",
|
||||
})
|
||||
.AddJsonAotContext(AppJsonContext.Default);
|
||||
});
|
||||
|
||||
var seedProvider = seedServices.BuildServiceProvider();
|
||||
var secureConfig = seedProvider.GetRequiredService<ISecureConfig>();
|
||||
|
||||
Console.WriteLine(" Seeding data via ISecureConfig...");
|
||||
await secureConfig.SetAsync("ApiOptions", new ApiOptions { ApiKey = "from-config-builder" });
|
||||
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddSecureConfig(builder =>
|
||||
{
|
||||
builder
|
||||
.WithBase64EncryptionKey(key)
|
||||
.WithAesCryptoProvider()
|
||||
.UseJsonFileStorage(new JsonStorageOptions
|
||||
{
|
||||
DirectoryPath = tempDir.Path,
|
||||
FileName = "demo4.json",
|
||||
});
|
||||
})
|
||||
.Build();
|
||||
|
||||
var apiKey = configuration["ApiOptions:ApiKey"];
|
||||
Console.WriteLine($" Read via IConfiguration: ApiOptions:ApiKey = {apiKey}");
|
||||
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
static async Task Demo5_CustomStorageProvider()
|
||||
{
|
||||
PrintHeader("Demo 5: Custom Storage Provider (In-Memory)");
|
||||
|
||||
var key = GenerateBase64Key();
|
||||
var memoryStore = new Dictionary<string, string>();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddSecureConfig(builder =>
|
||||
{
|
||||
builder
|
||||
.WithBase64EncryptionKey(key)
|
||||
.WithAesCryptoProvider()
|
||||
.UseCustomStorage(new InMemoryStorageProvider(memoryStore))
|
||||
.AddJsonAotContext(AppJsonContext.Default);
|
||||
});
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
var secureConfig = provider.GetRequiredService<ISecureConfig>();
|
||||
|
||||
Console.WriteLine(" Storing ApiOptions in in-memory storage...");
|
||||
await secureConfig.SetAsync("ApiOptions", new ApiOptions { ApiKey = "in-memory-secret" });
|
||||
|
||||
Console.WriteLine($" Memory store now has {memoryStore.Count} encrypted entry(ies)");
|
||||
Console.WriteLine($" Encrypted value starts with: {memoryStore["ApiOptions"][..40]}...");
|
||||
|
||||
var retrieved = await secureConfig.GetAsync<ApiOptions>("ApiOptions");
|
||||
Console.WriteLine($" Retrieved: ApiKey={retrieved!.ApiKey}");
|
||||
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
static async Task Demo6_CustomKeyProvider()
|
||||
{
|
||||
PrintHeader("Demo 6: Custom Key Provider");
|
||||
|
||||
using var tempDir = new TempDirectory();
|
||||
|
||||
var customKeyProvider = new EnvironmentVariableKeyProvider("MY_SECURE_CONFIG_KEY");
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddSecureConfig(builder =>
|
||||
{
|
||||
builder
|
||||
.WithCustomKeyProvider(customKeyProvider)
|
||||
.WithAesCryptoProvider()
|
||||
.UseJsonFileStorage(new JsonStorageOptions
|
||||
{
|
||||
DirectoryPath = tempDir.Path,
|
||||
FileName = "demo6.json",
|
||||
})
|
||||
.AddJsonAotContext(AppJsonContext.Default);
|
||||
});
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
var secureConfig = provider.GetRequiredService<ISecureConfig>();
|
||||
|
||||
Console.WriteLine(" Storing ApiOptions with custom key from environment variable...");
|
||||
await secureConfig.SetAsync("ApiOptions", new ApiOptions { ApiKey = "env-var-protected" });
|
||||
|
||||
var retrieved = await secureConfig.GetAsync<ApiOptions>("ApiOptions");
|
||||
Console.WriteLine($" Retrieved: ApiKey={retrieved!.ApiKey}");
|
||||
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
static async Task Demo7_TypedAOTOverloads()
|
||||
{
|
||||
PrintHeader("Demo 7: Typed AOT Overloads (Native AOT Support)");
|
||||
|
||||
using var tempDir = new TempDirectory();
|
||||
var key = GenerateBase64Key();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddSecureConfig(builder =>
|
||||
{
|
||||
builder
|
||||
.WithBase64EncryptionKey(key)
|
||||
.WithAesCryptoProvider()
|
||||
.UseJsonFileStorage(new JsonStorageOptions
|
||||
{
|
||||
DirectoryPath = tempDir.Path,
|
||||
FileName = "demo7.json",
|
||||
})
|
||||
.AddJsonAotContext(AppJsonContext.Default);
|
||||
});
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
var secureConfig = provider.GetRequiredService<ISecureConfig>();
|
||||
|
||||
JsonTypeInfo<DatabaseSettings> dbTypeInfo = AppJsonContext.Default.DatabaseSettings;
|
||||
JsonTypeInfo<SmtpSettings> smtpTypeInfo = AppJsonContext.Default.SmtpSettings;
|
||||
|
||||
Console.WriteLine(" Setting values using explicit JsonTypeInfo<T> overloads...");
|
||||
await secureConfig.SetAsync(
|
||||
"Database",
|
||||
new DatabaseSettings { ConnectionString = "Server=prod;Database=Main;", Timeout = 60, RetryCount = 5 },
|
||||
dbTypeInfo
|
||||
);
|
||||
|
||||
await secureConfig.SetAsync(
|
||||
"Smtp",
|
||||
new SmtpSettings { Host = "smtp.prod.com", Port = 465, Username = "admin", Password = "prod!", UseSsl = true },
|
||||
smtpTypeInfo
|
||||
);
|
||||
|
||||
Console.WriteLine(" Getting values using explicit JsonTypeInfo<T> overloads...");
|
||||
var db = await secureConfig.GetAsync("Database", dbTypeInfo);
|
||||
var smtp = await secureConfig.GetAsync("Smtp", smtpTypeInfo);
|
||||
|
||||
Console.WriteLine($" Database: ConnectionString={db!.ConnectionString}, Timeout={db.Timeout}");
|
||||
Console.WriteLine($" Smtp: Host={smtp!.Host}, Port={smtp.Port}, UseSsl={smtp.UseSsl}");
|
||||
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
static string GenerateBase64Key()
|
||||
{
|
||||
var key = new byte[32];
|
||||
RandomNumberGenerator.Fill(key);
|
||||
return Convert.ToBase64String(key);
|
||||
}
|
||||
|
||||
static void PrintHeader(string title)
|
||||
{
|
||||
Console.WriteLine($"── {title} ──");
|
||||
}
|
||||
|
||||
sealed class TempDirectory : IDisposable
|
||||
{
|
||||
public string Path { get; }
|
||||
|
||||
public TempDirectory()
|
||||
{
|
||||
Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), Guid.NewGuid().ToString());
|
||||
Directory.CreateDirectory(Path);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(Path))
|
||||
{
|
||||
Directory.Delete(Path, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed class InMemoryStorageProvider : ISecureStorageProvider
|
||||
{
|
||||
private readonly Dictionary<string, string> _store;
|
||||
|
||||
public InMemoryStorageProvider(Dictionary<string, string> store)
|
||||
{
|
||||
_store = store;
|
||||
}
|
||||
|
||||
public event EventHandler? StorageChanged;
|
||||
|
||||
public Task<string> ReadAsync(string key, CancellationToken ct = default)
|
||||
{
|
||||
return _store.TryGetValue(key, out var value) ? Task.FromResult(value) : Task.FromResult(string.Empty);
|
||||
}
|
||||
|
||||
public Task<IDictionary<string, string>> ReadAllAsync(CancellationToken ct = default)
|
||||
{
|
||||
return Task.FromResult<IDictionary<string, string>>(new Dictionary<string, string>(_store));
|
||||
}
|
||||
|
||||
public Task WriteAsync(string key, string encryptedData, CancellationToken ct = default)
|
||||
{
|
||||
_store[key] = encryptedData;
|
||||
StorageChanged?.Invoke(this, EventArgs.Empty);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<bool> DeleteAsync(string key, CancellationToken ct = default)
|
||||
{
|
||||
return Task.FromResult(_store.Remove(key));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
sealed class EnvironmentVariableKeyProvider : IEncryptionKeyProvider
|
||||
{
|
||||
private readonly string _variableName;
|
||||
|
||||
public EnvironmentVariableKeyProvider(string variableName)
|
||||
{
|
||||
_variableName = variableName;
|
||||
}
|
||||
|
||||
public byte[] GetKey()
|
||||
{
|
||||
var value = Environment.GetEnvironmentVariable(_variableName);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
Console.WriteLine($" [Warning] Environment variable '{_variableName}' not set. Using SHA256 hash of variable name as key.");
|
||||
value = _variableName;
|
||||
}
|
||||
|
||||
return SHA256.HashData(Encoding.UTF8.GetBytes(value));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace StevanFreeborn.Extensions.Configuration.Secure.Sample;
|
||||
|
||||
public sealed record SmtpSettings
|
||||
{
|
||||
public string Host { get; init; } = string.Empty;
|
||||
public int Port { get; init; }
|
||||
public string Username { get; init; } = string.Empty;
|
||||
public string Password { get; init; } = string.Empty;
|
||||
public bool UseSsl { get; init; }
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.5" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StevanFreeborn.Extensions.Configuration.Secure\StevanFreeborn.Extensions.Configuration.Secure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,62 @@
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
|
||||
namespace StevanFreeborn.Extensions.Configuration.Secure.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Provides secure storage, retrieval, and deletion of configuration values.
|
||||
/// All values are automatically encrypted before storage and decrypted on retrieval.
|
||||
/// </summary>
|
||||
public interface ISecureConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Serializes, encrypts, and stores a value for the specified key.
|
||||
/// Resolves <see cref="JsonTypeInfo{T}"/> from the registered serializer options.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the value to store.</typeparam>
|
||||
/// <param name="key">The configuration key.</param>
|
||||
/// <param name="value">The value to store.</param>
|
||||
/// <param name="ct">A token to monitor for cancellation requests.</param>
|
||||
/// <returns>A task representing the asynchronous operation.</returns>
|
||||
Task SetAsync<T>(string key, T value, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Serializes, encrypts, and stores a value for the specified key using the provided type metadata.
|
||||
/// This overload supports Native AOT by accepting pre-compiled <see cref="JsonTypeInfo{T}"/>.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the value to store.</typeparam>
|
||||
/// <param name="key">The configuration key.</param>
|
||||
/// <param name="value">The value to store.</param>
|
||||
/// <param name="typeInfo">The JSON type metadata for source-generated serialization.</param>
|
||||
/// <param name="ct">A token to monitor for cancellation requests.</param>
|
||||
/// <returns>A task representing the asynchronous operation.</returns>
|
||||
Task SetAsync<T>(string key, T value, JsonTypeInfo<T> typeInfo, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Reads, decrypts, and deserializes a value for the specified key.
|
||||
/// Resolves <see cref="JsonTypeInfo{T}"/> from the registered serializer options.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the value to retrieve.</typeparam>
|
||||
/// <param name="key">The configuration key.</param>
|
||||
/// <param name="ct">A token to monitor for cancellation requests.</param>
|
||||
/// <returns>The deserialized value, or <c>default</c> if the key does not exist.</returns>
|
||||
Task<T?> GetAsync<T>(string key, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Reads, decrypts, and deserializes a value for the specified key using the provided type metadata.
|
||||
/// This overload supports Native AOT by accepting pre-compiled <see cref="JsonTypeInfo{T}"/>.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the value to retrieve.</typeparam>
|
||||
/// <param name="key">The configuration key.</param>
|
||||
/// <param name="typeInfo">The JSON type metadata for source-generated serialization.</param>
|
||||
/// <param name="ct">A token to monitor for cancellation requests.</param>
|
||||
/// <returns>The deserialized value, or <c>default</c> if the key does not exist.</returns>
|
||||
Task<T?> GetAsync<T>(string key, JsonTypeInfo<T> typeInfo, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the value associated with the specified key from secure storage.
|
||||
/// </summary>
|
||||
/// <param name="key">The configuration key.</param>
|
||||
/// <param name="ct">A token to monitor for cancellation requests.</param>
|
||||
/// <returns><c>true</c> if the value was deleted; otherwise, <c>false</c>.</returns>
|
||||
Task<bool> DeleteAsync(string key, CancellationToken ct = default);
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
using StevanFreeborn.Extensions.Configuration.Secure.Cryptography;
|
||||
using StevanFreeborn.Extensions.Configuration.Secure.Storage;
|
||||
|
||||
namespace StevanFreeborn.Extensions.Configuration.Secure.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Provides a fluent builder interface for configuring secure configuration storage and encryption.
|
||||
/// </summary>
|
||||
public interface ISecureConfigBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Configures JSON file-based storage using the provided options instance.
|
||||
/// </summary>
|
||||
/// <param name="options">The JSON storage configuration options.</param>
|
||||
/// <returns>The current <see cref="ISecureConfigBuilder"/> instance for method chaining.</returns>
|
||||
ISecureConfigBuilder UseJsonFileStorage(JsonStorageOptions options);
|
||||
|
||||
/// <summary>
|
||||
/// Configures JSON file-based storage using an action to configure the options.
|
||||
/// </summary>
|
||||
/// <param name="configure">An action to configure the <see cref="JsonStorageOptions"/>.</param>
|
||||
/// <returns>The current <see cref="ISecureConfigBuilder"/> instance for method chaining.</returns>
|
||||
ISecureConfigBuilder UseJsonFileStorage(Action<JsonStorageOptions> configure);
|
||||
|
||||
/// <summary>
|
||||
/// Registers a JSON AOT source-generated context for serializing complex types.
|
||||
/// </summary>
|
||||
/// <param name="context">A context that implements <see cref="IJsonTypeInfoResolver"/> that will be used for JSON serialization.</param>
|
||||
/// <returns>The current <see cref="ISecureConfigBuilder"/> instance for method chaining.</returns>
|
||||
ISecureConfigBuilder AddJsonAotContext(IJsonTypeInfoResolver context);
|
||||
|
||||
/// <summary>
|
||||
/// Configures a custom storage provider for secure configuration data.
|
||||
/// </summary>
|
||||
/// <param name="provider">The custom storage provider implementation.</param>
|
||||
/// <returns>The current <see cref="ISecureConfigBuilder"/> instance for method chaining.</returns>
|
||||
ISecureConfigBuilder UseCustomStorage(ISecureStorageProvider provider);
|
||||
|
||||
/// <summary>
|
||||
/// Configures encryption using a Base64-encoded encryption key.
|
||||
/// </summary>
|
||||
/// <param name="key">The Base64-encoded encryption key string.</param>
|
||||
/// <returns>The current <see cref="ISecureConfigBuilder"/> instance for method chaining.</returns>
|
||||
ISecureConfigBuilder WithBase64EncryptionKey(string key);
|
||||
|
||||
/// <summary>
|
||||
/// Configures encryption using a key derived from the machine id.
|
||||
/// </summary>
|
||||
/// <returns>The current <see cref="ISecureConfigBuilder"/> instance for method chaining.</returns>
|
||||
ISecureConfigBuilder WithMachineIdKey();
|
||||
|
||||
/// <summary>
|
||||
/// Configures a custom encryption key provider.
|
||||
/// </summary>
|
||||
/// <param name="keyProvider">The custom encryption key provider implementation.</param>
|
||||
/// <returns>The current <see cref="ISecureConfigBuilder"/> instance for method chaining.</returns>
|
||||
ISecureConfigBuilder WithCustomKeyProvider(IEncryptionKeyProvider keyProvider);
|
||||
|
||||
/// <summary>
|
||||
/// Configures logging using the provided logger factory.
|
||||
/// </summary>
|
||||
/// <param name="loggerFactory">The logger factory to use for logging operations.</param>
|
||||
/// <returns>The current <see cref="ISecureConfigBuilder"/> instance for method chaining.</returns>
|
||||
ISecureConfigBuilder WithLoggerFactory(ILoggerFactory loggerFactory);
|
||||
|
||||
/// <summary>
|
||||
/// Configures AES crypto provider for encryption and decryption.
|
||||
/// </summary>
|
||||
/// <returns>The current <see cref="ISecureConfigBuilder"/> instance for method chaining.</returns>
|
||||
ISecureConfigBuilder WithAesCryptoProvider();
|
||||
|
||||
/// <summary>
|
||||
/// Configures the factory function that will be used to create the crypto provider for encryption and decryption
|
||||
/// </summary>
|
||||
/// <param name="cryptoProviderFactory">The crypto provider factory to use for encryption and decryption operations.</param>
|
||||
/// <returns>The current <see cref="ISecureConfigBuilder"/> instance for method chaining.</returns>
|
||||
ISecureConfigBuilder WithCustomCryptoProvider(Func<IEncryptionKeyProvider, ICryptoProvider> cryptoProviderFactory);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
|
||||
using StevanFreeborn.Extensions.Configuration.Secure.Cryptography;
|
||||
using StevanFreeborn.Extensions.Configuration.Secure.Storage;
|
||||
|
||||
namespace StevanFreeborn.Extensions.Configuration.Secure.Configuration;
|
||||
|
||||
internal sealed class SecureConfig(
|
||||
ISecureStorageProvider storageProvider,
|
||||
ICryptoProvider cryptoProvider,
|
||||
JsonSerializerOptions jsonOptions
|
||||
) : ISecureConfig
|
||||
{
|
||||
private readonly ISecureStorageProvider _storageProvider = storageProvider ??
|
||||
throw new ArgumentNullException(nameof(storageProvider));
|
||||
|
||||
private readonly ICryptoProvider _cryptoProvider = cryptoProvider ??
|
||||
throw new ArgumentNullException(nameof(cryptoProvider));
|
||||
|
||||
private readonly JsonSerializerOptions _jsonOptions = jsonOptions ??
|
||||
throw new ArgumentNullException(nameof(jsonOptions));
|
||||
|
||||
public Task SetAsync<T>(string key, T value, CancellationToken ct = default)
|
||||
{
|
||||
var typeInfo = GetTypeInfo<T>();
|
||||
return SetAsync(key, value, typeInfo, ct);
|
||||
}
|
||||
|
||||
public async Task SetAsync<T>(string key, T value, JsonTypeInfo<T> typeInfo, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
throw new ArgumentException("Cannot be null or whitespace.", nameof(key));
|
||||
}
|
||||
|
||||
if (value is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(value));
|
||||
}
|
||||
|
||||
var json = JsonSerializer.Serialize(value, typeInfo);
|
||||
var encryptedValue = _cryptoProvider.Encrypt(json);
|
||||
|
||||
await _storageProvider.WriteAsync(key, encryptedValue, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public Task<T?> GetAsync<T>(string key, CancellationToken ct = default)
|
||||
{
|
||||
var typeInfo = GetTypeInfo<T>();
|
||||
return GetAsync(key, typeInfo, ct);
|
||||
}
|
||||
|
||||
public async Task<T?> GetAsync<T>(string key, JsonTypeInfo<T> typeInfo, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
throw new ArgumentException("Cannot be null or whitespace.", nameof(key));
|
||||
}
|
||||
|
||||
var encryptedData = await _storageProvider.ReadAsync(key, ct).ConfigureAwait(false);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(encryptedData))
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
var data = _cryptoProvider.Decrypt(encryptedData);
|
||||
return JsonSerializer.Deserialize(data, typeInfo);
|
||||
}
|
||||
|
||||
public Task<bool> DeleteAsync(string key, CancellationToken ct = default)
|
||||
{
|
||||
return _storageProvider.DeleteAsync(key, ct);
|
||||
}
|
||||
|
||||
private JsonTypeInfo<T> GetTypeInfo<T>()
|
||||
{
|
||||
try
|
||||
{
|
||||
return (JsonTypeInfo<T>?)_jsonOptions.GetTypeInfo(typeof(T))
|
||||
?? throw new InvalidOperationException($"AOT metadata for type '{typeof(T).Name}' is missing. Did you forget to register it via {nameof(SecureConfigBuilder.AddJsonAotContext)}()?");
|
||||
}
|
||||
catch (NotSupportedException ex)
|
||||
{
|
||||
throw new InvalidOperationException($"AOT metadata for type '{typeof(T).Name}' is missing. Did you forget to register it via {nameof(SecureConfigBuilder.AddJsonAotContext)}()?", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
using StevanFreeborn.Extensions.Configuration.Secure.Cryptography;
|
||||
using StevanFreeborn.Extensions.Configuration.Secure.Storage;
|
||||
|
||||
namespace StevanFreeborn.Extensions.Configuration.Secure.Configuration;
|
||||
|
||||
internal sealed class SecureConfigBuilder : ISecureConfigBuilder
|
||||
{
|
||||
internal ISecureStorageProvider? StorageProvider { get; private set; }
|
||||
internal Func<IEncryptionKeyProvider, ICryptoProvider>? CryptoProviderFactory { get; private set; }
|
||||
internal IEncryptionKeyProvider? KeyProvider { get; private set; }
|
||||
internal ILoggerFactory LoggerFactory { get; private set; } = NullLoggerFactory.Instance;
|
||||
internal JsonSerializerOptions SerializerOptions { get; } = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
public ISecureConfigBuilder UseJsonFileStorage(JsonStorageOptions options)
|
||||
{
|
||||
StorageProvider = new JsonFileStorageProvider(options);
|
||||
return this;
|
||||
}
|
||||
|
||||
public ISecureConfigBuilder UseJsonFileStorage(Action<JsonStorageOptions> configure)
|
||||
{
|
||||
#if NET6_0_OR_GREATER
|
||||
ArgumentNullException.ThrowIfNull(configure);
|
||||
#else
|
||||
if (configure is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(configure));
|
||||
}
|
||||
#endif
|
||||
|
||||
var options = new JsonStorageOptions();
|
||||
configure.Invoke(options);
|
||||
StorageProvider = new JsonFileStorageProvider(options);
|
||||
return this;
|
||||
}
|
||||
|
||||
public ISecureConfigBuilder AddJsonAotContext(IJsonTypeInfoResolver context)
|
||||
{
|
||||
#if NET6_0_OR_GREATER
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
#else
|
||||
if (context is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
#endif
|
||||
|
||||
SerializerOptions.TypeInfoResolverChain.Insert(0, context);
|
||||
return this;
|
||||
}
|
||||
|
||||
public ISecureConfigBuilder UseCustomStorage(ISecureStorageProvider provider)
|
||||
{
|
||||
StorageProvider = provider ?? throw new ArgumentNullException(nameof(provider));
|
||||
return this;
|
||||
}
|
||||
|
||||
public ISecureConfigBuilder WithBase64EncryptionKey(string key)
|
||||
{
|
||||
KeyProvider = new StaticKeyProvider(key);
|
||||
return this;
|
||||
}
|
||||
|
||||
public ISecureConfigBuilder WithMachineIdKey()
|
||||
{
|
||||
var logger = LoggerFactory.CreateLogger<MachineIdKeyGenerator>();
|
||||
KeyProvider = new MachineIdKeyProvider(new MachineIdKeyGenerator(logger));
|
||||
return this;
|
||||
}
|
||||
|
||||
public ISecureConfigBuilder WithCustomKeyProvider(IEncryptionKeyProvider provider)
|
||||
{
|
||||
KeyProvider = provider ?? throw new ArgumentNullException(nameof(provider));
|
||||
return this;
|
||||
}
|
||||
|
||||
public ISecureConfigBuilder WithLoggerFactory(ILoggerFactory loggerFactory)
|
||||
{
|
||||
LoggerFactory = loggerFactory ?? NullLoggerFactory.Instance;
|
||||
return this;
|
||||
}
|
||||
|
||||
public ISecureConfigBuilder WithAesCryptoProvider()
|
||||
{
|
||||
CryptoProviderFactory = keyProvider => new AesCryptoProvider(keyProvider);
|
||||
return this;
|
||||
}
|
||||
|
||||
public ISecureConfigBuilder WithCustomCryptoProvider(Func<IEncryptionKeyProvider, ICryptoProvider> cryptoProviderFactory)
|
||||
{
|
||||
#if NET6_0_OR_GREATER
|
||||
ArgumentNullException.ThrowIfNull(cryptoProviderFactory);
|
||||
#else
|
||||
if (cryptoProviderFactory is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(cryptoProviderFactory));
|
||||
}
|
||||
#endif
|
||||
CryptoProviderFactory = cryptoProviderFactory;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
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 : ConfigurationProvider, IDisposable
|
||||
{
|
||||
private readonly ISecureStorageProvider _storageProvider;
|
||||
private readonly ICryptoProvider _cryptoProvider;
|
||||
private readonly ILogger<SecureConfigProvider> _logger;
|
||||
|
||||
public SecureConfigProvider(
|
||||
ISecureStorageProvider storageProvider,
|
||||
ICryptoProvider cryptoProvider,
|
||||
ILogger<SecureConfigProvider> logger
|
||||
)
|
||||
{
|
||||
_storageProvider = storageProvider ?? throw new ArgumentNullException(nameof(storageProvider));
|
||||
_cryptoProvider = cryptoProvider ?? throw new ArgumentNullException(nameof(cryptoProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
_storageProvider.StorageChanged += HandleStorageChangedAsync;
|
||||
}
|
||||
|
||||
public override void Load()
|
||||
{
|
||||
#pragma warning disable CA1849 // Call async methods when in an async method
|
||||
var encryptedData = _storageProvider.ReadAllAsync().GetAwaiter().GetResult();
|
||||
#pragma warning restore CA1849 // Call async methods when in an async method
|
||||
Data = ProcessAndDecryptData(encryptedData);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_storageProvider.StorageChanged -= HandleStorageChangedAsync;
|
||||
}
|
||||
|
||||
private async void HandleStorageChangedAsync(object? sender, EventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
var encryptedData = await _storageProvider.ReadAllAsync().ConfigureAwait(false);
|
||||
|
||||
var newData = ProcessAndDecryptData(encryptedData);
|
||||
|
||||
Data = newData;
|
||||
|
||||
OnReload();
|
||||
}
|
||||
#pragma warning disable CA1031 // Do not catch general exception types
|
||||
catch (Exception ex)
|
||||
#pragma warning restore CA1031 // Do not catch general exception types
|
||||
{
|
||||
_logger.LogFailedToReloadSecureConfig(ex);
|
||||
}
|
||||
}
|
||||
|
||||
private Dictionary<string, string?> ProcessAndDecryptData(IDictionary<string, string> encryptedData)
|
||||
{
|
||||
var flattenedData = new Dictionary<string, string?>(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);
|
||||
}
|
||||
}
|
||||
|
||||
return flattenedData;
|
||||
}
|
||||
|
||||
private static void FlattenJsonElement(IDictionary<string, string?> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
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 sealed class SecureConfigSource(
|
||||
ISecureStorageProvider storageProvider,
|
||||
ICryptoProvider cryptoProvider,
|
||||
ILoggerFactory loggerFactory
|
||||
) : IConfigurationSource
|
||||
{
|
||||
private readonly ISecureStorageProvider _storageProvider = storageProvider
|
||||
?? throw new ArgumentNullException(nameof(storageProvider));
|
||||
|
||||
private readonly ICryptoProvider _cryptoProvider = cryptoProvider
|
||||
?? throw new ArgumentNullException(nameof(cryptoProvider));
|
||||
|
||||
private readonly ILoggerFactory _loggerFactory = loggerFactory
|
||||
?? throw new ArgumentNullException(nameof(loggerFactory));
|
||||
|
||||
public IConfigurationProvider Build(IConfigurationBuilder builder)
|
||||
{
|
||||
var logger = _loggerFactory.CreateLogger<SecureConfigProvider>();
|
||||
return new SecureConfigProvider(_storageProvider, _cryptoProvider, logger);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StevanFreeborn.Extensions.Configuration.Secure.Cryptography;
|
||||
|
||||
internal sealed class AesCryptoProvider(IEncryptionKeyProvider keyProvider) : ICryptoProvider
|
||||
{
|
||||
private const int NonceSize = 12;
|
||||
private const int TagSize = 16;
|
||||
|
||||
private readonly IEncryptionKeyProvider _keyProvider = keyProvider
|
||||
?? throw new ArgumentNullException(nameof(keyProvider));
|
||||
|
||||
public string Encrypt(string plainText)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(plainText))
|
||||
{
|
||||
return plainText;
|
||||
}
|
||||
|
||||
var key = _keyProvider.GetKey();
|
||||
var plainBytes = Encoding.UTF8.GetBytes(plainText);
|
||||
|
||||
var nonce = new byte[NonceSize].AsSpan();
|
||||
RandomNumberGenerator.Fill(nonce);
|
||||
|
||||
var tag = new byte[TagSize].AsSpan();
|
||||
|
||||
var cipherBytes = new byte[plainBytes.Length];
|
||||
|
||||
#if NETSTANDARD2_1
|
||||
using var aesGcm = new AesGcm(key);
|
||||
#else
|
||||
using var aesGcm = new AesGcm(key, TagSize);
|
||||
#endif
|
||||
|
||||
aesGcm.Encrypt(nonce, plainBytes, cipherBytes, tag);
|
||||
|
||||
var combinedBytes = new byte[NonceSize + TagSize + plainBytes.Length];
|
||||
nonce.CopyTo(combinedBytes.AsSpan(0, NonceSize));
|
||||
tag.CopyTo(combinedBytes.AsSpan(NonceSize, TagSize));
|
||||
cipherBytes.CopyTo(combinedBytes.AsSpan(NonceSize + TagSize));
|
||||
|
||||
return Convert.ToBase64String(combinedBytes);
|
||||
}
|
||||
|
||||
public string Decrypt(string cipherText)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(cipherText))
|
||||
{
|
||||
return cipherText;
|
||||
}
|
||||
|
||||
var key = _keyProvider.GetKey();
|
||||
var combinedBytes = Convert.FromBase64String(cipherText).AsSpan();
|
||||
|
||||
if (combinedBytes.Length < NonceSize + TagSize)
|
||||
{
|
||||
throw new CryptographicException($"Invalid payload. {nameof(cipherText)} is not of expected length");
|
||||
}
|
||||
|
||||
var nonce = combinedBytes[..NonceSize];
|
||||
var tag = combinedBytes.Slice(NonceSize, TagSize);
|
||||
var cipherBytes = combinedBytes[(NonceSize + TagSize)..];
|
||||
var plainBytes = new byte[cipherBytes.Length];
|
||||
|
||||
#if NETSTANDARD2_1
|
||||
using var aesGcm = new AesGcm(key);
|
||||
#else
|
||||
using var aesGcm = new AesGcm(key, TagSize);
|
||||
#endif
|
||||
|
||||
aesGcm.Decrypt(nonce, cipherBytes, tag, plainBytes);
|
||||
|
||||
return Encoding.UTF8.GetString(plainBytes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace StevanFreeborn.Extensions.Configuration.Secure.Cryptography;
|
||||
|
||||
/// <summary>
|
||||
/// Defines the contract for cryptographic operations.
|
||||
/// </summary>
|
||||
public interface ICryptoProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Encrypts the provided plain text.
|
||||
/// </summary>
|
||||
/// <param name="plainText">The unencrypted string to be encrypted.</param>
|
||||
/// <returns>The encrypted cipher text. If <paramref name="plainText"/> is null, empty, or whitespace, it is returned unchanged.</returns>
|
||||
string Encrypt(string plainText);
|
||||
|
||||
/// <summary>
|
||||
/// Decrypts the provided cipher text.
|
||||
/// </summary>
|
||||
/// <param name="cipherText">The encrypted string to be decrypted.</param>
|
||||
/// <returns>The decrypted plain text. If <paramref name="cipherText"/> is null, empty, or whitespace, it is returned unchanged.</returns>
|
||||
string Decrypt(string cipherText);
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
namespace StevanFreeborn.Extensions.Configuration.Secure.Cryptography;
|
||||
|
||||
/// <summary>
|
||||
/// Defines a contract for providing encryption keys used to secure configuration data.
|
||||
/// </summary>
|
||||
public interface IEncryptionKeyProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Retrieves the encryption key as a byte array.
|
||||
/// </summary>
|
||||
/// <returns>A byte array containing the encryption key.</returns>
|
||||
byte[] GetKey();
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
namespace StevanFreeborn.Extensions.Configuration.Secure.Cryptography;
|
||||
|
||||
internal interface IMachineIdKeyGenerator
|
||||
{
|
||||
string GetId();
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StevanFreeborn.Extensions.Configuration.Secure.Cryptography;
|
||||
|
||||
internal sealed class MachineIdKeyGenerator : IMachineIdKeyGenerator
|
||||
{
|
||||
private const string IOPlatformUUID = nameof(IOPlatformUUID);
|
||||
private const string MachineGuid = nameof(MachineGuid);
|
||||
private const string WinRegistryPath = @"SOFTWARE\Microsoft\Cryptography";
|
||||
private readonly ILogger<MachineIdKeyGenerator> _logger;
|
||||
private readonly Lazy<string> _machineId;
|
||||
|
||||
public MachineIdKeyGenerator(ILogger<MachineIdKeyGenerator> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_machineId = new(GenerateMachineId);
|
||||
}
|
||||
|
||||
public string GetId()
|
||||
{
|
||||
return _machineId.Value;
|
||||
}
|
||||
|
||||
private string GenerateMachineId()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
using var key = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(WinRegistryPath);
|
||||
var guid = key?.GetValue(MachineGuid)?.ToString();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(guid) is false)
|
||||
{
|
||||
return guid;
|
||||
}
|
||||
}
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||
{
|
||||
const string machineIdPath = "/etc/machine-id";
|
||||
if (File.Exists(machineIdPath))
|
||||
{
|
||||
return File.ReadAllText(machineIdPath).Trim();
|
||||
}
|
||||
}
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "ioreg",
|
||||
Arguments = "-rd1 -c IOPlatformExpertDevice",
|
||||
RedirectStandardOutput = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
using var process = Process.Start(startInfo);
|
||||
using var reader = process?.StandardOutput;
|
||||
var output = reader?.ReadToEnd();
|
||||
|
||||
if (output != null && output.Contains(IOPlatformUUID, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var parts = output.Split([IOPlatformUUID], StringSplitOptions.None);
|
||||
|
||||
if (parts.Length > 1)
|
||||
{
|
||||
var idPart = parts[1].Split('\"');
|
||||
|
||||
if (idPart.Length > 3)
|
||||
{
|
||||
return idPart[3];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#pragma warning disable CA1031
|
||||
catch (Exception ex)
|
||||
#pragma warning restore CA1031
|
||||
{
|
||||
_logger.LogFailedRetrievingMachineId(ex);
|
||||
}
|
||||
|
||||
_logger.LogUsingFallbackStrategy();
|
||||
return $"{Environment.MachineName}_{Environment.UserName}";
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StevanFreeborn.Extensions.Configuration.Secure.Cryptography;
|
||||
|
||||
internal sealed class MachineIdKeyProvider(IMachineIdKeyGenerator generator) : IEncryptionKeyProvider
|
||||
{
|
||||
private readonly IMachineIdKeyGenerator _generator = generator;
|
||||
|
||||
public byte[] GetKey()
|
||||
{
|
||||
var machineId = _generator.GetId();
|
||||
var bytes = Encoding.UTF8.GetBytes(machineId);
|
||||
#if NET5_0_OR_GREATER
|
||||
return SHA256.HashData(bytes);
|
||||
#else
|
||||
using var sha256 = SHA256.Create();
|
||||
return sha256.ComputeHash(bytes);
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
namespace StevanFreeborn.Extensions.Configuration.Secure.Cryptography;
|
||||
|
||||
internal sealed class StaticKeyProvider : IEncryptionKeyProvider
|
||||
{
|
||||
private readonly byte[] _key;
|
||||
|
||||
public StaticKeyProvider(string base64Key)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(base64Key))
|
||||
{
|
||||
throw new ArgumentException("Cannot be null or whitespace.", nameof(base64Key));
|
||||
}
|
||||
|
||||
byte[] decodedKey;
|
||||
|
||||
try
|
||||
{
|
||||
decodedKey = Convert.FromBase64String(base64Key);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
throw new ArgumentException("The provided key is not a valid Base64 string.", nameof(base64Key), ex);
|
||||
}
|
||||
|
||||
if (decodedKey.Length is not 32)
|
||||
{
|
||||
throw new ArgumentException("The encryption key must be exactly 32 bytes (256 bits) for AES-256 encryption.", nameof(base64Key));
|
||||
}
|
||||
|
||||
_key = decodedKey;
|
||||
}
|
||||
|
||||
public byte[] GetKey()
|
||||
{
|
||||
return _key;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
internal static partial class LogMessages
|
||||
{
|
||||
[LoggerMessage(
|
||||
EventId = 1,
|
||||
Level = LogLevel.Warning,
|
||||
Message = "Failed to retrieve hardware-specific machine ID. Falling back to environment variables."
|
||||
)]
|
||||
public static partial void LogFailedRetrievingMachineId(this ILogger logger, Exception ex);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 2,
|
||||
Level = LogLevel.Information,
|
||||
Message = "Using fallback strategy for Machine ID generation."
|
||||
)]
|
||||
public static partial void LogUsingFallbackStrategy(this ILogger logger);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 3,
|
||||
Level = LogLevel.Warning,
|
||||
Message = "Failed to decrypt value for key {Key}"
|
||||
)]
|
||||
public static partial void LogDecryptionFailure(this ILogger logger, Exception ex, string key);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 4,
|
||||
Level = LogLevel.Warning,
|
||||
Message = "Failed to asynchronously reload secure configuration. The previous configuration state will be maintained."
|
||||
)]
|
||||
public static partial void LogFailedToReloadSecureConfig(this ILogger logger, Exception ex);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
using System.Text.Json;
|
||||
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
using StevanFreeborn.Extensions.Configuration.Secure.Configuration;
|
||||
using StevanFreeborn.Extensions.Configuration.Secure.Cryptography;
|
||||
using StevanFreeborn.Extensions.Configuration.Secure.Storage;
|
||||
|
||||
namespace StevanFreeborn.Extensions.Configuration.Secure;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for configuring secure configuration in .NET applications.
|
||||
/// </summary>
|
||||
public static class SecureConfigExtensions
|
||||
{
|
||||
private const string JsonSerializerOptionsKey = "SecureConfigJsonSerializerOptions";
|
||||
|
||||
/// <summary>
|
||||
/// Adds secure configuration to the configuration builder.
|
||||
/// </summary>
|
||||
/// <param name="builder">The configuration builder to add secure configuration to.</param>
|
||||
/// <param name="configure">An action to configure the secure configuration builder.</param>
|
||||
/// <returns>The configuration builder with secure configuration added.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/> or <paramref name="configure"/> is null.</exception>
|
||||
/// <exception cref="InvalidOperationException">Thrown when a required provider is not configured.</exception>
|
||||
public static IConfigurationBuilder AddSecureConfig(
|
||||
this IConfigurationBuilder builder,
|
||||
Action<ISecureConfigBuilder> configure
|
||||
)
|
||||
{
|
||||
#if NET6_0_OR_GREATER
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
ArgumentNullException.ThrowIfNull(configure);
|
||||
#else
|
||||
if (builder is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(builder));
|
||||
}
|
||||
|
||||
if (configure is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(configure));
|
||||
}
|
||||
#endif
|
||||
|
||||
var configBuilder = new SecureConfigBuilder();
|
||||
|
||||
configure.Invoke(configBuilder);
|
||||
|
||||
if (configBuilder.StorageProvider is null)
|
||||
{
|
||||
throw new InvalidOperationException("A storage provider must be configured.");
|
||||
}
|
||||
|
||||
if (configBuilder.KeyProvider is null)
|
||||
{
|
||||
throw new InvalidOperationException("A key provider must be configured");
|
||||
}
|
||||
|
||||
if (configBuilder.CryptoProviderFactory is null)
|
||||
{
|
||||
throw new InvalidOperationException("A crypto provider must be configured.");
|
||||
}
|
||||
|
||||
var cryptoProvider = configBuilder.CryptoProviderFactory.Invoke(configBuilder.KeyProvider);
|
||||
|
||||
var source = new SecureConfigSource(configBuilder.StorageProvider, cryptoProvider, configBuilder.LoggerFactory);
|
||||
return builder.Add(source);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds secure configuration services to the service collection.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection to add secure configuration services to.</param>
|
||||
/// <param name="configure">An action to configure the secure configuration builder.</param>
|
||||
/// <returns>The service collection with secure configuration services added.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="services"/> or <paramref name="configure"/> is null.</exception>
|
||||
/// <exception cref="InvalidOperationException">Thrown when a required provider is not configured.</exception>
|
||||
public static IServiceCollection AddSecureConfig(
|
||||
this IServiceCollection services,
|
||||
Action<ISecureConfigBuilder> configure
|
||||
)
|
||||
{
|
||||
#if NET6_0_OR_GREATER
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configure);
|
||||
#else
|
||||
if (services is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(services));
|
||||
}
|
||||
|
||||
if (configure is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(configure));
|
||||
}
|
||||
#endif
|
||||
|
||||
var configBuilder = new SecureConfigBuilder();
|
||||
|
||||
configure.Invoke(configBuilder);
|
||||
|
||||
if (configBuilder.StorageProvider is null)
|
||||
{
|
||||
throw new InvalidOperationException("A storage provider must be configured.");
|
||||
}
|
||||
|
||||
if (configBuilder.KeyProvider is null)
|
||||
{
|
||||
throw new InvalidOperationException("A key provider must be configured");
|
||||
}
|
||||
|
||||
if (configBuilder.CryptoProviderFactory is null)
|
||||
{
|
||||
throw new InvalidOperationException("A crypto provider must be configured.");
|
||||
}
|
||||
|
||||
services.TryAddKeyedSingleton(JsonSerializerOptionsKey, configBuilder.SerializerOptions);
|
||||
services.TryAddSingleton(configBuilder.StorageProvider);
|
||||
services.TryAddSingleton(configBuilder.KeyProvider);
|
||||
services.TryAddSingleton(configBuilder.CryptoProviderFactory.Invoke(configBuilder.KeyProvider));
|
||||
services.TryAddSingleton<ISecureConfig>(sp =>
|
||||
{
|
||||
var storageProvider = sp.GetRequiredService<ISecureStorageProvider>();
|
||||
var cryptoProvider = sp.GetRequiredService<ICryptoProvider>();
|
||||
var serializerOptions = sp.GetRequiredKeyedService<JsonSerializerOptions>(JsonSerializerOptionsKey);
|
||||
return new SecureConfig(storageProvider, cryptoProvider, serializerOptions);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>netstandard2.1;net8.0;net10.0;</TargetFrameworks>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<IsAotCompatible Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">true</IsAotCompatible>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
|
||||
<AnalysisLevel>latest</AnalysisLevel>
|
||||
<AnalysisMode>All</AnalysisMode>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<CodeAnalysisTreatWarningsAsErrors>true</CodeAnalysisTreatWarningsAsErrors>
|
||||
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="$(AssemblyName).Tests" />
|
||||
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.Extensions.FileProviders.Physical" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
|
||||
<PackageReference Include="System.Text.Json" Version="10.0.5" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,44 @@
|
||||
namespace StevanFreeborn.Extensions.Configuration.Secure.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Defines a contract for a provider that stores and retrieves secure configuration data.
|
||||
/// </summary>
|
||||
public interface ISecureStorageProvider : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Occurs when the underlying storage has changed and the configuration should be reloaded.
|
||||
/// </summary>
|
||||
event EventHandler StorageChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Reads the value associated with the specified key asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="key">The key of the configuration value to read.</param>
|
||||
/// <param name="ct">A cancellation token that can be used to cancel the read operation.</param>
|
||||
/// <returns>A task that represents the asynchronous read operation. The task result contains the value associated with the specified key, or an empty string if the key is not found.</returns>
|
||||
Task<string> ReadAsync(string key, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Reads all configuration values asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="ct">A cancellation token that can be used to cancel the read operation.</param>
|
||||
/// <returns>A task that represents the asynchronous read operation. The task result contains a dictionary of all configuration keys and their encrypted values.</returns>
|
||||
Task<IDictionary<string, string>> ReadAllAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Writes the specified key and encrypted data asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="key">The key of the configuration value to write.</param>
|
||||
/// <param name="encryptedData">The encrypted configuration data to write.</param>
|
||||
/// <param name="ct">A cancellation token that can be used to cancel the write operation.</param>
|
||||
/// <returns>A task that represents the asynchronous write operation.</returns>
|
||||
Task WriteAsync(string key, string encryptedData, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the configuration value associated with the specified key asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="key">The key of the configuration value to delete.</param>
|
||||
/// <param name="ct">A cancellation token that can be used to cancel the delete operation.</param>
|
||||
/// <returns>A task that represents the asynchronous delete operation. The task result contains <c>true</c> if the value was successfully deleted; otherwise, <c>false</c>.</returns>
|
||||
Task<bool> DeleteAsync(string key, CancellationToken ct = default);
|
||||
}
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
using System.Text.Json;
|
||||
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
|
||||
namespace StevanFreeborn.Extensions.Configuration.Secure.Storage;
|
||||
|
||||
internal sealed class JsonFileStorageProvider : ISecureStorageProvider
|
||||
{
|
||||
private readonly SemaphoreSlim _fileLock = new(1, 1);
|
||||
private readonly PhysicalFileProvider? _fileProvider;
|
||||
private readonly IDisposable? _changeTokenRegistration;
|
||||
private readonly JsonStorageOptions _options;
|
||||
|
||||
public event EventHandler? StorageChanged;
|
||||
|
||||
public JsonFileStorageProvider(JsonStorageOptions options)
|
||||
{
|
||||
_options = options
|
||||
?? throw new ArgumentNullException(nameof(options));
|
||||
|
||||
var directory = Path.GetDirectoryName(_options.FullPath);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(directory) is false && Directory.Exists(directory))
|
||||
{
|
||||
_fileProvider = new PhysicalFileProvider(directory)
|
||||
{
|
||||
UseActivePolling = true,
|
||||
UsePollingFileWatcher = true,
|
||||
};
|
||||
|
||||
_changeTokenRegistration = ChangeToken.OnChange(
|
||||
() => _fileProvider.Watch(_options.FileName),
|
||||
() => _ = NotifyStorageChangedAsync()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string> ReadAsync(string key, CancellationToken ct = default)
|
||||
{
|
||||
var data = await AcquireLockAndLoadAsync(ct).ConfigureAwait(false);
|
||||
|
||||
if (data is not null && data.TryGetValue(key, out var v))
|
||||
{
|
||||
return v;
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
public Task<IDictionary<string, string>> ReadAllAsync(CancellationToken ct = default)
|
||||
{
|
||||
return AcquireLockAndLoadAsync(ct);
|
||||
}
|
||||
|
||||
public async Task WriteAsync(string key, string encryptedData, CancellationToken ct = default)
|
||||
{
|
||||
await _fileLock.WaitAsync(ct).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
var data = await LoadAsync(ct).ConfigureAwait(false);
|
||||
data[key] = encryptedData;
|
||||
await SaveAsync(data, ct).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_fileLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(string key, CancellationToken ct = default)
|
||||
{
|
||||
await _fileLock.WaitAsync(ct).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
var data = await LoadAsync(ct).ConfigureAwait(false);
|
||||
var result = data.Remove(key);
|
||||
await SaveAsync(data, ct).ConfigureAwait(false);
|
||||
return result;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_fileLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_changeTokenRegistration?.Dispose();
|
||||
_fileProvider?.Dispose();
|
||||
_fileLock.Dispose();
|
||||
}
|
||||
|
||||
private async Task<IDictionary<string, string>> AcquireLockAndLoadAsync(CancellationToken ct)
|
||||
{
|
||||
await _fileLock.WaitAsync(ct).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
return await LoadAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_fileLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task NotifyStorageChangedAsync()
|
||||
{
|
||||
await Task.Delay(250).ConfigureAwait(false);
|
||||
StorageChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
private async Task<Dictionary<string, string>> LoadAsync(CancellationToken ct)
|
||||
{
|
||||
if (File.Exists(_options.FullPath) is false)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var stream = new FileStream(
|
||||
_options.FullPath,
|
||||
FileMode.Open,
|
||||
FileAccess.Read,
|
||||
FileShare.ReadWrite,
|
||||
bufferSize: 4096,
|
||||
FileOptions.Asynchronous | FileOptions.SequentialScan
|
||||
);
|
||||
|
||||
await using var _ = stream.ConfigureAwait(false);
|
||||
|
||||
if (stream.Length is 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var data = await JsonSerializer.DeserializeAsync(stream, SecureConfigJsonContext.Default.DictionaryStringString, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return data ?? [];
|
||||
}
|
||||
|
||||
private async Task SaveAsync(Dictionary<string, string> data, CancellationToken ct)
|
||||
{
|
||||
Directory.CreateDirectory(_options.DirectoryPath);
|
||||
|
||||
var stream = new FileStream(
|
||||
_options.FullPath,
|
||||
FileMode.Create,
|
||||
FileAccess.Write,
|
||||
FileShare.None,
|
||||
bufferSize: 4096,
|
||||
FileOptions.Asynchronous
|
||||
);
|
||||
|
||||
await using var _ = stream.ConfigureAwait(false);
|
||||
|
||||
await JsonSerializer.SerializeAsync(stream, data, SecureConfigJsonContext.Default.DictionaryStringString, ct)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace StevanFreeborn.Extensions.Configuration.Secure.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Options for configuring the <see cref="JsonFileStorageProvider"/>.
|
||||
/// </summary>
|
||||
public sealed class JsonStorageOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the name of the JSON file used for storage.
|
||||
/// </summary>
|
||||
public string FileName { get; set; } = "secure_config.json";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the directory path where the JSON file is located. Defaults to the base directory of the application.
|
||||
/// </summary>
|
||||
public string DirectoryPath { get; set; } = AppContext.BaseDirectory;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the full, combined path to the JSON file, including the directory and file name.
|
||||
/// </summary>
|
||||
public string FullPath => Path.Combine(DirectoryPath, FileName);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StevanFreeborn.Extensions.Configuration.Secure.Storage;
|
||||
|
||||
[JsonSerializable(typeof(Dictionary<string, string>))]
|
||||
internal sealed partial class SecureConfigJsonContext : JsonSerializerContext;
|
||||
@@ -0,0 +1,3 @@
|
||||
[*.cs]
|
||||
|
||||
dotnet_diagnostic.CA1707.severity = none
|
||||
@@ -0,0 +1,13 @@
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace StevanFreeborn.Extensions.Configuration.Secure.Tests.Common;
|
||||
|
||||
internal static class KeyGenerator
|
||||
{
|
||||
public static string GetValidBase64Key()
|
||||
{
|
||||
var key = new byte[32];
|
||||
RandomNumberGenerator.Fill(key);
|
||||
return Convert.ToBase64String(key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace StevanFreeborn.Extensions.Configuration.Secure.Tests.Common;
|
||||
|
||||
internal sealed class TempDirectory : IDisposable
|
||||
{
|
||||
public string Path { get; }
|
||||
|
||||
public TempDirectory()
|
||||
{
|
||||
Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), Guid.NewGuid().ToString());
|
||||
Directory.CreateDirectory(Path);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(Path))
|
||||
{
|
||||
Directory.Delete(Path, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
+252
@@ -0,0 +1,252 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
using StevanFreeborn.Extensions.Configuration.Secure.Configuration;
|
||||
using StevanFreeborn.Extensions.Configuration.Secure.Cryptography;
|
||||
using StevanFreeborn.Extensions.Configuration.Secure.Storage;
|
||||
using StevanFreeborn.Extensions.Configuration.Secure.Tests.Common;
|
||||
|
||||
namespace StevanFreeborn.Extensions.Configuration.Secure.Tests.Integration;
|
||||
|
||||
public class SecureConfigExtensionsTests : IDisposable
|
||||
{
|
||||
private readonly TempDirectory _tempDir = new();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_tempDir.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddSecureConfig_ToConfigurationBuilder_ItShouldReturnConfigurationBuilder()
|
||||
{
|
||||
var builder = new ConfigurationBuilder();
|
||||
|
||||
var result = builder.AddSecureConfig(CreateDefaultConfig(_tempDir.Path));
|
||||
|
||||
result.Should().BeSameAs(builder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddSecureConfig_ToConfigurationBuilder_ItShouldAddSecureConfigSource()
|
||||
{
|
||||
var builder = new ConfigurationBuilder();
|
||||
|
||||
builder.AddSecureConfig(CreateDefaultConfig(_tempDir.Path));
|
||||
|
||||
builder.Sources.Should().ContainSingle(s => s is SecureConfigSource);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddSecureConfig_ToServiceCollection_ItShouldReturnServiceCollection()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
var result = services.AddSecureConfig(CreateDefaultConfig(_tempDir.Path));
|
||||
|
||||
result.Should().BeSameAs(services);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddSecureConfig_ToServiceCollection_ItShouldRegisterISecureConfig()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddSecureConfig(CreateDefaultConfig(_tempDir.Path));
|
||||
|
||||
var descriptor = services.Should().ContainSingle(d => d.ServiceType == typeof(ISecureConfig)).Subject;
|
||||
descriptor.Lifetime.Should().Be(ServiceLifetime.Singleton);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddSecureConfig_ToServiceCollection_ItShouldRegisterAllDependencies()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddSecureConfig(CreateDefaultConfig(_tempDir.Path));
|
||||
|
||||
services.Should().Contain(d => d.ServiceType == typeof(ISecureStorageProvider));
|
||||
services.Should().Contain(d => d.ServiceType == typeof(IEncryptionKeyProvider));
|
||||
services.Should().Contain(d => d.ServiceType == typeof(ICryptoProvider));
|
||||
services.Should().Contain(d => d.ServiceType == typeof(ISecureConfig));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddSecureConfig_ToServiceCollection_ItShouldNotDuplicateExistingRegistrations()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddSecureConfig(CreateDefaultConfig(_tempDir.Path));
|
||||
services.AddSecureConfig(CreateDefaultConfig(_tempDir.Path));
|
||||
|
||||
services.Count(d => d.ServiceType == typeof(ISecureConfig)).Should().Be(1);
|
||||
services.Count(d => d.ServiceType == typeof(ISecureStorageProvider)).Should().Be(1);
|
||||
services.Count(d => d.ServiceType == typeof(IEncryptionKeyProvider)).Should().Be(1);
|
||||
services.Count(d => d.ServiceType == typeof(ICryptoProvider)).Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddSecureConfig_WithRealProviders_ItShouldResolveISecureConfig()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddSecureConfig(CreateDefaultConfig(_tempDir.Path));
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
var secureConfig = provider.GetRequiredService<ISecureConfig>();
|
||||
|
||||
secureConfig.Should().NotBeNull();
|
||||
secureConfig.Should().BeOfType<SecureConfig>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddSecureConfig_WithRealProviders_ItShouldSetAndGetValue()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddSecureConfig(CreateDefaultConfig(_tempDir.Path));
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
var secureConfig = provider.GetRequiredService<ISecureConfig>();
|
||||
|
||||
var testValue = new IntegrationTestConfig { Name = "TestName", Value = 42 };
|
||||
await secureConfig.SetAsync("test-key", testValue);
|
||||
|
||||
var result = await secureConfig.GetAsync<IntegrationTestConfig>("test-key");
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.Name.Should().Be("TestName");
|
||||
result.Value.Should().Be(42);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddSecureConfig_WithRealProviders_ItShouldDeleteValue()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddSecureConfig(CreateDefaultConfig(_tempDir.Path));
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
var secureConfig = provider.GetRequiredService<ISecureConfig>();
|
||||
|
||||
await secureConfig.SetAsync("delete-key", new IntegrationTestConfig { Name = "ToDelete", Value = 1 });
|
||||
|
||||
var deleted = await secureConfig.DeleteAsync("delete-key");
|
||||
|
||||
deleted.Should().BeTrue();
|
||||
|
||||
var result = await secureConfig.GetAsync<IntegrationTestConfig>("delete-key");
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddSecureConfig_WithRealProviders_ItShouldReturnDefaultForMissingKey()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddSecureConfig(CreateDefaultConfig(_tempDir.Path));
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
var secureConfig = provider.GetRequiredService<ISecureConfig>();
|
||||
|
||||
var result = await secureConfig.GetAsync<IntegrationTestConfig>("nonexistent-key");
|
||||
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddSecureConfig_WithRealProviders_ItShouldResolveAllServicesFromContainer()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddSecureConfig(CreateDefaultConfig(_tempDir.Path));
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
var storageProvider = provider.GetRequiredService<ISecureStorageProvider>();
|
||||
var cryptoProvider = provider.GetRequiredService<ICryptoProvider>();
|
||||
var secureConfig = provider.GetRequiredService<ISecureConfig>();
|
||||
|
||||
storageProvider.Should().NotBeNull();
|
||||
cryptoProvider.Should().NotBeNull();
|
||||
secureConfig.Should().NotBeNull();
|
||||
|
||||
storageProvider.Should().BeOfType<JsonFileStorageProvider>();
|
||||
cryptoProvider.Should().BeOfType<AesCryptoProvider>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddSecureConfig_WithMachineIdKey_ItShouldWorkEndToEnd()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddSecureConfig(builder =>
|
||||
{
|
||||
builder
|
||||
.WithMachineIdKey()
|
||||
.WithAesCryptoProvider()
|
||||
.UseJsonFileStorage(new JsonStorageOptions
|
||||
{
|
||||
DirectoryPath = _tempDir.Path,
|
||||
FileName = "secure-config.json",
|
||||
})
|
||||
.AddJsonAotContext(IntegrationTestJsonContext.Default);
|
||||
});
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
var secureConfig = provider.GetRequiredService<ISecureConfig>();
|
||||
|
||||
var testValue = new IntegrationTestConfig { Name = "MachineIdTest", Value = 99 };
|
||||
await secureConfig.SetAsync("machine-key", testValue);
|
||||
|
||||
var result = await secureConfig.GetAsync<IntegrationTestConfig>("machine-key");
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.Name.Should().Be("MachineIdTest");
|
||||
result.Value.Should().Be(99);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddSecureConfig_WithTypedOverload_ItShouldSerializeAndDeserializeCorrectly()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddSecureConfig(CreateDefaultConfig(_tempDir.Path));
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
var secureConfig = provider.GetRequiredService<ISecureConfig>();
|
||||
|
||||
var testValue = new IntegrationTestConfig { Name = "TypedTest", Value = 777 };
|
||||
await secureConfig.SetAsync("typed-key", testValue, IntegrationTestJsonContext.Default.IntegrationTestConfig);
|
||||
|
||||
var result = await secureConfig.GetAsync("typed-key", IntegrationTestJsonContext.Default.IntegrationTestConfig);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.Name.Should().Be("TypedTest");
|
||||
result.Value.Should().Be(777);
|
||||
}
|
||||
|
||||
private static Action<ISecureConfigBuilder> CreateDefaultConfig(string tempDir) => builder =>
|
||||
{
|
||||
builder
|
||||
.WithBase64EncryptionKey(KeyGenerator.GetValidBase64Key())
|
||||
.WithAesCryptoProvider()
|
||||
.UseJsonFileStorage(new JsonStorageOptions { DirectoryPath = tempDir, FileName = "secure-config.json" })
|
||||
.AddJsonAotContext(IntegrationTestJsonContext.Default);
|
||||
};
|
||||
}
|
||||
|
||||
internal sealed class IntegrationTestConfig
|
||||
{
|
||||
public string Name { get; init; } = string.Empty;
|
||||
public int Value { get; init; }
|
||||
}
|
||||
|
||||
[JsonSerializable(typeof(IntegrationTestConfig))]
|
||||
internal partial class IntegrationTestJsonContext : JsonSerializerContext
|
||||
{
|
||||
}
|
||||
+261
@@ -0,0 +1,261 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
using StevanFreeborn.Extensions.Configuration.Secure.Configuration;
|
||||
using StevanFreeborn.Extensions.Configuration.Secure.Storage;
|
||||
using StevanFreeborn.Extensions.Configuration.Secure.Tests.Common;
|
||||
|
||||
namespace StevanFreeborn.Extensions.Configuration.Secure.Tests.Integration;
|
||||
|
||||
public class SecureConfigOptionsIntegrationTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
private readonly string _base64Key;
|
||||
|
||||
public SecureConfigOptionsIntegrationTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
_base64Key = KeyGenerator.GetValidBase64Key();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConfigureSecureConfig_WithIOptions_ItShouldResolveOptions()
|
||||
{
|
||||
var host = await CreateHostAsync();
|
||||
|
||||
using var scope = host.Services.CreateScope();
|
||||
var secureConfig = scope.ServiceProvider.GetRequiredService<ISecureConfig>();
|
||||
|
||||
await secureConfig.SetAsync(nameof(TestApiOptions), new TestApiOptions { ApiKey = "initial-key" });
|
||||
|
||||
var config = (IConfigurationRoot)host.Services.GetRequiredService<IConfiguration>();
|
||||
config.Reload();
|
||||
|
||||
var options = scope.ServiceProvider.GetRequiredService<IOptions<TestApiOptions>>();
|
||||
|
||||
options.Value.Should().NotBeNull();
|
||||
options.Value.ApiKey.Should().Be("initial-key");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConfigureSecureConfig_WithIOptionsSnapshot_ItShouldResolveNewInstancePerScope()
|
||||
{
|
||||
var host = await CreateHostAsync();
|
||||
|
||||
using var scope1 = host.Services.CreateScope();
|
||||
var snapshot1 = scope1.ServiceProvider.GetRequiredService<IOptionsSnapshot<TestApiOptions>>();
|
||||
|
||||
using var scope2 = host.Services.CreateScope();
|
||||
var snapshot2 = scope2.ServiceProvider.GetRequiredService<IOptionsSnapshot<TestApiOptions>>();
|
||||
|
||||
snapshot1.Should().NotBeSameAs(snapshot2);
|
||||
snapshot1.Value.Should().BeEquivalentTo(snapshot2.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConfigureSecureConfig_WithIOptionsMonitor_ItShouldResolveMonitor()
|
||||
{
|
||||
var host = await CreateHostAsync();
|
||||
|
||||
var monitor = host.Services.GetRequiredService<IOptionsMonitor<TestApiOptions>>();
|
||||
|
||||
monitor.Should().NotBeNull();
|
||||
monitor.CurrentValue.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConfigureSecureConfig_WhenValueUpdatedAndReloaded_ItShouldReflectInIOptionsMonitor()
|
||||
{
|
||||
var host = await CreateHostAsync();
|
||||
|
||||
var secureConfig = host.Services.GetRequiredService<ISecureConfig>();
|
||||
var monitor = host.Services.GetRequiredService<IOptionsMonitor<TestApiOptions>>();
|
||||
var config = (IConfigurationRoot)host.Services.GetRequiredService<IConfiguration>();
|
||||
|
||||
var originalValue = monitor.CurrentValue.ApiKey;
|
||||
|
||||
await secureConfig.SetAsync(nameof(TestApiOptions), new TestApiOptions { ApiKey = "updated-key" });
|
||||
|
||||
config.Reload();
|
||||
|
||||
monitor.CurrentValue.ApiKey.Should().Be("updated-key");
|
||||
monitor.CurrentValue.ApiKey.Should().NotBe(originalValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConfigureSecureConfig_WhenValueUpdatedAndReloaded_ItShouldReflectInNewIOptionsSnapshot()
|
||||
{
|
||||
var host = await CreateHostAsync();
|
||||
|
||||
using var scope1 = host.Services.CreateScope();
|
||||
var snapshotBefore = scope1.ServiceProvider.GetRequiredService<IOptionsSnapshot<TestApiOptions>>();
|
||||
var originalValue = snapshotBefore.Value.ApiKey;
|
||||
|
||||
var secureConfig = host.Services.GetRequiredService<ISecureConfig>();
|
||||
var config = (IConfigurationRoot)host.Services.GetRequiredService<IConfiguration>();
|
||||
|
||||
await secureConfig.SetAsync(nameof(TestApiOptions), new TestApiOptions { ApiKey = "snapshot-updated-key" });
|
||||
|
||||
config.Reload();
|
||||
|
||||
using var scope2 = host.Services.CreateScope();
|
||||
var snapshotAfter = scope2.ServiceProvider.GetRequiredService<IOptionsSnapshot<TestApiOptions>>();
|
||||
|
||||
snapshotAfter.Value.ApiKey.Should().Be("snapshot-updated-key");
|
||||
snapshotAfter.Value.ApiKey.Should().NotBe(originalValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConfigureSecureConfig_WhenValueUpdatedAndReloaded_ItShouldNotUpdateExistingIOptions()
|
||||
{
|
||||
var host = await CreateHostAsync();
|
||||
|
||||
var secureConfig = host.Services.GetRequiredService<ISecureConfig>();
|
||||
var options = host.Services.GetRequiredService<IOptions<TestApiOptions>>();
|
||||
var config = (IConfigurationRoot)host.Services.GetRequiredService<IConfiguration>();
|
||||
|
||||
var originalValue = options.Value.ApiKey;
|
||||
|
||||
await secureConfig.SetAsync(nameof(TestApiOptions), new TestApiOptions { ApiKey = "should-not-update" });
|
||||
|
||||
config.Reload();
|
||||
|
||||
options.Value.ApiKey.Should().Be(originalValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConfigureSecureConfig_WithNestedOptions_ItShouldBindCorrectly()
|
||||
{
|
||||
var host = await CreateHostAsync();
|
||||
|
||||
var secureConfig = host.Services.GetRequiredService<ISecureConfig>();
|
||||
var config = (IConfigurationRoot)host.Services.GetRequiredService<IConfiguration>();
|
||||
|
||||
await secureConfig.SetAsync(nameof(TestApiOptions), new TestApiOptions { ApiKey = "nested-key" });
|
||||
|
||||
config.Reload();
|
||||
|
||||
var options = host.Services.GetRequiredService<IOptions<TestApiOptions>>();
|
||||
|
||||
options.Value.ApiKey.Should().Be("nested-key");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConfigureSecureConfig_WithFullHost_ItShouldStartAndResolveAllServices()
|
||||
{
|
||||
var host = await CreateHostAsync();
|
||||
|
||||
var secureConfig = host.Services.GetRequiredService<ISecureConfig>();
|
||||
var options = host.Services.GetRequiredService<IOptions<TestApiOptions>>();
|
||||
var snapshotFactory = host.Services.GetRequiredService<IServiceScopeFactory>();
|
||||
var monitor = host.Services.GetRequiredService<IOptionsMonitor<TestApiOptions>>();
|
||||
var configuration = host.Services.GetRequiredService<IConfiguration>();
|
||||
|
||||
secureConfig.Should().NotBeNull();
|
||||
options.Should().NotBeNull();
|
||||
snapshotFactory.Should().NotBeNull();
|
||||
monitor.Should().NotBeNull();
|
||||
configuration.Should().NotBeNull();
|
||||
|
||||
using var scope = snapshotFactory.CreateScope();
|
||||
var snapshot = scope.ServiceProvider.GetRequiredService<IOptionsSnapshot<TestApiOptions>>();
|
||||
snapshot.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConfigureSecureConfig_WhenValueUpdated_ItShouldPropagateThroughEntirePipeline()
|
||||
{
|
||||
var host = await CreateHostAsync();
|
||||
|
||||
var secureConfig = host.Services.GetRequiredService<ISecureConfig>();
|
||||
var options = host.Services.GetRequiredService<IOptions<TestApiOptions>>();
|
||||
var monitor = host.Services.GetRequiredService<IOptionsMonitor<TestApiOptions>>();
|
||||
var config = (IConfigurationRoot)host.Services.GetRequiredService<IConfiguration>();
|
||||
|
||||
var originalOptionsValue = options.Value.ApiKey;
|
||||
var originalMonitorValue = monitor.CurrentValue.ApiKey;
|
||||
|
||||
await secureConfig.SetAsync(nameof(TestApiOptions), new TestApiOptions { ApiKey = "pipeline-updated-key" });
|
||||
|
||||
config.Reload();
|
||||
|
||||
monitor.CurrentValue.ApiKey.Should().Be("pipeline-updated-key");
|
||||
|
||||
using var scope = host.Services.CreateScope();
|
||||
var snapshot = scope.ServiceProvider.GetRequiredService<IOptionsSnapshot<TestApiOptions>>();
|
||||
snapshot.Value.ApiKey.Should().Be("pipeline-updated-key");
|
||||
|
||||
options.Value.ApiKey.Should().Be(originalOptionsValue);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_tempDir))
|
||||
{
|
||||
Directory.Delete(_tempDir, true);
|
||||
}
|
||||
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private Action<ISecureConfigBuilder> CreateConfigBuilder() => builder =>
|
||||
{
|
||||
builder
|
||||
.WithBase64EncryptionKey(_base64Key)
|
||||
.WithAesCryptoProvider()
|
||||
.UseJsonFileStorage(new JsonStorageOptions
|
||||
{
|
||||
DirectoryPath = _tempDir,
|
||||
FileName = "secure-config.json"
|
||||
})
|
||||
.AddJsonAotContext(OptionsTestJsonContext.Default);
|
||||
};
|
||||
|
||||
private async Task<IHost> CreateHostAsync()
|
||||
{
|
||||
var configure = CreateConfigBuilder();
|
||||
|
||||
var hostBuilder = Host.CreateDefaultBuilder()
|
||||
.ConfigureLogging(c => c.ClearProviders())
|
||||
.ConfigureAppConfiguration((_, builder) =>
|
||||
{
|
||||
builder.AddSecureConfig(configure);
|
||||
})
|
||||
.ConfigureServices((context, services) =>
|
||||
{
|
||||
services.Configure<TestApiOptions>(context.Configuration.GetSection(nameof(TestApiOptions)));
|
||||
services.AddSecureConfig(configure);
|
||||
});
|
||||
|
||||
var host = hostBuilder.Build();
|
||||
await host.StartAsync();
|
||||
|
||||
var secureConfig = host.Services.GetRequiredService<ISecureConfig>();
|
||||
|
||||
if (string.IsNullOrEmpty((await secureConfig.GetAsync<TestApiOptions>(nameof(TestApiOptions)))?.ApiKey))
|
||||
{
|
||||
await secureConfig.SetAsync(nameof(TestApiOptions), new TestApiOptions { ApiKey = "default-key" });
|
||||
var config = (IConfigurationRoot)host.Services.GetRequiredService<IConfiguration>();
|
||||
config.Reload();
|
||||
}
|
||||
|
||||
return host;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TestApiOptions
|
||||
{
|
||||
public string ApiKey { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
[JsonSerializable(typeof(TestApiOptions))]
|
||||
internal partial class OptionsTestJsonContext : JsonSerializerContext
|
||||
{
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net8.0;net10.0;</TargetFrameworks>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AwesomeAssertions" Version="9.4.0" />
|
||||
<PackageReference Include="coverlet.collector" Version="8.0.1">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.3.0" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
<Using Include="AwesomeAssertions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\StevanFreeborn.Extensions.Configuration.Secure\StevanFreeborn.Extensions.Configuration.Secure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+333
@@ -0,0 +1,333 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
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 SecureConfigBuilderTests
|
||||
{
|
||||
private readonly SecureConfigBuilder _sut = new();
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WhenCalled_ItShouldInitializeWithDefaultValues()
|
||||
{
|
||||
_sut.StorageProvider.Should().BeNull();
|
||||
_sut.CryptoProviderFactory.Should().BeNull();
|
||||
_sut.KeyProvider.Should().BeNull();
|
||||
_sut.LoggerFactory.Should().Be(NullLoggerFactory.Instance);
|
||||
_sut.SerializerOptions.PropertyNameCaseInsensitive.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UseJsonFileStorage_WithOptions_ItShouldSetStorageProvider()
|
||||
{
|
||||
var options = new JsonStorageOptions
|
||||
{
|
||||
FileName = "test_config.json",
|
||||
DirectoryPath = "/tmp/config"
|
||||
};
|
||||
|
||||
var result = _sut.UseJsonFileStorage(options);
|
||||
|
||||
result.Should().BeSameAs(_sut);
|
||||
_sut.StorageProvider.Should().NotBeNull();
|
||||
_sut.StorageProvider.Should().BeOfType<JsonFileStorageProvider>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UseJsonFileStorage_WithOptions_ItShouldUseProvidedOptions()
|
||||
{
|
||||
var options = new JsonStorageOptions
|
||||
{
|
||||
FileName = "custom.json",
|
||||
DirectoryPath = "/custom/path"
|
||||
};
|
||||
|
||||
_sut.UseJsonFileStorage(options);
|
||||
|
||||
_sut.StorageProvider.Should().BeOfType<JsonFileStorageProvider>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UseJsonFileStorage_WithConfigureAction_ItShouldSetStorageProvider()
|
||||
{
|
||||
var result = _sut.UseJsonFileStorage(opt => opt.FileName = "test.json");
|
||||
|
||||
result.Should().BeSameAs(_sut);
|
||||
_sut.StorageProvider.Should().NotBeNull();
|
||||
_sut.StorageProvider.Should().BeOfType<JsonFileStorageProvider>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UseJsonFileStorage_WithConfigureAction_ItShouldApplyConfiguration()
|
||||
{
|
||||
_sut.UseJsonFileStorage(opt =>
|
||||
{
|
||||
opt.FileName = "configured.json";
|
||||
opt.DirectoryPath = "/configured/path";
|
||||
});
|
||||
|
||||
_sut.StorageProvider.Should().BeOfType<JsonFileStorageProvider>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UseJsonFileStorage_WithNullConfigureAction_ItShouldThrowArgumentNullException()
|
||||
{
|
||||
var act = () => _sut.UseJsonFileStorage((Action<JsonStorageOptions>)null!);
|
||||
|
||||
act.Should().Throw<ArgumentNullException>()
|
||||
.WithParameterName("configure");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddJsonAotContext_WithContext_ItShouldAddToResolverChain()
|
||||
{
|
||||
var mockContext = new Mock<IJsonTypeInfoResolver>();
|
||||
|
||||
var result = _sut.AddJsonAotContext(mockContext.Object);
|
||||
|
||||
result.Should().BeSameAs(_sut);
|
||||
_sut.SerializerOptions.TypeInfoResolverChain.Should().Contain(mockContext.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddJsonAotContext_WithNullContext_ItShouldThrowArgumentNullException()
|
||||
{
|
||||
var act = () => _sut.AddJsonAotContext(null!);
|
||||
|
||||
act.Should().Throw<ArgumentNullException>()
|
||||
.WithParameterName("context");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddJsonAotContext_WhenCalledMultipleTimes_ItShouldAddAllToChain()
|
||||
{
|
||||
var mockContext1 = new Mock<IJsonTypeInfoResolver>();
|
||||
var mockContext2 = new Mock<IJsonTypeInfoResolver>();
|
||||
|
||||
_sut.AddJsonAotContext(mockContext1.Object);
|
||||
_sut.AddJsonAotContext(mockContext2.Object);
|
||||
|
||||
_sut.SerializerOptions.TypeInfoResolverChain.Should().Contain(mockContext1.Object);
|
||||
_sut.SerializerOptions.TypeInfoResolverChain.Should().Contain(mockContext2.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UseCustomStorage_WithProvider_ItShouldSetStorageProvider()
|
||||
{
|
||||
var mockProvider = new Mock<ISecureStorageProvider>();
|
||||
|
||||
var result = _sut.UseCustomStorage(mockProvider.Object);
|
||||
|
||||
result.Should().BeSameAs(_sut);
|
||||
_sut.StorageProvider.Should().BeSameAs(mockProvider.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UseCustomStorage_WithNullProvider_ItShouldThrowArgumentNullException()
|
||||
{
|
||||
var act = () => _sut.UseCustomStorage(null!);
|
||||
|
||||
act.Should().Throw<ArgumentNullException>()
|
||||
.WithParameterName("provider");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithBase64EncryptionKey_WithValidKey_ItShouldSetKeyProvider()
|
||||
{
|
||||
var validKey = Convert.ToBase64String(new byte[32]);
|
||||
|
||||
var result = _sut.WithBase64EncryptionKey(validKey);
|
||||
|
||||
result.Should().BeSameAs(_sut);
|
||||
_sut.KeyProvider.Should().NotBeNull();
|
||||
_sut.KeyProvider.Should().BeOfType<StaticKeyProvider>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithBase64EncryptionKey_WithValidKey_ItShouldReturnCorrectKey()
|
||||
{
|
||||
var keyBytes = new byte[32];
|
||||
RandomNumberGenerator.Fill(keyBytes);
|
||||
|
||||
var validKey = Convert.ToBase64String(keyBytes);
|
||||
|
||||
_sut.WithBase64EncryptionKey(validKey);
|
||||
|
||||
_sut.KeyProvider!.GetKey().Should().Equal(keyBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithMachineIdKey_WhenCalled_ItShouldSetKeyProvider()
|
||||
{
|
||||
var result = _sut.WithMachineIdKey();
|
||||
|
||||
result.Should().BeSameAs(_sut);
|
||||
_sut.KeyProvider.Should().NotBeNull();
|
||||
_sut.KeyProvider.Should().BeOfType<MachineIdKeyProvider>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithMachineIdKey_WhenCalled_ItShouldUseLoggerFactory()
|
||||
{
|
||||
var mockLoggerFactory = new Mock<ILoggerFactory>();
|
||||
var mockLogger = new Mock<ILogger>();
|
||||
|
||||
mockLoggerFactory.Setup(f => f.CreateLogger(typeof(MachineIdKeyGenerator).FullName!))
|
||||
.Returns(mockLogger.Object);
|
||||
|
||||
_sut.WithLoggerFactory(mockLoggerFactory.Object);
|
||||
|
||||
_sut.WithMachineIdKey();
|
||||
|
||||
mockLoggerFactory.Verify(f => f.CreateLogger(typeof(MachineIdKeyGenerator).FullName!), Times.Once());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithCustomKeyProvider_WithProvider_ItShouldSetKeyProvider()
|
||||
{
|
||||
var mockProvider = new Mock<IEncryptionKeyProvider>();
|
||||
|
||||
var result = _sut.WithCustomKeyProvider(mockProvider.Object);
|
||||
|
||||
result.Should().BeSameAs(_sut);
|
||||
_sut.KeyProvider.Should().BeSameAs(mockProvider.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithCustomKeyProvider_WithNullProvider_ItShouldThrowArgumentNullException()
|
||||
{
|
||||
var act = () => _sut.WithCustomKeyProvider(null!);
|
||||
|
||||
act.Should().Throw<ArgumentNullException>()
|
||||
.WithParameterName("provider");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithLoggerFactory_WithFactory_ItShouldSetLoggerFactory()
|
||||
{
|
||||
var mockFactory = new Mock<ILoggerFactory>();
|
||||
|
||||
var result = _sut.WithLoggerFactory(mockFactory.Object);
|
||||
|
||||
result.Should().BeSameAs(_sut);
|
||||
_sut.LoggerFactory.Should().BeSameAs(mockFactory.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithLoggerFactory_WithNullFactory_ItShouldSetNullLoggerFactory()
|
||||
{
|
||||
var mockFactory = new Mock<ILoggerFactory>();
|
||||
_sut.WithLoggerFactory(mockFactory.Object);
|
||||
|
||||
_sut.WithLoggerFactory(null!);
|
||||
|
||||
_sut.LoggerFactory.Should().Be(NullLoggerFactory.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithAesCryptoProvider_WhenCalled_ItShouldSetCryptoProviderFactory()
|
||||
{
|
||||
var mockKeyProvider = new Mock<IEncryptionKeyProvider>();
|
||||
|
||||
var result = _sut.WithAesCryptoProvider();
|
||||
|
||||
result.Should().BeSameAs(_sut);
|
||||
_sut.CryptoProviderFactory.Should().NotBeNull();
|
||||
|
||||
var cryptoProvider = _sut.CryptoProviderFactory!(mockKeyProvider.Object);
|
||||
cryptoProvider.Should().BeOfType<AesCryptoProvider>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithCustomCryptoProvider_WithFactory_ItShouldSetCryptoProviderFactory()
|
||||
{
|
||||
var mockCryptoProvider = new Mock<ICryptoProvider>();
|
||||
var mockKeyProvider = new Mock<IEncryptionKeyProvider>();
|
||||
Func<IEncryptionKeyProvider, ICryptoProvider> factory = (kp) => mockCryptoProvider.Object;
|
||||
|
||||
var result = _sut.WithCustomCryptoProvider(factory);
|
||||
|
||||
result.Should().BeSameAs(_sut);
|
||||
_sut.CryptoProviderFactory.Should().NotBeNull();
|
||||
|
||||
var cryptoProvider = _sut.CryptoProviderFactory!(mockKeyProvider.Object);
|
||||
cryptoProvider.Should().BeSameAs(mockCryptoProvider.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithCustomCryptoProvider_WithNullFactory_ItShouldThrowArgumentNullException()
|
||||
{
|
||||
var act = () => _sut.WithCustomCryptoProvider(null!);
|
||||
|
||||
act.Should().Throw<ArgumentNullException>()
|
||||
.WithParameterName("cryptoProviderFactory");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MethodChaining_WhenAllMethodsCalled_ItShouldConfigureAllProperties()
|
||||
{
|
||||
var mockStorageProvider = new Mock<ISecureStorageProvider>();
|
||||
var mockKeyProvider = new Mock<IEncryptionKeyProvider>();
|
||||
var mockLoggerFactory = new Mock<ILoggerFactory>();
|
||||
var mockCryptoProvider = new Mock<ICryptoProvider>();
|
||||
|
||||
_sut.UseCustomStorage(mockStorageProvider.Object)
|
||||
.WithCustomKeyProvider(mockKeyProvider.Object)
|
||||
.WithLoggerFactory(mockLoggerFactory.Object)
|
||||
.WithCustomCryptoProvider((kp) => mockCryptoProvider.Object);
|
||||
|
||||
_sut.StorageProvider.Should().BeSameAs(mockStorageProvider.Object);
|
||||
_sut.KeyProvider.Should().BeSameAs(mockKeyProvider.Object);
|
||||
_sut.LoggerFactory.Should().BeSameAs(mockLoggerFactory.Object);
|
||||
_sut.CryptoProviderFactory.Should().NotBeNull();
|
||||
|
||||
var provider = _sut.CryptoProviderFactory!(mockKeyProvider.Object);
|
||||
provider.Should().BeSameAs(mockCryptoProvider.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MethodChaining_WhenOverridingStorage_ItShouldUseLastSetProvider()
|
||||
{
|
||||
var mockProvider1 = new Mock<ISecureStorageProvider>();
|
||||
var mockProvider2 = new Mock<ISecureStorageProvider>();
|
||||
|
||||
_sut.UseCustomStorage(mockProvider1.Object)
|
||||
.UseCustomStorage(mockProvider2.Object);
|
||||
|
||||
_sut.StorageProvider.Should().BeSameAs(mockProvider2.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MethodChaining_WhenOverridingKeyProvider_ItShouldUseLastSetProvider()
|
||||
{
|
||||
var mockProvider1 = new Mock<IEncryptionKeyProvider>();
|
||||
var mockProvider2 = new Mock<IEncryptionKeyProvider>();
|
||||
|
||||
_sut.WithCustomKeyProvider(mockProvider1.Object)
|
||||
.WithCustomKeyProvider(mockProvider2.Object);
|
||||
|
||||
_sut.KeyProvider.Should().BeSameAs(mockProvider2.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MethodChaining_WhenOverridingLoggerFactory_ItShouldUseLastSetFactory()
|
||||
{
|
||||
var mockFactory1 = new Mock<ILoggerFactory>();
|
||||
var mockFactory2 = new Mock<ILoggerFactory>();
|
||||
|
||||
_sut.WithLoggerFactory(mockFactory1.Object)
|
||||
.WithLoggerFactory(mockFactory2.Object);
|
||||
|
||||
_sut.LoggerFactory.Should().BeSameAs(mockFactory2.Object);
|
||||
}
|
||||
}
|
||||
+514
@@ -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<ISecureStorageProvider> _mockStorage = new();
|
||||
private readonly Mock<ICryptoProvider> _mockCrypto = new();
|
||||
private readonly Mock<ILogger<SecureConfigProvider>> _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<string, string>
|
||||
{
|
||||
{ rootKey, encryptedString },
|
||||
};
|
||||
|
||||
_mockStorage.Setup(m => m.ReadAllAsync(It.IsAny<CancellationToken>())).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<CancellationToken>())).ReturnsAsync(new Dictionary<string, string>());
|
||||
|
||||
var act = _sut.Load;
|
||||
act.Should().NotThrow();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_WhenDecryptionFails_ItShouldLogErrorAndContinue()
|
||||
{
|
||||
var storedData = new Dictionary<string, string>
|
||||
{
|
||||
{ "ValidKey", "encrypted_valid" },
|
||||
{ "BadKey", "encrypted_bad" },
|
||||
};
|
||||
|
||||
_mockLogger.Setup(m => m.IsEnabled(LogLevel.Warning)).Returns(true);
|
||||
_mockStorage.Setup(m => m.ReadAllAsync(It.IsAny<CancellationToken>())).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.Warning,
|
||||
It.Is<EventId>(id => id.Id == 3),
|
||||
It.Is<It.IsAnyType>((state, type) => state.ToString()!.Contains("Failed to decrypt value for key")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
|
||||
),
|
||||
Times.Once()
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_WhenCalledWithDeeplyNestedObject_ItShouldFlattenCorrectly()
|
||||
{
|
||||
var rawJson = @"{
|
||||
""Level1"": {
|
||||
""Level2"": {
|
||||
""Level3"": {
|
||||
""Value"": ""deep_value""
|
||||
}
|
||||
}
|
||||
}
|
||||
}";
|
||||
|
||||
_mockStorage.Setup(m => m.ReadAllAsync(It.IsAny<CancellationToken>())).ReturnsAsync(new Dictionary<string, string> { { "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<CancellationToken>())).ReturnsAsync(new Dictionary<string, string> { { "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<CancellationToken>())).ReturnsAsync(new Dictionary<string, string> { { "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<CancellationToken>())).ReturnsAsync(new Dictionary<string, string> { { "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<CancellationToken>())).ReturnsAsync(new Dictionary<string, string> { { "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<CancellationToken>())).ReturnsAsync(new Dictionary<string, string> { { "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<CancellationToken>())).ReturnsAsync(new Dictionary<string, string> { { "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<CancellationToken>())).ReturnsAsync(new Dictionary<string, string> { { "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<CancellationToken>())).ReturnsAsync(new Dictionary<string, string> { { "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<CancellationToken>())).ReturnsAsync(new Dictionary<string, string> { { "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<CancellationToken>())).ReturnsAsync(new Dictionary<string, string> { { "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<CancellationToken>())).ReturnsAsync(new Dictionary<string, string>
|
||||
{
|
||||
{ "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<CancellationToken>())).ReturnsAsync(new Dictionary<string, string> { { "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<CancellationToken>())).ReturnsAsync(new Dictionary<string, string> { { "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<CancellationToken>())).ReturnsAsync(new Dictionary<string, string> { { "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<CancellationToken>())).ReturnsAsync(new Dictionary<string, string> { { "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.Warning)).Returns(true);
|
||||
_mockStorage.Setup(m => m.ReadAllAsync(It.IsAny<CancellationToken>())).ReturnsAsync(new Dictionary<string, string> { { "Bad", "enc" } });
|
||||
_mockCrypto.Setup(m => m.Decrypt("enc")).Returns("not valid json{{{");
|
||||
|
||||
_sut.Load();
|
||||
|
||||
_mockLogger.Verify(x => x.Log(
|
||||
LogLevel.Warning,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, _) => v.ToString()!.Contains("Bad")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
|
||||
),
|
||||
Times.Once()
|
||||
);
|
||||
}
|
||||
}
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
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 SecureConfigSourceTests
|
||||
{
|
||||
private readonly Mock<ISecureStorageProvider> _mockStorage = new();
|
||||
private readonly Mock<ICryptoProvider> _mockCrypto = new();
|
||||
private readonly Mock<ILoggerFactory> _mockLoggerFactory = new();
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WhenStorageProviderIsNull_ItShouldThrowArgumentNullException()
|
||||
{
|
||||
var act = () => new SecureConfigSource(
|
||||
storageProvider: null!,
|
||||
cryptoProvider: _mockCrypto.Object,
|
||||
loggerFactory: _mockLoggerFactory.Object
|
||||
);
|
||||
|
||||
act.Should().Throw<ArgumentNullException>()
|
||||
.WithParameterName("storageProvider");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WhenCryptoProviderIsNull_ItShouldThrowArgumentNullException()
|
||||
{
|
||||
var act = () => new SecureConfigSource(
|
||||
storageProvider: _mockStorage.Object,
|
||||
cryptoProvider: null!,
|
||||
loggerFactory: _mockLoggerFactory.Object
|
||||
);
|
||||
|
||||
act.Should().Throw<ArgumentNullException>()
|
||||
.WithParameterName("cryptoProvider");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WhenLoggerFactoryIsNull_ItShouldThrowArgumentNullException()
|
||||
{
|
||||
var act = () => new SecureConfigSource(
|
||||
storageProvider: _mockStorage.Object,
|
||||
cryptoProvider: _mockCrypto.Object,
|
||||
loggerFactory: null!
|
||||
);
|
||||
|
||||
act.Should().Throw<ArgumentNullException>()
|
||||
.WithParameterName("loggerFactory");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WhenAllDependenciesAreProvided_ItShouldNotThrow()
|
||||
{
|
||||
var act = () => new SecureConfigSource(
|
||||
storageProvider: _mockStorage.Object,
|
||||
cryptoProvider: _mockCrypto.Object,
|
||||
loggerFactory: _mockLoggerFactory.Object
|
||||
);
|
||||
|
||||
act.Should().NotThrow();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WhenCalled_ItShouldReturnSecureConfigProvider()
|
||||
{
|
||||
var sut = new SecureConfigSource(
|
||||
storageProvider: _mockStorage.Object,
|
||||
cryptoProvider: _mockCrypto.Object,
|
||||
loggerFactory: _mockLoggerFactory.Object
|
||||
);
|
||||
|
||||
var mockBuilder = new Mock<IConfigurationBuilder>();
|
||||
|
||||
var result = sut.Build(mockBuilder.Object);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result.Should().BeOfType<SecureConfigProvider>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WhenCalled_ItShouldCreateLoggerFromFactory()
|
||||
{
|
||||
var mockLogger = new Mock<ILogger<SecureConfigProvider>>();
|
||||
_mockLoggerFactory
|
||||
.Setup(f => f.CreateLogger(typeof(SecureConfigProvider).FullName!))
|
||||
.Returns(mockLogger.Object);
|
||||
|
||||
var sut = new SecureConfigSource(
|
||||
storageProvider: _mockStorage.Object,
|
||||
cryptoProvider: _mockCrypto.Object,
|
||||
loggerFactory: _mockLoggerFactory.Object
|
||||
);
|
||||
|
||||
var mockBuilder = new Mock<IConfigurationBuilder>();
|
||||
sut.Build(mockBuilder.Object);
|
||||
|
||||
_mockLoggerFactory.Verify(
|
||||
f => f.CreateLogger(typeof(SecureConfigProvider).FullName!),
|
||||
Times.Once()
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WhenCalledMultipleTimes_ItShouldReturnNewProviderInstance()
|
||||
{
|
||||
var sut = new SecureConfigSource(
|
||||
storageProvider: _mockStorage.Object,
|
||||
cryptoProvider: _mockCrypto.Object,
|
||||
loggerFactory: _mockLoggerFactory.Object
|
||||
);
|
||||
|
||||
var mockBuilder = new Mock<IConfigurationBuilder>();
|
||||
|
||||
var result1 = sut.Build(mockBuilder.Object);
|
||||
var result2 = sut.Build(mockBuilder.Object);
|
||||
|
||||
result1.Should().NotBeSameAs(result2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WhenCalledWithNullBuilder_ItShouldStillReturnProvider()
|
||||
{
|
||||
var sut = new SecureConfigSource(
|
||||
storageProvider: _mockStorage.Object,
|
||||
cryptoProvider: _mockCrypto.Object,
|
||||
loggerFactory: _mockLoggerFactory.Object
|
||||
);
|
||||
|
||||
var result = sut.Build(builder: null!);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result.Should().BeOfType<SecureConfigProvider>();
|
||||
}
|
||||
}
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
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 SecureConfigTests
|
||||
{
|
||||
private readonly Mock<ICryptoProvider> _mockCryptoProvider = new();
|
||||
private readonly Mock<ISecureStorageProvider> _mockStorageProvider = new();
|
||||
private readonly JsonSerializerOptions _jsonSerializerOptions = new();
|
||||
private readonly SecureConfig _sut;
|
||||
|
||||
public SecureConfigTests()
|
||||
{
|
||||
_sut = new(_mockStorageProvider.Object, _mockCryptoProvider.Object, _jsonSerializerOptions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WhenCalledWithNullStorageProvider_ItShouldThrowArgumentNullException()
|
||||
{
|
||||
var act = () => new SecureConfig(null!, _mockCryptoProvider.Object, _jsonSerializerOptions);
|
||||
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WhenCalledWithNullCryptoProvider_ItShouldThrowArgumentNullException()
|
||||
{
|
||||
var act = () => new SecureConfig(_mockStorageProvider.Object, null!, _jsonSerializerOptions);
|
||||
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WhenCalledWithNullJsonOptions_ItShouldThrowArgumentNullException()
|
||||
{
|
||||
var act = () => new SecureConfig(_mockStorageProvider.Object, _mockCryptoProvider.Object, null!);
|
||||
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetAsync_WhenCalledWithNullKey_ItShouldThrowArgumentNullException()
|
||||
{
|
||||
var act = async () => await _sut.SetAsync(null!, string.Empty, SecureConfigTestsJsonContext.Default.String);
|
||||
|
||||
await act.Should().ThrowAsync<ArgumentException>();
|
||||
}
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task SetAsync_WhenCalledWithNullValue_ItShouldThrowArgumentNullException()
|
||||
{
|
||||
var act = async () => await _sut.SetAsync("Key", null!, SecureConfigTestsJsonContext.Default.String);
|
||||
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetAsync_WhenCalledWithoutJsonContextSet_ItShouldSerializeGivenValueAndEncryptIt()
|
||||
{
|
||||
var key = "Database";
|
||||
var config = new DummyConfig("localhost", 9999);
|
||||
var encryptedString = "encryptedString";
|
||||
var json = JsonSerializer.Serialize(config);
|
||||
|
||||
_mockCryptoProvider.Setup(m => m.Encrypt(json)).Returns(encryptedString);
|
||||
|
||||
var act = async () => await _sut.SetAsync(key, config);
|
||||
|
||||
await act.Should().ThrowAsync<InvalidOperationException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetAsync_WhenCalledWithJsonContextSet_ItShouldSerializeGivenValueAndEncryptIt()
|
||||
{
|
||||
var key = "Database";
|
||||
var config = new DummyConfig("localhost", 9999);
|
||||
var encryptedString = "encryptedString";
|
||||
var json = JsonSerializer.Serialize(config);
|
||||
|
||||
_jsonSerializerOptions.TypeInfoResolverChain.Insert(0, SecureConfigTestsJsonContext.Default);
|
||||
_mockCryptoProvider.Setup(m => m.Encrypt(json)).Returns(encryptedString);
|
||||
|
||||
await _sut.SetAsync(key, config);
|
||||
|
||||
_mockStorageProvider.Verify(m => m.WriteAsync(key, encryptedString, It.IsAny<CancellationToken>()), Times.Once());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetAsync_WhenCalledWithTypeInfo_ItShouldSerializeGivenValueAndEncryptIt()
|
||||
{
|
||||
var key = "Database";
|
||||
var config = new DummyConfig("localhost", 9999);
|
||||
var encryptedString = "encryptedString";
|
||||
var json = JsonSerializer.Serialize(config);
|
||||
|
||||
_mockCryptoProvider.Setup(m => m.Encrypt(json)).Returns(encryptedString);
|
||||
|
||||
await _sut.SetAsync(key, config, SecureConfigTestsJsonContext.Default.DummyConfig);
|
||||
|
||||
_mockStorageProvider.Verify(m => m.WriteAsync(key, encryptedString, It.IsAny<CancellationToken>()), Times.Once());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_WhenCalledWithNullKey_ItShouldThrowArgumentNullException()
|
||||
{
|
||||
var act = async () => await _sut.GetAsync(null!, SecureConfigTestsJsonContext.Default.DummyConfig);
|
||||
|
||||
await act.Should().ThrowAsync<ArgumentException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_WhenKeyExistsAndJsonContextNotSet_ItShouldReadDecryptAndDeserializeTheValue()
|
||||
{
|
||||
var key = "Database";
|
||||
var expectedConfig = new DummyConfig("localhost", 9999);
|
||||
var encryptedString = "encryptedString";
|
||||
var json = JsonSerializer.Serialize(expectedConfig);
|
||||
|
||||
_mockStorageProvider.Setup(m => m.ReadAsync(key, It.IsAny<CancellationToken>())).ReturnsAsync(encryptedString);
|
||||
_mockCryptoProvider.Setup(m => m.Decrypt(encryptedString)).Returns(json);
|
||||
|
||||
var act = async () => await _sut.GetAsync<DummyConfig>(key);
|
||||
|
||||
await act.Should().ThrowAsync<InvalidOperationException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_WhenKeyExistsAndJsonContextIsSet_ItShouldReadDecryptAndDeserializeTheValue()
|
||||
{
|
||||
var key = "Database";
|
||||
var expectedConfig = new DummyConfig("localhost", 9999);
|
||||
var encryptedString = "encryptedString";
|
||||
var json = JsonSerializer.Serialize(expectedConfig);
|
||||
|
||||
_jsonSerializerOptions.TypeInfoResolverChain.Insert(0, SecureConfigTestsJsonContext.Default);
|
||||
_mockStorageProvider.Setup(m => m.ReadAsync(key, It.IsAny<CancellationToken>())).ReturnsAsync(encryptedString);
|
||||
_mockCryptoProvider.Setup(m => m.Decrypt(encryptedString)).Returns(json);
|
||||
|
||||
var result = await _sut.GetAsync(key, SecureConfigTestsJsonContext.Default.DummyConfig);
|
||||
|
||||
result.Should().BeEquivalentTo(expectedConfig);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_WhenKeyExists_ItShouldReadDecryptAndDeserializeTheValue()
|
||||
{
|
||||
var key = "Database";
|
||||
var expectedConfig = new DummyConfig("localhost", 9999);
|
||||
var encryptedString = "encryptedString";
|
||||
var json = JsonSerializer.Serialize(expectedConfig);
|
||||
|
||||
_mockStorageProvider.Setup(m => m.ReadAsync(key, It.IsAny<CancellationToken>())).ReturnsAsync(encryptedString);
|
||||
_mockCryptoProvider.Setup(m => m.Decrypt(encryptedString)).Returns(json);
|
||||
|
||||
var result = await _sut.GetAsync(key, SecureConfigTestsJsonContext.Default.DummyConfig);
|
||||
|
||||
result.Should().BeEquivalentTo(expectedConfig);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_WhenKeyDoesNotExist_ItShouldReturnDefaultValue()
|
||||
{
|
||||
var key = "Database";
|
||||
var expectedConfig = new DummyConfig("localhost", 9999);
|
||||
var json = JsonSerializer.Serialize(expectedConfig);
|
||||
|
||||
_mockStorageProvider.Setup(m => m.ReadAsync(key, It.IsAny<CancellationToken>())).ReturnsAsync(string.Empty);
|
||||
|
||||
var result = await _sut.GetAsync(key, SecureConfigTestsJsonContext.Default.DummyConfig);
|
||||
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_WhenCalled_ItShouldRemoveValue()
|
||||
{
|
||||
var key = "Database";
|
||||
|
||||
_mockStorageProvider.Setup(m => m.DeleteAsync(key, It.IsAny<CancellationToken>())).ReturnsAsync(true);
|
||||
|
||||
var result = await _sut.DeleteAsync(key);
|
||||
|
||||
result.Should().BeTrue();
|
||||
_mockStorageProvider.Verify(m => m.DeleteAsync(key, It.IsAny<CancellationToken>()), Times.Once());
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record DummyConfig(string Host, int Port);
|
||||
|
||||
[JsonSerializable(typeof(string))]
|
||||
[JsonSerializable(typeof(DummyConfig))]
|
||||
internal partial class SecureConfigTestsJsonContext : JsonSerializerContext
|
||||
{
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
using System.Security.Cryptography;
|
||||
|
||||
using Moq;
|
||||
|
||||
using StevanFreeborn.Extensions.Configuration.Secure.Cryptography;
|
||||
|
||||
namespace StevanFreeborn.Extensions.Configuration.Secure.Tests.Unit.Cryptography;
|
||||
|
||||
public class AesCryptoProviderTests
|
||||
{
|
||||
private readonly Mock<IEncryptionKeyProvider> _mockKeyProvider = new();
|
||||
private readonly AesCryptoProvider _sut;
|
||||
|
||||
public AesCryptoProviderTests()
|
||||
{
|
||||
var key = new byte[32];
|
||||
RandomNumberGenerator.Fill(key);
|
||||
_mockKeyProvider.Setup(static m => m.GetKey()).Returns(key);
|
||||
|
||||
_sut = new(_mockKeyProvider.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EncryptAndDecrypt_WhenCalled_ItShouldReturnOriginalString()
|
||||
{
|
||||
var originalText = "SuperDuperSecret";
|
||||
|
||||
var encryptedText = _sut.Encrypt(originalText);
|
||||
var decryptedText = _sut.Decrypt(encryptedText);
|
||||
|
||||
encryptedText.Should().NotBe(originalText);
|
||||
decryptedText.Should().Be(originalText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Encrypt_WhenCalledWithSameInputTwitch_ItShouldProduceDifferentCipherText()
|
||||
{
|
||||
var plainText = "identicalInput";
|
||||
|
||||
var cipherTextOne = _sut.Encrypt(plainText);
|
||||
var cipherTextTwo = _sut.Encrypt(plainText);
|
||||
|
||||
cipherTextOne.Should().NotBe(cipherTextTwo);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Decrypt_WhenCalledWithTamperedData_ItShouldThrowCrytographicException()
|
||||
{
|
||||
var cipherText = _sut.Encrypt("some data");
|
||||
var rawBytes = Convert.FromBase64String(cipherText);
|
||||
rawBytes[^1] = (byte)(rawBytes[^1] ^ 0xFF);
|
||||
|
||||
var tamperedText = Convert.ToBase64String(rawBytes);
|
||||
|
||||
var act = () => _sut.Decrypt(tamperedText);
|
||||
|
||||
act.Should().Throw<CryptographicException>();
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
using Moq;
|
||||
|
||||
using StevanFreeborn.Extensions.Configuration.Secure.Cryptography;
|
||||
|
||||
namespace StevanFreeborn.Extensions.Configuration.Secure.Tests.Unit.Cryptography;
|
||||
|
||||
public class MachineIdKeyGeneratorTests
|
||||
{
|
||||
private readonly Mock<ILogger<MachineIdKeyGenerator>> _mockLogger = new();
|
||||
private readonly MachineIdKeyGenerator _sut;
|
||||
|
||||
public MachineIdKeyGeneratorTests()
|
||||
{
|
||||
_sut = new(_mockLogger.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetId_WhenCalled_ItShouldReturnNonEmptyString()
|
||||
{
|
||||
_sut.GetId().Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetId_WhenCalledMultipleTimes_ItShouldReturnConsistentId()
|
||||
{
|
||||
var resultOne = _sut.GetId();
|
||||
var resultTwo = _sut.GetId();
|
||||
|
||||
resultTwo.Should().Be(resultOne);
|
||||
}
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
using Moq;
|
||||
|
||||
using StevanFreeborn.Extensions.Configuration.Secure.Cryptography;
|
||||
|
||||
namespace StevanFreeborn.Extensions.Configuration.Secure.Tests.Unit.Cryptography;
|
||||
|
||||
public class MachineIdKeyProviderTests
|
||||
{
|
||||
private readonly Mock<IMachineIdKeyGenerator> _mockKeyGenerator = new();
|
||||
private readonly MachineIdKeyProvider _sut;
|
||||
|
||||
public MachineIdKeyProviderTests()
|
||||
{
|
||||
_sut = new(_mockKeyGenerator.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetKey_WhenCalled_ItShouldReturn32ByteKey()
|
||||
{
|
||||
_mockKeyGenerator.Setup(static m => m.GetId()).Returns("random-stuff");
|
||||
|
||||
var result = _sut.GetKey();
|
||||
|
||||
result.Length.Should().Be(32);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetKey_WhenCalledForSameInput_ItShouldProduceConsistentHash()
|
||||
{
|
||||
var input = "some-machine-id";
|
||||
|
||||
var mockInstanceOne = new Mock<IMachineIdKeyGenerator>();
|
||||
mockInstanceOne.Setup(static m => m.GetId()).Returns(input);
|
||||
|
||||
var instanceOne = new MachineIdKeyProvider(mockInstanceOne.Object);
|
||||
var resultOne = instanceOne.GetKey();
|
||||
|
||||
var mockInstanceTwo = new Mock<IMachineIdKeyGenerator>();
|
||||
mockInstanceTwo.Setup(static m => m.GetId()).Returns(input);
|
||||
|
||||
var instanceTwo = new MachineIdKeyProvider(mockInstanceTwo.Object);
|
||||
var resultTwo = instanceTwo.GetKey();
|
||||
|
||||
resultOne.Should().BeEquivalentTo(resultTwo);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetKey_WhenCalledWithDifferentInput_ItShouldProduceDifferentHashes()
|
||||
{
|
||||
var inputOne = "inputOne";
|
||||
var inputTwo = "inputTwo";
|
||||
|
||||
_mockKeyGenerator.SetupSequence(static m => m.GetId())
|
||||
.Returns(inputOne)
|
||||
.Returns(inputTwo);
|
||||
|
||||
var resultOne = _sut.GetKey();
|
||||
var resultTwo = _sut.GetKey();
|
||||
|
||||
resultOne.Should().NotBeEquivalentTo(resultTwo);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetKey_WhenCalled_ItShouldUseSHA256Hash()
|
||||
{
|
||||
var rawId = "rawId";
|
||||
var expectedHash = SHA256.HashData(Encoding.UTF8.GetBytes(rawId));
|
||||
|
||||
_mockKeyGenerator.Setup(static m => m.GetId()).Returns(rawId);
|
||||
|
||||
var result = _sut.GetKey();
|
||||
|
||||
result.Should().BeEquivalentTo(expectedHash);
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
using StevanFreeborn.Extensions.Configuration.Secure.Cryptography;
|
||||
|
||||
namespace StevanFreeborn.Extensions.Configuration.Secure.Tests.Unit.Cryptography;
|
||||
|
||||
public class StaticKeyProviderTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_ShouldThrowArgumentNullException_WhenKeyIsNull()
|
||||
{
|
||||
var act = () => new StaticKeyProvider(null!);
|
||||
|
||||
act.Should().Throw<ArgumentException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WhenCalledAndKeyIsNot32Bytes_ItShouldThrowArgumentException()
|
||||
{
|
||||
var shortKey = Convert.ToBase64String(new byte[16]);
|
||||
|
||||
var act = () => new StaticKeyProvider(shortKey);
|
||||
|
||||
act.Should().Throw<ArgumentException>().WithMessage("*must be exactly 32 bytes*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetKey_WhenCalled_ItShouldReturnValid32ByteArray()
|
||||
{
|
||||
var expectedBytes = new byte[32];
|
||||
Random.Shared.NextBytes(expectedBytes);
|
||||
var base64Key = Convert.ToBase64String(expectedBytes);
|
||||
|
||||
var provider = new StaticKeyProvider(base64Key);
|
||||
|
||||
var result = provider.GetKey();
|
||||
|
||||
result.Should().BeEquivalentTo(expectedBytes);
|
||||
result.Length.Should().Be(32);
|
||||
}
|
||||
}
|
||||
+713
@@ -0,0 +1,713 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
using Moq;
|
||||
|
||||
using StevanFreeborn.Extensions.Configuration.Secure.Configuration;
|
||||
using StevanFreeborn.Extensions.Configuration.Secure.Cryptography;
|
||||
using StevanFreeborn.Extensions.Configuration.Secure.Storage;
|
||||
using StevanFreeborn.Extensions.Configuration.Secure.Tests.Common;
|
||||
|
||||
namespace StevanFreeborn.Extensions.Configuration.Secure.Tests.Unit;
|
||||
|
||||
public class SecureConfigExtensionsTests
|
||||
{
|
||||
private readonly Mock<ISecureStorageProvider> _mockStorageProvider = new();
|
||||
private readonly Mock<IEncryptionKeyProvider> _mockKeyProvider = new();
|
||||
private readonly Mock<ICryptoProvider> _mockCryptoProvider = new();
|
||||
private readonly Mock<ILoggerFactory> _mockLoggerFactory = new();
|
||||
|
||||
[Fact]
|
||||
public void AddSecureConfig_WithNullBuilder_ItShouldThrowArgumentNullException()
|
||||
{
|
||||
IConfigurationBuilder builder = null!;
|
||||
|
||||
var act = () => builder.AddSecureConfig(config => { });
|
||||
|
||||
act.Should().Throw<ArgumentNullException>()
|
||||
.WithParameterName("builder");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddSecureConfig_WithNullConfigure_ItShouldThrowArgumentNullException()
|
||||
{
|
||||
var builder = new ConfigurationBuilder();
|
||||
|
||||
var act = () => builder.AddSecureConfig(null!);
|
||||
|
||||
act.Should().Throw<ArgumentNullException>()
|
||||
.WithParameterName("configure");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddSecureConfig_WhenStorageProviderNotConfigured_ItShouldThrowInvalidOperationException()
|
||||
{
|
||||
var builder = new ConfigurationBuilder();
|
||||
|
||||
var act = () => builder.AddSecureConfig(config =>
|
||||
{
|
||||
config.WithBase64EncryptionKey(KeyGenerator.GetValidBase64Key());
|
||||
config.WithAesCryptoProvider();
|
||||
});
|
||||
|
||||
act.Should().Throw<InvalidOperationException>()
|
||||
.WithMessage("A storage provider must be configured.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddSecureConfig_WhenKeyProviderNotConfigured_ItShouldThrowInvalidOperationException()
|
||||
{
|
||||
var builder = new ConfigurationBuilder();
|
||||
|
||||
var act = () => builder.AddSecureConfig(config =>
|
||||
{
|
||||
config.UseCustomStorage(_mockStorageProvider.Object);
|
||||
config.WithAesCryptoProvider();
|
||||
});
|
||||
|
||||
act.Should().Throw<InvalidOperationException>()
|
||||
.WithMessage("A key provider must be configured");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddSecureConfig_WhenCryptoProviderNotConfigured_ItShouldThrowInvalidOperationException()
|
||||
{
|
||||
var builder = new ConfigurationBuilder();
|
||||
|
||||
var act = () => builder.AddSecureConfig(config =>
|
||||
{
|
||||
config.UseCustomStorage(_mockStorageProvider.Object);
|
||||
config.WithBase64EncryptionKey(KeyGenerator.GetValidBase64Key());
|
||||
});
|
||||
|
||||
act.Should().Throw<InvalidOperationException>()
|
||||
.WithMessage("A crypto provider must be configured.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddSecureConfig_WhenProperlyConfigured_ItShouldAddSecureConfigSource()
|
||||
{
|
||||
var builder = new ConfigurationBuilder();
|
||||
|
||||
var result = builder.AddSecureConfig(config =>
|
||||
{
|
||||
config.UseCustomStorage(_mockStorageProvider.Object);
|
||||
config.WithBase64EncryptionKey(KeyGenerator.GetValidBase64Key());
|
||||
config.WithAesCryptoProvider();
|
||||
});
|
||||
|
||||
result.Should().BeSameAs(builder);
|
||||
builder.Sources.Should().ContainSingle(s => s is SecureConfigSource);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddSecureConfig_WhenProperlyConfigured_ItShouldBuildConfigurationProvider()
|
||||
{
|
||||
var encryptedValue = "encrypted_value";
|
||||
var decryptedJson = /*lang=json,strict*/ @"{ ""Setting"": ""Value"", ""Number"": 42 }";
|
||||
|
||||
_mockStorageProvider
|
||||
.Setup(s => s.ReadAllAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new Dictionary<string, string> { { "MySection", encryptedValue } });
|
||||
|
||||
_mockCryptoProvider
|
||||
.Setup(c => c.Decrypt(encryptedValue))
|
||||
.Returns(decryptedJson);
|
||||
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddSecureConfig(config =>
|
||||
{
|
||||
config.UseCustomStorage(_mockStorageProvider.Object);
|
||||
config.WithCustomKeyProvider(_mockKeyProvider.Object);
|
||||
config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object);
|
||||
})
|
||||
.Build();
|
||||
|
||||
configuration.GetSection("MySection")["Setting"].Should().Be("Value");
|
||||
configuration.GetSection("MySection")["Number"].Should().Be("42");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddSecureConfig_WithJsonFileStorage_ItShouldWorkEndToEnd()
|
||||
{
|
||||
using var tempDir = new TempDirectory();
|
||||
var filePath = Path.Combine(tempDir.Path, "test_config.json");
|
||||
|
||||
var keyBytes = new byte[32];
|
||||
RandomNumberGenerator.Fill(keyBytes);
|
||||
var keyProvider = new StaticKeyProvider(Convert.ToBase64String(keyBytes));
|
||||
var cryptoProvider = new AesCryptoProvider(keyProvider);
|
||||
|
||||
var originalData = /*lang=json,strict*/ @"{ ""AppName"": ""TestApp"", ""Version"": ""1.0.0"" }";
|
||||
var encryptedData = cryptoProvider.Encrypt(originalData);
|
||||
|
||||
File.WriteAllText(filePath, $"{{\"Settings\":\"{encryptedData}\"}}");
|
||||
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddSecureConfig(config =>
|
||||
{
|
||||
config.UseJsonFileStorage(options =>
|
||||
{
|
||||
options.DirectoryPath = tempDir.Path;
|
||||
options.FileName = "test_config.json";
|
||||
});
|
||||
config.WithBase64EncryptionKey(Convert.ToBase64String(keyBytes));
|
||||
config.WithAesCryptoProvider();
|
||||
})
|
||||
.Build();
|
||||
|
||||
configuration["Settings:AppName"].Should().Be("TestApp");
|
||||
configuration["Settings:Version"].Should().Be("1.0.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddSecureConfig_WithLoggerFactory_ItShouldUseProvidedLoggerFactory()
|
||||
{
|
||||
_mockStorageProvider
|
||||
.Setup(s => s.ReadAllAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new Dictionary<string, string>());
|
||||
|
||||
var mockLogger = new Mock<ILogger<SecureConfigProvider>>();
|
||||
|
||||
_mockLoggerFactory
|
||||
.Setup(f => f.CreateLogger(typeof(SecureConfigProvider).FullName!))
|
||||
.Returns(mockLogger.Object);
|
||||
|
||||
var builder = new ConfigurationBuilder();
|
||||
|
||||
builder.AddSecureConfig(config =>
|
||||
{
|
||||
config.UseCustomStorage(_mockStorageProvider.Object);
|
||||
config.WithBase64EncryptionKey(KeyGenerator.GetValidBase64Key());
|
||||
config.WithAesCryptoProvider();
|
||||
config.WithLoggerFactory(_mockLoggerFactory.Object);
|
||||
})
|
||||
.Build();
|
||||
|
||||
_mockLoggerFactory.Verify(
|
||||
f => f.CreateLogger(typeof(SecureConfigProvider).FullName!),
|
||||
Times.Once()
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddSecureConfig_WithMultipleCalls_ItShouldAddMultipleSources()
|
||||
{
|
||||
var builder = new ConfigurationBuilder();
|
||||
|
||||
builder.AddSecureConfig(config =>
|
||||
{
|
||||
config.UseCustomStorage(_mockStorageProvider.Object);
|
||||
config.WithBase64EncryptionKey(KeyGenerator.GetValidBase64Key());
|
||||
config.WithAesCryptoProvider();
|
||||
});
|
||||
|
||||
builder.AddSecureConfig(config =>
|
||||
{
|
||||
config.UseCustomStorage(_mockStorageProvider.Object);
|
||||
config.WithBase64EncryptionKey(KeyGenerator.GetValidBase64Key());
|
||||
config.WithAesCryptoProvider();
|
||||
});
|
||||
|
||||
builder.Sources.Should().HaveCount(2);
|
||||
builder.Sources.Should().AllBeOfType<SecureConfigSource>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddSecureConfig_ServiceCollection_WithNullServices_ItShouldThrowArgumentNullException()
|
||||
{
|
||||
IServiceCollection services = null!;
|
||||
|
||||
var act = () => services.AddSecureConfig(config => { });
|
||||
|
||||
act.Should().Throw<ArgumentNullException>()
|
||||
.WithParameterName("services");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddSecureConfig_ServiceCollection_WithNullConfigure_ItShouldThrowArgumentNullException()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
var act = () => services.AddSecureConfig(null!);
|
||||
|
||||
act.Should().Throw<ArgumentNullException>()
|
||||
.WithParameterName("configure");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddSecureConfig_ServiceCollection_WhenStorageProviderNotConfigured_ItShouldThrowInvalidOperationException()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
var act = () => services.AddSecureConfig(config =>
|
||||
{
|
||||
config.WithBase64EncryptionKey(KeyGenerator.GetValidBase64Key());
|
||||
config.WithAesCryptoProvider();
|
||||
});
|
||||
|
||||
act.Should().Throw<InvalidOperationException>()
|
||||
.WithMessage("A storage provider must be configured.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddSecureConfig_ServiceCollection_WhenKeyProviderNotConfigured_ItShouldThrowInvalidOperationException()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
var act = () => services.AddSecureConfig(config =>
|
||||
{
|
||||
config.UseCustomStorage(_mockStorageProvider.Object);
|
||||
config.WithAesCryptoProvider();
|
||||
});
|
||||
|
||||
act.Should().Throw<InvalidOperationException>()
|
||||
.WithMessage("A key provider must be configured");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddSecureConfig_ServiceCollection_WhenCryptoProviderNotConfigured_ItShouldThrowInvalidOperationException()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
var act = () => services.AddSecureConfig(config =>
|
||||
{
|
||||
config.UseCustomStorage(_mockStorageProvider.Object);
|
||||
config.WithBase64EncryptionKey(KeyGenerator.GetValidBase64Key());
|
||||
});
|
||||
|
||||
act.Should().Throw<InvalidOperationException>()
|
||||
.WithMessage("A crypto provider must be configured.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddSecureConfig_ServiceCollection_WhenProperlyConfigured_ItShouldRegisterServices()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddSecureConfig(config =>
|
||||
{
|
||||
config.UseCustomStorage(_mockStorageProvider.Object);
|
||||
config.WithCustomKeyProvider(_mockKeyProvider.Object);
|
||||
config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object);
|
||||
});
|
||||
|
||||
services.Should().Contain(s => s.ServiceType == typeof(ISecureStorageProvider));
|
||||
services.Should().Contain(s => s.ServiceType == typeof(IEncryptionKeyProvider));
|
||||
services.Should().Contain(s => s.ServiceType == typeof(ICryptoProvider));
|
||||
services.Should().Contain(s => s.ServiceType == typeof(ISecureConfig));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddSecureConfig_ServiceCollection_ItShouldReturnServiceCollection()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
var result = services.AddSecureConfig(config =>
|
||||
{
|
||||
config.UseCustomStorage(_mockStorageProvider.Object);
|
||||
config.WithCustomKeyProvider(_mockKeyProvider.Object);
|
||||
config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object);
|
||||
});
|
||||
|
||||
result.Should().BeSameAs(services);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddSecureConfig_ServiceCollection_ItShouldRegisterStorageProviderAsSingleton()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddSecureConfig(config =>
|
||||
{
|
||||
config.UseCustomStorage(_mockStorageProvider.Object);
|
||||
config.WithCustomKeyProvider(_mockKeyProvider.Object);
|
||||
config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object);
|
||||
});
|
||||
|
||||
var storageDescriptor = services.First(s => s.ServiceType == typeof(ISecureStorageProvider));
|
||||
storageDescriptor.Lifetime.Should().Be(ServiceLifetime.Singleton);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddSecureConfig_ServiceCollection_ItShouldRegisterKeyProviderAsSingleton()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddSecureConfig(config =>
|
||||
{
|
||||
config.UseCustomStorage(_mockStorageProvider.Object);
|
||||
config.WithCustomKeyProvider(_mockKeyProvider.Object);
|
||||
config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object);
|
||||
});
|
||||
|
||||
var keyDescriptor = services.First(s => s.ServiceType == typeof(IEncryptionKeyProvider));
|
||||
keyDescriptor.Lifetime.Should().Be(ServiceLifetime.Singleton);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddSecureConfig_ServiceCollection_ItShouldRegisterCryptoProviderAsSingleton()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddSecureConfig(config =>
|
||||
{
|
||||
config.UseCustomStorage(_mockStorageProvider.Object);
|
||||
config.WithCustomKeyProvider(_mockKeyProvider.Object);
|
||||
config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object);
|
||||
});
|
||||
|
||||
var cryptoDescriptor = services.First(s => s.ServiceType == typeof(ICryptoProvider));
|
||||
cryptoDescriptor.Lifetime.Should().Be(ServiceLifetime.Singleton);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddSecureConfig_ServiceCollection_ItShouldRegisterSecureConfigAsSingleton()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddSecureConfig(config =>
|
||||
{
|
||||
config.UseCustomStorage(_mockStorageProvider.Object);
|
||||
config.WithCustomKeyProvider(_mockKeyProvider.Object);
|
||||
config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object);
|
||||
});
|
||||
|
||||
var secureConfigDescriptor = services.First(s => s.ServiceType == typeof(ISecureConfig));
|
||||
secureConfigDescriptor.Lifetime.Should().Be(ServiceLifetime.Singleton);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddSecureConfig_ServiceCollection_ItShouldResolveSecureConfigFromDI()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddSecureConfig(config =>
|
||||
{
|
||||
config.UseCustomStorage(_mockStorageProvider.Object);
|
||||
config.WithCustomKeyProvider(_mockKeyProvider.Object);
|
||||
config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object);
|
||||
});
|
||||
|
||||
var serviceProvider = services.BuildServiceProvider();
|
||||
var secureConfig = serviceProvider.GetService<ISecureConfig>();
|
||||
|
||||
secureConfig.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddSecureConfig_ServiceCollection_ItShouldResolveSameSecureConfigInstance()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddSecureConfig(config =>
|
||||
{
|
||||
config.UseCustomStorage(_mockStorageProvider.Object);
|
||||
config.WithCustomKeyProvider(_mockKeyProvider.Object);
|
||||
config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object);
|
||||
});
|
||||
|
||||
var serviceProvider = services.BuildServiceProvider();
|
||||
var instance1 = serviceProvider.GetRequiredService<ISecureConfig>();
|
||||
var instance2 = serviceProvider.GetRequiredService<ISecureConfig>();
|
||||
|
||||
instance1.Should().BeSameAs(instance2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddSecureConfig_ServiceCollection_ItShouldResolveSameStorageProviderInstance()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddSecureConfig(config =>
|
||||
{
|
||||
config.UseCustomStorage(_mockStorageProvider.Object);
|
||||
config.WithCustomKeyProvider(_mockKeyProvider.Object);
|
||||
config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object);
|
||||
});
|
||||
|
||||
var serviceProvider = services.BuildServiceProvider();
|
||||
var instance1 = serviceProvider.GetRequiredService<ISecureStorageProvider>();
|
||||
var instance2 = serviceProvider.GetRequiredService<ISecureStorageProvider>();
|
||||
|
||||
instance1.Should().BeSameAs(_mockStorageProvider.Object);
|
||||
instance2.Should().BeSameAs(_mockStorageProvider.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddSecureConfig_ServiceCollection_ItShouldResolveSameKeyProviderInstance()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddSecureConfig(config =>
|
||||
{
|
||||
config.UseCustomStorage(_mockStorageProvider.Object);
|
||||
config.WithCustomKeyProvider(_mockKeyProvider.Object);
|
||||
config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object);
|
||||
});
|
||||
|
||||
var serviceProvider = services.BuildServiceProvider();
|
||||
var instance1 = serviceProvider.GetRequiredService<IEncryptionKeyProvider>();
|
||||
var instance2 = serviceProvider.GetRequiredService<IEncryptionKeyProvider>();
|
||||
|
||||
instance1.Should().BeSameAs(_mockKeyProvider.Object);
|
||||
instance2.Should().BeSameAs(_mockKeyProvider.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddSecureConfig_ServiceCollection_ItShouldResolveSameCryptoProviderInstance()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddSecureConfig(config =>
|
||||
{
|
||||
config.UseCustomStorage(_mockStorageProvider.Object);
|
||||
config.WithCustomKeyProvider(_mockKeyProvider.Object);
|
||||
config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object);
|
||||
});
|
||||
|
||||
var serviceProvider = services.BuildServiceProvider();
|
||||
var instance1 = serviceProvider.GetRequiredService<ICryptoProvider>();
|
||||
var instance2 = serviceProvider.GetRequiredService<ICryptoProvider>();
|
||||
|
||||
instance1.Should().BeSameAs(_mockCryptoProvider.Object);
|
||||
instance2.Should().BeSameAs(_mockCryptoProvider.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddSecureConfig_ServiceCollection_WithRealAesCryptoProvider_ItShouldWorkEndToEnd()
|
||||
{
|
||||
using var tempDir = new TempDirectory();
|
||||
var filePath = Path.Combine(tempDir.Path, "test_config.json");
|
||||
|
||||
var keyBytes = new byte[32];
|
||||
RandomNumberGenerator.Fill(keyBytes);
|
||||
var base64Key = Convert.ToBase64String(keyBytes);
|
||||
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddSecureConfig(config =>
|
||||
{
|
||||
config.AddJsonAotContext(SecureConfigExtensionsTestsJsonContext.Default);
|
||||
config.UseJsonFileStorage(options =>
|
||||
{
|
||||
options.DirectoryPath = tempDir.Path;
|
||||
options.FileName = "test_config.json";
|
||||
});
|
||||
config.WithBase64EncryptionKey(base64Key);
|
||||
config.WithAesCryptoProvider();
|
||||
});
|
||||
|
||||
var serviceProvider = services.BuildServiceProvider();
|
||||
var secureConfig = serviceProvider.GetRequiredService<ISecureConfig>();
|
||||
var storageProvider = serviceProvider.GetRequiredService<ISecureStorageProvider>();
|
||||
|
||||
var testObject = new TestConfig { Name = "TestName", Value = 123 };
|
||||
await secureConfig.SetAsync("TestKey", testObject);
|
||||
|
||||
var retrievedObject = await secureConfig.GetAsync<TestConfig>("TestKey");
|
||||
|
||||
retrievedObject.Should().NotBeNull();
|
||||
retrievedObject.Name.Should().Be("TestName");
|
||||
retrievedObject.Value.Should().Be(123);
|
||||
|
||||
File.Exists(filePath).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddSecureConfig_ServiceCollection_WithMachineIdKey_ItShouldWorkEndToEnd()
|
||||
{
|
||||
using var tempDir = new TempDirectory();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddSecureConfig(config =>
|
||||
{
|
||||
config.AddJsonAotContext(SecureConfigExtensionsTestsJsonContext.Default);
|
||||
config.UseJsonFileStorage(options =>
|
||||
{
|
||||
options.DirectoryPath = tempDir.Path;
|
||||
options.FileName = "test_config.json";
|
||||
});
|
||||
config.WithMachineIdKey();
|
||||
config.WithAesCryptoProvider();
|
||||
});
|
||||
|
||||
var serviceProvider = services.BuildServiceProvider();
|
||||
var secureConfig = serviceProvider.GetRequiredService<ISecureConfig>();
|
||||
|
||||
var testObject = new TestConfig { Name = "MachineIdTest", Value = 456 };
|
||||
await secureConfig.SetAsync("MachineTest", testObject);
|
||||
|
||||
var retrievedObject = await secureConfig.GetAsync<TestConfig>("MachineTest");
|
||||
|
||||
retrievedObject.Should().NotBeNull();
|
||||
retrievedObject.Name.Should().Be("MachineIdTest");
|
||||
retrievedObject.Value.Should().Be(456);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddSecureConfig_ServiceCollection_ItShouldRegisterJsonSerializerOptions()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddSecureConfig(config =>
|
||||
{
|
||||
config.UseCustomStorage(_mockStorageProvider.Object);
|
||||
config.WithCustomKeyProvider(_mockKeyProvider.Object);
|
||||
config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object);
|
||||
});
|
||||
|
||||
var serviceProvider = services.BuildServiceProvider();
|
||||
var keyedService = serviceProvider.GetKeyedService<JsonSerializerOptions>("SecureConfigJsonSerializerOptions");
|
||||
|
||||
keyedService.Should().NotBeNull();
|
||||
keyedService.PropertyNameCaseInsensitive.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddSecureConfig_CombinedWithOtherProviders_ItShouldMergeConfiguration()
|
||||
{
|
||||
var encryptedValue = "encrypted_value";
|
||||
var decryptedJson = /*lang=json,strict*/ @"{ ""SecureSetting"": ""SecureValue"" }";
|
||||
|
||||
_mockStorageProvider
|
||||
.Setup(s => s.ReadAllAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new Dictionary<string, string> { { "SecureSection", encryptedValue } });
|
||||
|
||||
_mockCryptoProvider
|
||||
.Setup(c => c.Decrypt(encryptedValue))
|
||||
.Returns(decryptedJson);
|
||||
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["RegularSetting"] = "RegularValue",
|
||||
["AnotherSetting"] = "AnotherValue"
|
||||
})
|
||||
.AddSecureConfig(config =>
|
||||
{
|
||||
config.UseCustomStorage(_mockStorageProvider.Object);
|
||||
config.WithCustomKeyProvider(_mockKeyProvider.Object);
|
||||
config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object);
|
||||
})
|
||||
.Build();
|
||||
|
||||
configuration["RegularSetting"].Should().Be("RegularValue");
|
||||
configuration["AnotherSetting"].Should().Be("AnotherValue");
|
||||
configuration["SecureSection:SecureSetting"].Should().Be("SecureValue");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddSecureConfig_WithNestedConfiguration_ItShouldFlattenCorrectly()
|
||||
{
|
||||
var encryptedValue = "encrypted_nested";
|
||||
var decryptedJson = /*lang=json,strict*/ @"{
|
||||
""Level1"": {
|
||||
""Level2"": {
|
||||
""Setting"": ""NestedValue""
|
||||
}
|
||||
}
|
||||
}";
|
||||
|
||||
_mockStorageProvider
|
||||
.Setup(s => s.ReadAllAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new Dictionary<string, string> { { "Nested", encryptedValue } });
|
||||
|
||||
_mockCryptoProvider
|
||||
.Setup(c => c.Decrypt(encryptedValue))
|
||||
.Returns(decryptedJson);
|
||||
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddSecureConfig(config =>
|
||||
{
|
||||
config.UseCustomStorage(_mockStorageProvider.Object);
|
||||
config.WithCustomKeyProvider(_mockKeyProvider.Object);
|
||||
config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object);
|
||||
})
|
||||
.Build();
|
||||
|
||||
configuration["Nested:Level1:Level2:Setting"].Should().Be("NestedValue");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddSecureConfig_WithArrays_ItShouldIndexCorrectly()
|
||||
{
|
||||
var encryptedValue = "encrypted_array";
|
||||
var decryptedJson = /*lang=json,strict*/ @"{ ""Items"": [""First"", ""Second"", ""Third""] }";
|
||||
|
||||
_mockStorageProvider
|
||||
.Setup(s => s.ReadAllAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new Dictionary<string, string> { { "ArraySection", encryptedValue } });
|
||||
|
||||
_mockCryptoProvider
|
||||
.Setup(c => c.Decrypt(encryptedValue))
|
||||
.Returns(decryptedJson);
|
||||
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddSecureConfig(config =>
|
||||
{
|
||||
config.UseCustomStorage(_mockStorageProvider.Object);
|
||||
config.WithCustomKeyProvider(_mockKeyProvider.Object);
|
||||
config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object);
|
||||
})
|
||||
.Build();
|
||||
|
||||
configuration["ArraySection:Items:0"].Should().Be("First");
|
||||
configuration["ArraySection:Items:1"].Should().Be("Second");
|
||||
configuration["ArraySection:Items:2"].Should().Be("Third");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddSecureConfig_WithMultipleSections_ItShouldLoadAllSections()
|
||||
{
|
||||
var encrypted1 = "encrypted1";
|
||||
var encrypted2 = "encrypted2";
|
||||
var decrypted1 = /*lang=json,strict*/ @"{ ""Setting1"": ""Value1"" }";
|
||||
var decrypted2 = /*lang=json,strict*/ @"{ ""Setting2"": ""Value2"" }";
|
||||
|
||||
_mockStorageProvider
|
||||
.Setup(s => s.ReadAllAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new Dictionary<string, string>
|
||||
{
|
||||
{ "Section1", encrypted1 },
|
||||
{ "Section2", encrypted2 }
|
||||
});
|
||||
|
||||
_mockCryptoProvider
|
||||
.Setup(c => c.Decrypt("encrypted1"))
|
||||
.Returns(decrypted1);
|
||||
|
||||
_mockCryptoProvider
|
||||
.Setup(c => c.Decrypt("encrypted2"))
|
||||
.Returns(decrypted2);
|
||||
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddSecureConfig(config =>
|
||||
{
|
||||
config.UseCustomStorage(_mockStorageProvider.Object);
|
||||
config.WithCustomKeyProvider(_mockKeyProvider.Object);
|
||||
config.WithCustomCryptoProvider((kp) => _mockCryptoProvider.Object);
|
||||
})
|
||||
.Build();
|
||||
|
||||
configuration["Section1:Setting1"].Should().Be("Value1");
|
||||
configuration["Section2:Setting2"].Should().Be("Value2");
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TestConfig
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public int Value { get; set; }
|
||||
}
|
||||
|
||||
[JsonSerializable(typeof(TestConfig))]
|
||||
internal partial class SecureConfigExtensionsTestsJsonContext : JsonSerializerContext
|
||||
{
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
using StevanFreeborn.Extensions.Configuration.Secure.Storage;
|
||||
using StevanFreeborn.Extensions.Configuration.Secure.Tests.Common;
|
||||
|
||||
namespace StevanFreeborn.Extensions.Configuration.Secure.Tests.Unit.Storage;
|
||||
|
||||
public class JsonFileStorageProviderTests : IDisposable
|
||||
{
|
||||
private readonly TempDirectory _tempDir = new();
|
||||
private readonly JsonStorageOptions _options;
|
||||
private readonly JsonFileStorageProvider _sut;
|
||||
|
||||
public JsonFileStorageProviderTests()
|
||||
{
|
||||
_options = new()
|
||||
{
|
||||
FileName = "testsettings.json",
|
||||
DirectoryPath = _tempDir.Path,
|
||||
};
|
||||
|
||||
_sut = new(_options);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WhenCalledWithNullOptions_ItShouldThrowArgumentNullException()
|
||||
{
|
||||
var act = static () => new JsonFileStorageProvider(null!);
|
||||
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAsync_And_ReadAsync_WhenCalled_ItShouldBeAbleToPersistAndRetrieveValues()
|
||||
{
|
||||
var configKey = "KeyA";
|
||||
var configValue = "Value";
|
||||
|
||||
await _sut.WriteAsync(configKey, configValue);
|
||||
var result = await _sut.ReadAsync(configKey);
|
||||
|
||||
result.Should().Be(configValue);
|
||||
File.Exists(_options.FullPath).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAllAsync_WhenCalled_ItShouldReturnAllStoredKeys()
|
||||
{
|
||||
await _sut.WriteAsync("Key1", "Val1");
|
||||
await _sut.WriteAsync("Key2", "Val2");
|
||||
|
||||
var allData = await _sut.ReadAllAsync();
|
||||
|
||||
allData.Should().BeEquivalentTo(new Dictionary<string, string>()
|
||||
{
|
||||
["Key1"] = "Val1",
|
||||
["Key2"] = "Val2",
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_WhenCalled_ItShouldRemoveKey()
|
||||
{
|
||||
await _sut.WriteAsync("KeyToDelete", "SomeValue");
|
||||
|
||||
var deleteResult = await _sut.DeleteAsync("KeyToDelete");
|
||||
var readResult = await _sut.ReadAsync("KeyToDelete");
|
||||
|
||||
deleteResult.Should().BeTrue();
|
||||
readResult.Should().BeEmpty();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_tempDir.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAsync_WhenCalledConcurrently_ItShouldNotThrowFileInUseException()
|
||||
{
|
||||
const int numberOfWrites = 50;
|
||||
var tasks = new List<Task>();
|
||||
|
||||
foreach (var index in Enumerable.Range(0, numberOfWrites))
|
||||
{
|
||||
tasks.Add(Task.Run(() => _sut.WriteAsync($"Key{index}", $"Val{index}")));
|
||||
}
|
||||
|
||||
var act = async () => await Task.WhenAll(tasks);
|
||||
|
||||
await act.Should().NotThrowAsync();
|
||||
|
||||
var allData = await _sut.ReadAllAsync();
|
||||
allData.Should().HaveCount(numberOfWrites);
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
using StevanFreeborn.Extensions.Configuration.Secure.Storage;
|
||||
|
||||
namespace StevanFreeborn.Extensions.Configuration.Secure.Tests.Unit.Storage;
|
||||
|
||||
public class JsonStorageOptionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Path_WhenCalledWithFileNameSet_ItShouldReturnExpectedPath()
|
||||
{
|
||||
var fileName = "appsettings.json";
|
||||
var expectedPath = Path.Combine(AppContext.BaseDirectory, fileName);
|
||||
|
||||
var opts = new JsonStorageOptions()
|
||||
{
|
||||
FileName = fileName,
|
||||
};
|
||||
|
||||
opts.FullPath.Should().Be(expectedPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Path_WhenCalledWithFileNameAndDirectoryPathSet_ItShouldReturnExpectedPath()
|
||||
{
|
||||
var fileName = "appsettings.json";
|
||||
var directoryPath = @"C:\Path\To\Some\Directory";
|
||||
var expectedPath = Path.Combine(directoryPath, fileName);
|
||||
|
||||
var opts = new JsonStorageOptions()
|
||||
{
|
||||
FileName = fileName,
|
||||
DirectoryPath = directoryPath,
|
||||
};
|
||||
|
||||
opts.FullPath.Should().Be(expectedPath);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user