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 ####
|
#### Core EditorConfig Options ####
|
||||||
|
|
||||||
# Indentation and spacing
|
# Indentation and spacing
|
||||||
indent_size = 4
|
indent_size = 2
|
||||||
tab_width = 4
|
tab_width = 2
|
||||||
|
|
||||||
# New line preferences
|
# New line preferences
|
||||||
insert_final_newline = false
|
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_collection_expression = when_types_loosely_match:suggestion
|
||||||
dotnet_style_prefer_compound_assignment = true:suggestion
|
dotnet_style_prefer_compound_assignment = true:suggestion
|
||||||
dotnet_style_prefer_conditional_expression_over_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_foreach_explicit_cast_in_source = when_strongly_typed:suggestion
|
||||||
dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion
|
dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion
|
||||||
dotnet_style_prefer_inferred_tuple_names = true:suggestion
|
dotnet_style_prefer_inferred_tuple_names = true:suggestion
|
||||||
@@ -90,10 +90,13 @@ dotnet_remove_unnecessary_suppression_exclusions = none
|
|||||||
#### C# Coding Conventions ####
|
#### C# Coding Conventions ####
|
||||||
[*.cs]
|
[*.cs]
|
||||||
|
|
||||||
|
dotnet_diagnostic.IDE0058.severity = none
|
||||||
|
dotnet_diagnostic.IDE0100.severity = none
|
||||||
|
|
||||||
# var preferences
|
# var preferences
|
||||||
csharp_style_var_elsewhere = false:silent
|
csharp_style_var_elsewhere = true:suggestion
|
||||||
csharp_style_var_for_built_in_types = false:silent
|
csharp_style_var_for_built_in_types = true:suggestion
|
||||||
csharp_style_var_when_type_is_apparent = false:silent
|
csharp_style_var_when_type_is_apparent = true:suggestion
|
||||||
|
|
||||||
# Expression-bodied members
|
# Expression-bodied members
|
||||||
csharp_style_expression_bodied_accessors = true:silent
|
csharp_style_expression_bodied_accessors = true:silent
|
||||||
|
|||||||
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"cSpell.words": [
|
||||||
|
"ioreg",
|
||||||
|
"netstandard"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,2 +1,9 @@
|
|||||||
<Solution>
|
<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>
|
</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