using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using ScadaLink.AuditLog.Configuration; namespace ScadaLink.AuditLog.Tests.Configuration; /// /// Task 9 (Bundle E): binding + validator /// behavior. The validator enforces invariants used by M2+ writers /// (per docs/plans/2026-05-20-auditlog-m1-foundation.md): /// DefaultCapBytes > 0, ErrorCapBytes >= DefaultCapBytes, /// RetentionDays in [30, 3650]. Header-redact defaults match the /// design doc (alog.md ยง6): Authorization, X-Api-Key, Cookie, Set-Cookie. /// public class AuditLogOptionsTests { private static IOptions BuildOptions(Dictionary config) { var configuration = new ConfigurationBuilder() .AddInMemoryCollection(config) .Build(); var services = new ServiceCollection(); services.AddAuditLog(configuration); return services.BuildServiceProvider().GetRequiredService>(); } [Fact] public void ValidBinding_PopulatesAllScalarFields() { var opts = BuildOptions(new Dictionary { ["AuditLog:DefaultCapBytes"] = "4096", ["AuditLog:ErrorCapBytes"] = "32768", ["AuditLog:RetentionDays"] = "180", }).Value; Assert.Equal(4096, opts.DefaultCapBytes); Assert.Equal(32768, opts.ErrorCapBytes); Assert.Equal(180, opts.RetentionDays); } [Fact] public void DefaultsAreReasonable_WhenSectionEmpty() { var opts = BuildOptions(new Dictionary()).Value; Assert.Equal(8192, opts.DefaultCapBytes); Assert.Equal(65536, opts.ErrorCapBytes); Assert.Equal(365, opts.RetentionDays); Assert.Contains("Authorization", opts.HeaderRedactList); Assert.Contains("X-Api-Key", opts.HeaderRedactList); Assert.Contains("Cookie", opts.HeaderRedactList); Assert.Contains("Set-Cookie", opts.HeaderRedactList); Assert.Empty(opts.GlobalBodyRedactors); Assert.Empty(opts.PerTargetOverrides); } [Fact] public void HeaderRedactList_BindsFromConfig_AppendsToDefaults() { // Microsoft.Extensions.Configuration's collection binder appends to a // defaulted list (it does not replace it), so config-supplied entries // augment the built-in redact list rather than overriding it. The // built-in entries are the safety-net defaults documented on // AuditLogOptions; supplying additional headers is the supported // extension point. var opts = BuildOptions(new Dictionary { ["AuditLog:HeaderRedactList:0"] = "X-Custom-Auth", ["AuditLog:HeaderRedactList:1"] = "X-Tenant-Id", }).Value; Assert.Contains("X-Custom-Auth", opts.HeaderRedactList); Assert.Contains("X-Tenant-Id", opts.HeaderRedactList); Assert.Contains("Authorization", opts.HeaderRedactList); Assert.Contains("X-Api-Key", opts.HeaderRedactList); } [Fact] public void PerTargetOverrides_BindsFromConfig() { var opts = BuildOptions(new Dictionary { ["AuditLog:PerTargetOverrides:CRM:CapBytes"] = "16384", ["AuditLog:PerTargetOverrides:CRM:AdditionalBodyRedactors:0"] = @"\d{16}", }).Value; Assert.True(opts.PerTargetOverrides.ContainsKey("CRM")); var crm = opts.PerTargetOverrides["CRM"]; Assert.Equal(16384, crm.CapBytes); Assert.NotNull(crm.AdditionalBodyRedactors); Assert.Contains(@"\d{16}", crm.AdditionalBodyRedactors!); } [Fact] public void InvalidDefaultCapBytes_FailsValidation() { var result = new AuditLogOptionsValidator().Validate( Options.DefaultName, new AuditLogOptions { DefaultCapBytes = 0 }); Assert.True(result.Failed); Assert.Contains("DefaultCapBytes", result.FailureMessage); } [Fact] public void InvalidErrorCapBytes_FailsValidation() { var result = new AuditLogOptionsValidator().Validate( Options.DefaultName, new AuditLogOptions { DefaultCapBytes = 1000, ErrorCapBytes = 100 }); Assert.True(result.Failed); Assert.Contains("ErrorCapBytes", result.FailureMessage); } [Fact] public void RetentionDaysBelowMinimum_FailsValidation() { var result = new AuditLogOptionsValidator().Validate( Options.DefaultName, new AuditLogOptions { RetentionDays = 0 }); Assert.True(result.Failed); Assert.Contains("RetentionDays", result.FailureMessage); } [Fact] public void RetentionDaysAboveMaximum_FailsValidation() { var result = new AuditLogOptionsValidator().Validate( Options.DefaultName, new AuditLogOptions { RetentionDays = 3651 }); Assert.True(result.Failed); Assert.Contains("RetentionDays", result.FailureMessage); } [Fact] public void DefaultOptions_PassValidation() { var result = new AuditLogOptionsValidator().Validate( Options.DefaultName, new AuditLogOptions()); Assert.True(result.Succeeded, result.FailureMessage); } [Fact] public void InvalidRetention_BoundViaConfig_RejectedOnValueAccess() { var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["AuditLog:RetentionDays"] = "0", }) .Build(); var services = new ServiceCollection(); services.AddAuditLog(configuration); var provider = services.BuildServiceProvider(); var opts = provider.GetRequiredService>(); var ex = Assert.Throws(() => _ = opts.Value); Assert.Contains("RetentionDays", ex.Message); } }