using MemberCenter.Infrastructure.Configuration; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.Extensions.Configuration; using Xunit; namespace MemberCenter.Security.Tests; public sealed class SecurityConfigurationTests { [Theory] [InlineData("https://example.com/app", true)] [InlineData("https://example.com/app/callback?code=1", true)] [InlineData("https://example.com/application", false)] [InlineData("https://example.com.attacker.test/app", false)] [InlineData("https://example.com:444/app", false)] [InlineData("https://user@example.com/app", false)] public void ReturnUrlRequiresMatchingOriginAndPathBoundary(string candidate, bool expected) { var result = ReturnUrlValidator.IsWithinAllowedPrefix( new Uri(candidate), "https://example.com/app", allowInsecureHttp: false); Assert.Equal(expected, result); } [Fact] public void ReturnUrlRequiresHttpsWhenInsecureHttpIsDisabled() { Assert.False(ReturnUrlValidator.IsWithinAllowedPrefix( new Uri("http://example.com/app"), "http://example.com/app", allowInsecureHttp: false)); } [Fact] public void ForwardedHeadersFailClosedWithoutTrustedProxy() { var options = new ForwardedHeadersOptions(); TrustedForwardedHeaders.Configure(options, Configuration()); Assert.Equal(ForwardedHeaders.None, options.ForwardedHeaders); Assert.Empty(options.KnownNetworks); Assert.Empty(options.KnownProxies); } [Fact] public void TrustedProxyAllowlistIncludesForwardedHost() { var options = new ForwardedHeadersOptions(); TrustedForwardedHeaders.Configure(options, Configuration(new() { ["ReverseProxy:KnownProxies"] = "10.0.0.10", ["ReverseProxy:KnownNetworks"] = "10.1.0.0/16" })); Assert.Equal( ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost, options.ForwardedHeaders); Assert.Single(options.KnownProxies); Assert.Single(options.KnownNetworks); } [Fact] public void ManagedProxyModeIncludesForwardedHostForExternalUrlGeneration() { var options = new ForwardedHeadersOptions(); TrustedForwardedHeaders.Configure(options, Configuration(new() { ["ReverseProxy:TrustForwardedHeaders"] = "true", ["ReverseProxy:ForwardLimit"] = "1" })); Assert.Equal( ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost, options.ForwardedHeaders); Assert.Equal(1, options.ForwardLimit); Assert.Empty(options.KnownProxies); Assert.Empty(options.KnownNetworks); } [Theory] [InlineData("0.0.0.0/0")] [InlineData("::/0")] [InlineData("not-a-network")] [InlineData("10.0.0.0/33")] public void InvalidOrGlobalProxyNetworkIsRejected(string network) { var options = new ForwardedHeadersOptions(); Assert.Throws(() => TrustedForwardedHeaders.Configure( options, Configuration(new() { ["ReverseProxy:KnownNetworks"] = network }))); } [Fact] public void RequiredCertificateMustBeConfigured() { Assert.Throws(() => CertificateLoader.LoadFromConfiguration( Configuration(), "Auth:Certificates:Signing", required: true)); } [Fact] public void MissingCertificateFileIsRejected() { Assert.Throws(() => CertificateLoader.LoadFromConfiguration( Configuration(new() { ["Auth:Certificates:Signing:Path"] = "/missing/signing.pfx" }), "Auth:Certificates:Signing", required: true)); } [Fact] public void SecurityOptionDefaultsRemainStable() { var identity = new IdentitySecurityOptions(); Assert.Equal(8, identity.Password.RequiredLength); Assert.True(identity.Password.RequireDigit); Assert.True(identity.Password.RequireLowercase); Assert.True(identity.Password.RequireUppercase); Assert.False(identity.Password.RequireNonAlphanumeric); Assert.Equal(5, identity.Lockout.MaxFailedAccessAttempts); Assert.Equal(15, identity.Lockout.DefaultLockoutMinutes); var newsletter = new NewsletterTokenOptions(); Assert.Equal(7, newsletter.ConfirmTokenLifetimeDays); Assert.Equal(7, newsletter.UnsubscribeTokenLifetimeDays); Assert.Equal(7, newsletter.OneClickTokenLifetimeDays); Assert.Equal(1000, newsletter.OneClickBatchSizeLimit); var fileAccess = new FileAccessTokenOptions(); Assert.Equal(30, fileAccess.MinimumLifetimeSeconds); Assert.Equal(300, fileAccess.DefaultLifetimeSeconds); Assert.Equal(900, fileAccess.MaximumLifetimeSeconds); } [Fact] public void InvalidSecurityOptionsAreRejected() { Assert.Throws(() => SecurityRuntimeOptionsValidator.Validate(new IdentitySecurityOptions { Password = new PasswordPolicyOptions { RequiredLength = 7 } })); Assert.Throws(() => SecurityRuntimeOptionsValidator.Validate(new FileAccessTokenOptions { MinimumLifetimeSeconds = 60, DefaultLifetimeSeconds = 30, MaximumLifetimeSeconds = 900 })); } private static IConfiguration Configuration(Dictionary? values = null) => new ConfigurationBuilder().AddInMemoryCollection(values).Build(); }