elsa-core/test/unit/Elsa.ExternalAuthentication.UnitTests/Foundational/ExternalAuthenticationOptionsTests.cs
2026-07-25 03:48:50 +02:00

167 lines
8.9 KiB
C#

using Elsa.ExternalAuthentication.Contracts;
using Elsa.ExternalAuthentication.Models;
using Elsa.ExternalAuthentication.Options;
using Elsa.ExternalAuthentication.Validation;
namespace Elsa.ExternalAuthentication.UnitTests.Foundational;
public class ExternalAuthenticationOptionsTests
{
[Fact]
public void DefaultsFavorTheMostRestrictiveOperationalSettings()
{
var options = new ExternalAuthenticationOptions();
Assert.True(options.EnableDatabaseConnections);
Assert.Equal(TimeSpan.FromMinutes(10), options.Lifetimes.BrokerTransactionLifetime);
Assert.Equal(TimeSpan.FromMinutes(1), options.Lifetimes.CompletionCodeLifetime);
Assert.Equal(64, options.Claims.MaximumClaimCount);
Assert.Equal(1_024, options.Claims.MaximumValueLength);
Assert.Equal(16 * 1_024, options.Claims.MaximumTotalBytes);
Assert.True(options.ProviderEgress.RequireHttps);
Assert.False(options.ProviderEgress.AllowPrivateNetworkDestinations);
Assert.Equal(3, options.ProviderEgress.MaximumRedirects);
Assert.True(options.Redirects.RequirePkceS256);
Assert.False(options.Redirects.AllowDevelopmentLoopbackCallbacks);
Assert.Equal(BrowserCredentialPersistence.Memory, options.WebAssemblyPersistence.Persistence);
Assert.True(options.WebAssemblyPersistence.RequireExplicitPersistentStorageWarning);
Assert.True(options.FinalLoginPathGuard.IsEnabled);
Assert.True(options.FinalLoginPathGuard.RequireRecoveryMethod);
}
[Fact]
public void ValidatorRejectsDuplicateOrUnavailableExtensionTypes()
{
var options = new ExternalAuthenticationOptions
{
AllowedAdapterTypes = ["oidc", "oidc", "saml"],
AllowedUnlinkedIdentityPolicyTypes = ["reject"],
AllowedPermissionGrantSourceTypes = ["elsa-roles"]
};
var validator = CreateValidator(adapters: [new TestAdapter("oidc"), new TestAdapter("oidc")]);
var result = validator.Validate(null, options);
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!, failure => failure.Contains("adapter type 'oidc' is registered more than once", StringComparison.Ordinal));
Assert.Contains(result.Failures!, failure => failure.Contains("allowed adapter type 'oidc' is configured more than once", StringComparison.Ordinal));
Assert.Contains(result.Failures!, failure => failure.Contains("allowed adapter type 'saml' is not installed", StringComparison.Ordinal));
}
[Fact]
public void ValidatorRejectsInsecureClientRegistrations()
{
var options = CreateValidOptions();
options.Clients =
[
new AuthenticationClient(
"studio",
"Studio",
AuthenticationClientType.Public,
new HashSet<Uri> { new("http://studio.example/callback") },
new HashSet<Uri>(),
new HashSet<string> { "https://studio.example/" },
new HashSet<string>(),
new SecretBinding("test", "must-not-be-set"),
true)
];
var result = CreateValidator().Validate(null, options);
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!, failure => failure.Contains("invalid callback URI", StringComparison.Ordinal));
Assert.Contains(result.Failures!, failure => failure.Contains("must not define a client secret binding", StringComparison.Ordinal));
Assert.Contains(result.Failures!, failure => failure.Contains("must not contain a path, query, or trailing slash", StringComparison.Ordinal));
}
[Fact]
public void ValidatorRejectsUnsafeProviderEgressConfiguration()
{
var options = CreateValidOptions();
options.ProviderEgress = new ProviderEgressOptions
{
MaximumRedirects = -1,
ConnectTimeout = TimeSpan.Zero,
RequestTimeout = TimeSpan.Zero,
MaximumDiscoveryResponseBytes = 0,
MaximumTokenResponseBytes = 0,
MaximumUserInfoResponseBytes = 0,
AllowedHosts = ["*.example.test"],
ProxyUri = new Uri("ftp://user:password@proxy.example")
};
var result = CreateValidator().Validate(null, options);
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!, failure => failure.Contains("egress maximum redirects", StringComparison.Ordinal));
Assert.Contains(result.Failures!, failure => failure.Contains("egress timeouts", StringComparison.Ordinal));
Assert.Contains(result.Failures!, failure => failure.Contains("response-size limits", StringComparison.Ordinal));
Assert.Contains(result.Failures!, failure => failure.Contains("allowed host", StringComparison.Ordinal));
Assert.Contains(result.Failures!, failure => failure.Contains("proxy URI", StringComparison.Ordinal));
}
[Fact]
public void ValidatorRejectsConfigurationCollisionsAndUnknownAdapterTypes()
{
var options = CreateValidOptions();
var unknownAdapter = ExternalAuthenticationTestData.CreateConnection("unknown", "tenant-b", "partner");
unknownAdapter.AdapterType = "saml";
options.ConfigurationConnections =
[
ExternalAuthenticationTestData.CreateConnection("host", ConnectionScope.HostTenantId, "corp"),
ExternalAuthenticationTestData.CreateConnection("tenant", "tenant-a", " CORP "),
unknownAdapter
];
var result = CreateValidator().Validate(null, options);
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!, failure => failure.Contains("must use the host scope", StringComparison.Ordinal));
Assert.Contains(result.Failures!, failure => failure.Contains("configured more than once", StringComparison.Ordinal));
Assert.Contains(result.Failures!, failure => failure.Contains("selects adapter type 'saml', which is not installed", StringComparison.Ordinal));
}
private static ExternalAuthenticationOptions CreateValidOptions() => new()
{
AllowedAdapterTypes = ["oidc"],
AllowedUnlinkedIdentityPolicyTypes = ["reject"],
AllowedPermissionGrantSourceTypes = ["elsa-roles"]
};
private static ExternalAuthenticationOptionsValidator CreateValidator(
IEnumerable<IExternalAuthenticationAdapter>? adapters = null)
{
var extensions = new ExternalAuthenticationExtensionOptions();
foreach (var adapter in adapters ?? [new TestAdapter("oidc")])
extensions.Registrations.Add(new(ExternalAuthenticationExtensionKind.Adapter, adapter.Type));
extensions.Registrations.Add(new(ExternalAuthenticationExtensionKind.UnlinkedIdentityPolicy, "reject"));
extensions.Registrations.Add(new(ExternalAuthenticationExtensionKind.PermissionGrantSource, "elsa-roles"));
return new(Microsoft.Extensions.Options.Options.Create(extensions));
}
private sealed class TestAdapter(string type) : IExternalAuthenticationAdapter
{
public string Type => type;
public ExternalAuthenticationAdapterDescriptor Describe() => throw new NotSupportedException();
public ValueTask<ConnectionValidationResult> ValidateAsync(ConnectionValidationContext context, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public ValueTask<ExternalAuthorizationRequest> CreateAuthorizationRequestAsync(ExternalAuthorizationContext context, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public ValueTask<ExternalAuthenticationResult> AuthenticateCallbackAsync(ExternalCallbackContext context, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public ValueTask<ConnectionTestResult> TestAsync(ConnectionTestContext context, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public ValueTask<ExternalLogoutRequest?> CreateLogoutRequestAsync(ExternalLogoutContext context, CancellationToken cancellationToken = default) => throw new NotSupportedException();
}
private sealed class TestUnlinkedIdentityPolicy(string type) : IUnlinkedIdentityPolicy
{
public string Type => type;
public UnlinkedIdentityPolicyDescriptor Describe() => throw new NotSupportedException();
public ValueTask<UnlinkedIdentityDecision> EvaluateAsync(UnlinkedIdentityContext context, CancellationToken cancellationToken = default) => throw new NotSupportedException();
}
private sealed class TestPermissionGrantSource(string type) : IPermissionGrantSource
{
public string Type => type;
public PermissionGrantSourceDescriptor Describe() => throw new NotSupportedException();
public ValueTask<PermissionGrantResult> GetGrantsAsync(PermissionGrantContext context, CancellationToken cancellationToken = default) => throw new NotSupportedException();
}
}