member_center/tests/MemberCenter.Security.Tests/SecurityConfigurationTests.cs

153 lines
5.6 KiB
C#

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<InvalidOperationException>(() => TrustedForwardedHeaders.Configure(
options, Configuration(new() { ["ReverseProxy:KnownNetworks"] = network })));
}
[Fact]
public void RequiredCertificateMustBeConfigured()
{
Assert.Throws<InvalidOperationException>(() => CertificateLoader.LoadFromConfiguration(
Configuration(), "Auth:Certificates:Signing", required: true));
}
[Fact]
public void MissingCertificateFileIsRejected()
{
Assert.Throws<InvalidOperationException>(() => 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<InvalidOperationException>(() => SecurityRuntimeOptionsValidator.Validate(new IdentitySecurityOptions
{
Password = new PasswordPolicyOptions { RequiredLength = 7 }
}));
Assert.Throws<InvalidOperationException>(() => SecurityRuntimeOptionsValidator.Validate(new FileAccessTokenOptions
{
MinimumLifetimeSeconds = 60,
DefaultLifetimeSeconds = 30,
MaximumLifetimeSeconds = 900
}));
}
private static IConfiguration Configuration(Dictionary<string, string?>? values = null) =>
new ConfigurationBuilder().AddInMemoryCollection(values).Build();
}