diff --git a/UltimateAuth.slnx b/UltimateAuth.slnx index 9bef746e..5f659632 100644 --- a/UltimateAuth.slnx +++ b/UltimateAuth.slnx @@ -26,6 +26,7 @@ + diff --git a/src/CodeBeam.UltimateAuth.Core/AssemblyVisibility.cs b/src/CodeBeam.UltimateAuth.Core/AssemblyVisibility.cs index b346c2c9..284fd414 100644 --- a/src/CodeBeam.UltimateAuth.Core/AssemblyVisibility.cs +++ b/src/CodeBeam.UltimateAuth.Core/AssemblyVisibility.cs @@ -9,3 +9,4 @@ [assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore")] [assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Tests.Unit")] [assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Tests.Integration")] +[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Tests.Integration.EfCore")] diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshTokenPersistence.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshTokenPersistence.cs index a3cea858..332e0e34 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshTokenPersistence.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshTokenPersistence.cs @@ -3,15 +3,12 @@ public enum RefreshTokenPersistence { /// - /// Refresh token store'a yazılır. - /// Login, first-issue gibi normal akışlar için. + /// Refresh token persists to the store. /// Persist = 0, /// - /// Refresh token store'a yazılmaz. - /// Rotation gibi özel akışlarda, - /// caller tarafından kontrol edilir. + /// Refresh token does not persist to the store. /// DoNotPersist = 10 } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/IdentifierUniquenessResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/IdentifierUniquenessResolver.cs new file mode 100644 index 00000000..5d9b922f --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/IdentifierUniquenessResolver.cs @@ -0,0 +1,24 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +// TODO(policy): Move identifier uniqueness decision/enforcement to the Policy layer. +public static class IdentifierUniquenessResolver +{ + public static UniquenessScope GetScope(UAuthServerOptions options, UserIdentifierType type) + { + ArgumentNullException.ThrowIfNull(options); + + var uniqueness = options.Identifiers.Uniqueness; + + return type switch + { + UserIdentifierType.Username => uniqueness.Username, + UserIdentifierType.Email => uniqueness.Email, + UserIdentifierType.Phone => uniqueness.Phone, + _ => uniqueness.Custom + }; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/UserCreateValidator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/UserCreateValidator.cs index 3d16c55a..79c2bfb9 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/UserCreateValidator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/UserCreateValidator.cs @@ -1,17 +1,20 @@ using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Server.Services; using CodeBeam.UltimateAuth.Users; +using CodeBeam.UltimateAuth.Users.Contracts; namespace CodeBeam.UltimateAuth.Server.Infrastructure; public sealed class UserCreateValidator : IUserCreateValidator { private readonly IUserIdentifierValidator _identifierValidator; + private readonly IUserIdentifierAvailabilityService _identifierAvailability; private readonly IUserProfileValidator _profileValidator; - public UserCreateValidator(IUserIdentifierValidator identifierValidator, IUserProfileValidator profileValidator) + public UserCreateValidator(IUserIdentifierValidator identifierValidator, IUserIdentifierAvailabilityService identifierAvailability, IUserProfileValidator profileValidator) { _identifierValidator = identifierValidator; + _identifierAvailability = identifierAvailability; _profileValidator = profileValidator; } @@ -35,6 +38,23 @@ public async Task ValidateAsync(AccessContext context }, ct); errors.AddRange(r.Errors); + + if (r.IsValid) + { + var availability = await _identifierAvailability.CheckAsync( + context, + new CheckUserIdentifierAvailabilityRequest + { + Type = UserIdentifierType.Username, + Value = request.UserName + }, + ct); + + if (!availability.IsAvailable) + { + errors.Add(new UAuthValidationError("username_unavailable", "username")); + } + } } if (!string.IsNullOrWhiteSpace(request.Email)) @@ -46,6 +66,23 @@ public async Task ValidateAsync(AccessContext context }, ct); errors.AddRange(r.Errors); + + if (r.IsValid) + { + var availability = await _identifierAvailability.CheckAsync( + context, + new CheckUserIdentifierAvailabilityRequest + { + Type = UserIdentifierType.Email, + Value = request.Email + }, + ct); + + if (!availability.IsAvailable) + { + errors.Add(new UAuthValidationError("email_unavailable", "email")); + } + } } if (!string.IsNullOrWhiteSpace(request.Phone)) @@ -57,6 +94,25 @@ public async Task ValidateAsync(AccessContext context }, ct); errors.AddRange(r.Errors); + + if (r.IsValid) + { + // TODO: CheckAsync also validates identifiers, make them effective. + // TODO: This guard doesn't work with concurrent requests. + var availability = await _identifierAvailability.CheckAsync(context, + new CheckUserIdentifierAvailabilityRequest + { + Type = UserIdentifierType.Phone, + Value = request.Phone + }, + ct); + + if (!availability.IsAvailable) + { + errors.Add( + new UAuthValidationError("phone_unavailable", "phone")); + } + } } var effectiveDisplayName = diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserIdentifierAvailabilityService.cs b/src/CodeBeam.UltimateAuth.Server/Services/Abstractions/IUserIdentifierAvailabilityService.cs similarity index 86% rename from src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserIdentifierAvailabilityService.cs rename to src/CodeBeam.UltimateAuth.Server/Services/Abstractions/IUserIdentifierAvailabilityService.cs index 07f2e961..9e6cf4d8 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserIdentifierAvailabilityService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/Abstractions/IUserIdentifierAvailabilityService.cs @@ -1,7 +1,7 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Users.Contracts; -namespace CodeBeam.UltimateAuth.Users.Reference; +namespace CodeBeam.UltimateAuth.Server.Services; public interface IUserIdentifierAvailabilityService { diff --git a/src/bundle/CodeBeam.UltimateAuth.EntityFrameworkCore.Bundle/AssemblyVisibility.cs b/src/bundle/CodeBeam.UltimateAuth.EntityFrameworkCore.Bundle/AssemblyVisibility.cs new file mode 100644 index 00000000..ed166fcc --- /dev/null +++ b/src/bundle/CodeBeam.UltimateAuth.EntityFrameworkCore.Bundle/AssemblyVisibility.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Tests.Unit")] diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthLoginDispatch.razor b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthLoginDispatch.razor index af62ef65..183f19ce 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthLoginDispatch.razor +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthLoginDispatch.razor @@ -3,6 +3,7 @@ @namespace CodeBeam.UltimateAuth.Client.Blazor @using CodeBeam.UltimateAuth.Core.Defaults @using Microsoft.AspNetCore.WebUtilities +@inject IUAuthLoginPageResolver LoginPageResolver @inject NavigationManager Nav @code { @@ -20,7 +21,7 @@ ? value.ToString() : null; - var loginRoute = UAuthLoginPageDiscovery.Resolve(); + var loginRoute = LoginPageResolver.Resolve(); string target; string? safeReturnUrl = null; diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Extensions/ServiceCollectionExtensions.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Extensions/ServiceCollectionExtensions.cs index 5a8f3d95..17d6ea68 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Extensions/ServiceCollectionExtensions.cs +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Extensions/ServiceCollectionExtensions.cs @@ -52,6 +52,8 @@ private static IServiceCollection AddUltimateAuthClientBlazorInternal(this IServ services.AddScoped(); services.AddScoped(); + services.TryAddSingleton(); + services.AddAuthorizationCore(); return services; diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthLoginPageDiscovery.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthLoginPageDiscovery.cs index a4b93b3e..84cc3af5 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthLoginPageDiscovery.cs +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthLoginPageDiscovery.cs @@ -1,46 +1,191 @@ using Microsoft.AspNetCore.Components; -namespace CodeBeam.UltimateAuth.Client.Infrastructure; +namespace CodeBeam.UltimateAuth.Client.Blazor.Infrastructure; /// -/// Discovers the login page route by scanning for a component decorated with the [UAuthLoginPage] attribute. If no such component is found, it defaults to "/login". If multiple components are found, an exception is thrown. -/// The resolved route is cached for subsequent calls. +/// Discovers the login page route from the component decorated with +/// . /// public static class UAuthLoginPageDiscovery { + private const string DefaultLoginRoute = "/login"; + private static string? _cached; /// - /// Resolves the login page route by scanning for a component decorated with the [UAuthLoginPage] attribute. If no such component is found, it defaults to "/login". - /// If multiple components are found, an exception is thrown. + /// Resolves the login page route by scanning loaded assemblies for a component + /// decorated with . /// - /// - /// + /// + /// Route selection order: + /// + /// Preferred route explicitly configured on . + /// Root route (/). + /// Conventional login route (/login). + /// First route in deterministic ordinal-ignore-case order. + /// Default route (/login) when the component has no route. + /// + /// public static string Resolve() { - if (_cached != null) + if (_cached is not null) return _cached; - var assemblies = AppDomain.CurrentDomain.GetAssemblies(); + var candidates = AppDomain.CurrentDomain + .GetAssemblies() + .SelectMany(GetLoadableTypes) + .Where(HasLoginPageAttribute) + .ToArray(); + + if (candidates.Length == 0) + return _cached = DefaultLoginRoute; + + if (candidates.Length > 1) + { + throw new InvalidOperationException( + "Multiple [UAuthLoginPage] components were found. " + + "Make sure only one component is marked as the UltimateAuth login page."); + } + + return _cached = ResolveRoute(candidates[0]); + } + + internal static string ResolveRoute(Type componentType) + { + ArgumentNullException.ThrowIfNull(componentType); + + var loginPage = componentType + .GetCustomAttributes(typeof(UAuthLoginPageAttribute), inherit: true) + .Cast() + .SingleOrDefault(); + + if (loginPage is null) + { + throw new InvalidOperationException( + $"Component '{componentType.FullName}' is not decorated with [UAuthLoginPage]."); + } + + var routes = componentType + .GetCustomAttributes(typeof(RouteAttribute), inherit: true) + .Cast() + .Select(x => x.Template) + .ToArray(); + + return ResolveRoute( + loginPage, + routes, + componentType.FullName); + } + + internal static string ResolveRoute( + UAuthLoginPageAttribute loginPage, + IEnumerable routes, + string? componentName = null) + { + ArgumentNullException.ThrowIfNull(loginPage); + ArgumentNullException.ThrowIfNull(routes); + + var normalizedRoutes = routes + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Select(NormalizeRoute) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (!string.IsNullOrWhiteSpace(loginPage.PreferredRoute)) + { + var normalizedPreferred = + NormalizeRoute(loginPage.PreferredRoute); + + var preferred = normalizedRoutes.FirstOrDefault(x => + string.Equals( + x, + normalizedPreferred, + StringComparison.OrdinalIgnoreCase)); - var candidates = assemblies - .SelectMany(a => + if (preferred is null) { - try { return a.GetTypes(); } - catch { return Array.Empty(); } - }) - .Where(t => t.GetCustomAttributes(typeof(UAuthLoginPageAttribute), true).Any()) - .ToList(); + var componentDescription = + string.IsNullOrWhiteSpace(componentName) + ? "the login page component" + : $"component '{componentName}'"; + + throw new InvalidOperationException( + $"Preferred login route '{loginPage.PreferredRoute}' " + + $"is not defined on {componentDescription}."); + } + + return preferred; + } + + var root = normalizedRoutes.FirstOrDefault(x => + string.Equals( + x, + "/", + StringComparison.OrdinalIgnoreCase)); + + if (root is not null) + return root; + + var login = normalizedRoutes.FirstOrDefault(x => + string.Equals( + x, + DefaultLoginRoute, + StringComparison.OrdinalIgnoreCase)); + + if (login is not null) + return login; + + if (normalizedRoutes.Length > 0) + { + return normalizedRoutes + .OrderBy(x => x, StringComparer.OrdinalIgnoreCase) + .First(); + } + + return DefaultLoginRoute; + } + + private static IEnumerable GetLoadableTypes( + System.Reflection.Assembly assembly) + { + try + { + return assembly.GetTypes(); + } + catch (System.Reflection.ReflectionTypeLoadException ex) + { + return ex.Types + .Where(x => x is not null) + .Cast(); + } + catch + { + return Array.Empty(); + } + } + + private static bool HasLoginPageAttribute(Type type) + { + return type + .GetCustomAttributes( + typeof(UAuthLoginPageAttribute), + inherit: true) + .Any(); + } + + private static string NormalizeRoute(string route) + { + if (string.IsNullOrWhiteSpace(route)) + return "/"; - if (candidates.Count == 0) - return _cached = "/login"; + route = route.Trim(); - if (candidates.Count > 1) - throw new InvalidOperationException("Multiple [UAuthLoginPage] found. Make sure you only have one login page that attribute defined or define Navigation.LoginResolver explicitly."); + if (!route.StartsWith('/')) + route = "/" + route; - var routeAttr = candidates[0].GetCustomAttributes(typeof(RouteAttribute), true).FirstOrDefault() as RouteAttribute; + if (route.Length > 1) + route = route.TrimEnd('/'); - _cached = routeAttr?.Template ?? "/login"; - return _cached; + return route; } -} +} \ No newline at end of file diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthLoginPageResolver.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthLoginPageResolver.cs new file mode 100644 index 00000000..43a9dff8 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthLoginPageResolver.cs @@ -0,0 +1,11 @@ +using CodeBeam.UltimateAuth.Client.Infrastructure; + +namespace CodeBeam.UltimateAuth.Client.Blazor.Infrastructure; + +internal sealed class UAuthLoginPageResolver : IUAuthLoginPageResolver +{ + public string Resolve() + { + return UAuthLoginPageDiscovery.Resolve(); + } +} \ No newline at end of file diff --git a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthLoginPageResolver.cs b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthLoginPageResolver.cs new file mode 100644 index 00000000..72704a68 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthLoginPageResolver.cs @@ -0,0 +1,6 @@ +namespace CodeBeam.UltimateAuth.Client.Infrastructure; + +public interface IUAuthLoginPageResolver +{ + string Resolve(); +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthLoginPageAttribute.cs b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthLoginPageAttribute.cs index ab7db38c..d1e419d7 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthLoginPageAttribute.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthLoginPageAttribute.cs @@ -6,4 +6,10 @@ [AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] public sealed class UAuthLoginPageAttribute : Attribute { + public string? PreferredRoute { get; } + + public UAuthLoginPageAttribute(string? preferredRoute = null) + { + PreferredRoute = preferredRoute; + } } diff --git a/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/AssemblyVisibility.cs b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/AssemblyVisibility.cs new file mode 100644 index 00000000..ed166fcc --- /dev/null +++ b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/AssemblyVisibility.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Tests.Unit")] diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/ConditionalScopeBuilder.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/ConditionalScopeBuilder.cs index 4c6181ae..c92b5de7 100644 --- a/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/ConditionalScopeBuilder.cs +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/ConditionalScopeBuilder.cs @@ -33,4 +33,12 @@ private IPolicyScopeBuilder Add() where TPolicy : IAccessPolicy public IPolicyScopeBuilder RequirePermission() => Add(); public IPolicyScopeBuilder RequireAuthenticated() => Add(); public IPolicyScopeBuilder DenyCrossTenant() => Add(); + + public IConditionalPolicyBuilder When( + Func predicate) + { + ArgumentNullException.ThrowIfNull(predicate); + + return new ConditionalPolicyBuilder(_prefix, context => (_condition(context) == _expected) && predicate(context), _registry, _services); + } } diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/IPolicyScopeBuilder.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/IPolicyScopeBuilder.cs index 9f400fae..b51a78db 100644 --- a/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/IPolicyScopeBuilder.cs +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/IPolicyScopeBuilder.cs @@ -1,4 +1,6 @@ -namespace CodeBeam.UltimateAuth.Policies; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Policies; public interface IPolicyScopeBuilder { @@ -6,4 +8,6 @@ public interface IPolicyScopeBuilder IPolicyScopeBuilder RequireSelf(); IPolicyScopeBuilder RequirePermission(); IPolicyScopeBuilder DenyCrossTenant(); + + IConditionalPolicyBuilder When(Func predicate); } diff --git a/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2Options.cs b/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2Options.cs index 4f73b643..e0cee43c 100644 --- a/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2Options.cs +++ b/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2Options.cs @@ -3,10 +3,10 @@ public sealed class Argon2Options { // OWASP recommended baseline - public int MemorySizeKb { get; init; } = 64 * 1024; // 64 MB - public int Iterations { get; init; } = 3; - public int Parallelism { get; init; } = Environment.ProcessorCount; + public int MemorySizeKb { get; set; } = 64 * 1024; // 64 MB + public int Iterations { get; set; } = 3; + public int Parallelism { get; set; } = Environment.ProcessorCount; - public int SaltSize { get; init; } = 16; - public int HashSize { get; init; } = 32; + public int SaltSize { get; set; } = 16; + public int HashSize { get; set; } = 32; } diff --git a/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2PasswordHasher.cs b/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2PasswordHasher.cs index 7e3b7875..f72fa2ae 100644 --- a/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2PasswordHasher.cs +++ b/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2PasswordHasher.cs @@ -49,11 +49,11 @@ public bool Verify(PasswordHash hash, string secret) !int.TryParse(parts[2], out var parallelism)) return false; - var salt = Convert.FromBase64String(parts[3]); - var expectedHash = Convert.FromBase64String(parts[4]); - try { + var salt = Convert.FromBase64String(parts[3]); + var expectedHash = Convert.FromBase64String(parts[4]); + var argon2 = new Argon2id(Encoding.UTF8.GetBytes(secret)) { Salt = salt, diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs index 8dedbff7..ea1a0174 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs @@ -2,6 +2,7 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using CodeBeam.UltimateAuth.Server.Services; namespace CodeBeam.UltimateAuth.Users.Reference.Extensions; public static class ServiceCollectionExtensions diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs index 08ab97cc..eff28d65 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs @@ -8,6 +8,7 @@ using CodeBeam.UltimateAuth.Users.Contracts; using CodeBeam.UltimateAuth.Users; using Microsoft.Extensions.Options; +using CodeBeam.UltimateAuth.Server.Services; namespace CodeBeam.UltimateAuth.Users.Reference; @@ -529,7 +530,6 @@ public async Task AddUserIdentifierAsync(AccessContext context, AddUserIdentifie if (userScopeResult.Exists) throw new UAuthIdentifierConflictException("identifier_already_exists_for_user"); - // TODO(policy): Move identifier uniqueness decision/enforcement to the Policy layer. await EnsureIdentifierUniquenessAsync(identifierStore, request.Type, normalized.Normalized, userKey, excludeIdentifierId: null, innerCt); if (request.IsPrimary) @@ -995,6 +995,7 @@ private async Task EnsureIdentifierUniquenessAsync( } } + // TODO(policy): Move identifier uniqueness decision/enforcement to the Policy layer. private UniquenessScope GetUniquenessScope(UserIdentifierType type) { var uniqueness = _options.Identifiers.Uniqueness; diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserIdentifierAvailabilityService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserIdentifierAvailabilityService.cs index 19afc1ff..7778eed5 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserIdentifierAvailabilityService.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserIdentifierAvailabilityService.cs @@ -1,19 +1,24 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Server.Services; using CodeBeam.UltimateAuth.Users.Contracts; using CodeBeam.UltimateAuth.Users.Reference; +using Microsoft.Extensions.Options; public sealed class UserIdentifierAvailabilityService : IUserIdentifierAvailabilityService { private readonly IUserIdentifierValidator _validator; private readonly IIdentifierNormalizer _normalizer; private readonly IUserIdentifierStoreFactory _storeFactory; + private readonly UAuthServerOptions _options; - public UserIdentifierAvailabilityService(IUserIdentifierValidator validator, IIdentifierNormalizer normalizer, IUserIdentifierStoreFactory storeFactory) + public UserIdentifierAvailabilityService(IUserIdentifierValidator validator, IIdentifierNormalizer normalizer, IUserIdentifierStoreFactory storeFactory, IOptions options) { _validator = validator; _normalizer = normalizer; _storeFactory = storeFactory; + _options = options.Value; } public async Task CheckAsync(AccessContext context, CheckUserIdentifierAvailabilityRequest request, CancellationToken ct = default) @@ -42,14 +47,44 @@ public async Task CheckAsync(AccessContext con }); } + var uniquenessScope = IdentifierUniquenessResolver.GetScope(_options, request.Type); + + if (uniquenessScope is UniquenessScope.None or UniquenessScope.WithinUser) + { + if (context.TargetUserKey is null) + { + return UserIdentifierAvailabilityResult.Available(normalized.Normalized); + } + } + var store = _storeFactory.Create(context.ResourceTenant); - var existence = await store.ExistsAsync( - new IdentifierExistenceQuery( - request.Type, - normalized.Normalized, - IdentifierExistenceScope.TenantAny), - ct); + var query = uniquenessScope switch + { + UniquenessScope.Tenant => + new IdentifierExistenceQuery( + request.Type, + normalized.Normalized, + IdentifierExistenceScope.TenantAny), + + UniquenessScope.WithinUser => + new IdentifierExistenceQuery( + request.Type, + normalized.Normalized, + IdentifierExistenceScope.WithinUser, + context.TargetUserKey), + + UniquenessScope.None => + new IdentifierExistenceQuery( + request.Type, + normalized.Normalized, + IdentifierExistenceScope.WithinUser, + context.TargetUserKey), + + _ => throw new InvalidOperationException($"Unsupported uniqueness scope '{uniquenessScope}'.") + }; + + var existence = await store.ExistsAsync(query, ct); return existence.Exists ? UserIdentifierAvailabilityResult.Unavailable(normalized.Normalized) diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/CodeBeam.UltimateAuth.Tests.Integration.EfCore.csproj b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/CodeBeam.UltimateAuth.Tests.Integration.EfCore.csproj new file mode 100644 index 00000000..c5f95081 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/CodeBeam.UltimateAuth.Tests.Integration.EfCore.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Extensions/ServiceCollectionTestExtensions.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Extensions/ServiceCollectionTestExtensions.cs new file mode 100644 index 00000000..abc023d8 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Extensions/ServiceCollectionTestExtensions.cs @@ -0,0 +1,46 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Tests.Integration.EfCore; + +internal static class ServiceCollectionTestExtensions +{ + public static void DecorateForTest(this IServiceCollection services, Func decorator) where TService : class + { + var descriptor = services.LastOrDefault(x => x.ServiceType == typeof(TService)); + + if (descriptor is null) + { + throw new InvalidOperationException($"Service '{typeof(TService).FullName}' is not registered."); + } + + services.Remove(descriptor); + + services.Add( + ServiceDescriptor.Describe(typeof(TService), + sp => + { + var inner = CreateInstance(sp, descriptor); + + return decorator(sp, inner); + }, + descriptor.Lifetime)); + } + + private static TService CreateInstance(IServiceProvider serviceProvider, ServiceDescriptor descriptor) where TService : class + { + if (descriptor.ImplementationInstance is TService instance) + return instance; + + if (descriptor.ImplementationFactory is not null) + { + return (TService)descriptor.ImplementationFactory(serviceProvider); + } + + if (descriptor.ImplementationType is not null) + { + return (TService)ActivatorUtilities.CreateInstance(serviceProvider,descriptor.ImplementationType); + } + + throw new InvalidOperationException($"Unable to construct decorated service '{typeof(TService).FullName}'."); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/EfCoreTestRuntime.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/EfCoreTestRuntime.cs new file mode 100644 index 00000000..013bb234 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/EfCoreTestRuntime.cs @@ -0,0 +1,120 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.EntityFrameworkCore; +using CodeBeam.UltimateAuth.EntityFrameworkCore.Extensions; +using CodeBeam.UltimateAuth.Server.Extensions; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace CodeBeam.UltimateAuth.Tests.Integration.EfCore; + +internal sealed class EfCoreTestRuntime : IAsyncDisposable +{ + private readonly SqliteConnection _connection; + + public IServiceProvider Services { get; } + + public IntegrationTestClock Clock { get; } + + private EfCoreTestRuntime( + SqliteConnection connection, + IServiceProvider services, + IntegrationTestClock clock) + { + _connection = connection; + Services = services; + Clock = clock; + } + + public static async Task CreateAsync( + Action? configureServices = null) + { + var connection = + new SqliteConnection("Data Source=:memory:"); + + await connection.OpenAsync(); + + var services = new ServiceCollection(); + + services.AddLogging(); + + // AddUltimateAuthServer registers ASP.NET Core authorization services. + // The test runtime therefore also needs the routing infrastructure + // normally supplied by WebApplication. + services.AddRouting(); + + var configuration = new ConfigurationBuilder().AddInMemoryCollection().Build(); + + services.AddSingleton(configuration); + + services + .AddUltimateAuthServer() + .AddUltimateAuthEntityFrameworkCore(db => + { + db.UseSqlite(connection); + }); + + // + // Replace the production clock with a deterministic test clock. + // + var clock = new IntegrationTestClock(); + + services.RemoveAll(); + services.AddSingleton(clock); + + // + // Apply fault injection / test-specific overrides last. + // + configureServices?.Invoke(services); + + var provider = + services.BuildServiceProvider( + new ServiceProviderOptions + { + ValidateScopes = true, + ValidateOnBuild = true + }); + + var runtime = new EfCoreTestRuntime(connection, provider, clock); + + try + { + await runtime.InitializeDatabaseAsync(); + + return runtime; + } + catch + { + await runtime.DisposeAsync(); + throw; + } + } + + private async Task InitializeDatabaseAsync() + { + await using var scope = + Services.CreateAsyncScope(); + + var db = + scope.ServiceProvider + .GetRequiredService(); + + await db.Database.EnsureCreatedAsync(); + } + + public async ValueTask DisposeAsync() + { + if (Services is IAsyncDisposable asyncDisposable) + { + await asyncDisposable.DisposeAsync(); + } + else if (Services is IDisposable disposable) + { + disposable.Dispose(); + } + + await _connection.DisposeAsync(); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/FailingUserIdentifierStore.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/FailingUserIdentifierStore.cs new file mode 100644 index 00000000..dfd5b153 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/FailingUserIdentifierStore.cs @@ -0,0 +1,102 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users.Reference; + +namespace CodeBeam.UltimateAuth.Tests.Integration.EfCore; + +internal sealed class FailingUserIdentifierStore : IUserIdentifierStore +{ + private readonly IUserIdentifierStore _inner; + private readonly UserIdentifierStoreFaultState _fault; + + public FailingUserIdentifierStore(IUserIdentifierStore inner, UserIdentifierStoreFaultState fault) + { + _inner = inner; + _fault = fault; + } + + public Task GetAsync( + Guid key, + CancellationToken ct = default) + => _inner.GetAsync(key, ct); + + public Task ExistsAsync( + Guid key, + CancellationToken ct = default) + => _inner.ExistsAsync(key, ct); + + public async Task AddAsync( + UserIdentifier entity, + CancellationToken ct = default) + { + if (_fault.ShouldFail(entity)) + { + throw new InvalidOperationException( + "simulated_identifier_store_failure"); + } + + await _inner.AddAsync(entity, ct); + } + + public Task SaveAsync( + UserIdentifier entity, + long expectedVersion, + CancellationToken ct = default) + => _inner.SaveAsync(entity, expectedVersion, ct); + + public Task DeleteAsync( + Guid key, + long expectedVersion, + DeleteMode deleteMode, + DateTimeOffset now, + CancellationToken ct = default) + => _inner.DeleteAsync( + key, + expectedVersion, + deleteMode, + now, + ct); + + public Task ExistsAsync( + IdentifierExistenceQuery query, + CancellationToken ct = default) + => _inner.ExistsAsync(query, ct); + + public Task> GetByUserAsync( + UserKey userKey, + CancellationToken ct = default) + => _inner.GetByUserAsync(userKey, ct); + + public Task GetByIdAsync( + Guid id, + CancellationToken ct = default) + => _inner.GetByIdAsync(id, ct); + + public Task GetAsync( + UserIdentifierType type, + string value, + CancellationToken ct = default) + => _inner.GetAsync(type, value, ct); + + public Task> QueryAsync( + UserIdentifierQuery query, + CancellationToken ct = default) + => _inner.QueryAsync(query, ct); + + public Task> GetByUsersAsync( + IReadOnlyList userKeys, + CancellationToken ct = default) + => _inner.GetByUsersAsync(userKeys, ct); + + public Task DeleteByUserAsync( + UserKey userKey, + DeleteMode mode, + DateTimeOffset deletedAt, + CancellationToken ct = default) + => _inner.DeleteByUserAsync( + userKey, + mode, + deletedAt, + ct); +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/FailingUserIdentifierStoreFactory.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/FailingUserIdentifierStoreFactory.cs new file mode 100644 index 00000000..35d14694 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/FailingUserIdentifierStoreFactory.cs @@ -0,0 +1,26 @@ +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Users.Reference; + +namespace CodeBeam.UltimateAuth.Tests.Integration.EfCore; + +internal sealed class FailingUserIdentifierStoreFactory + : IUserIdentifierStoreFactory +{ + private readonly IUserIdentifierStoreFactory _inner; + private readonly UserIdentifierStoreFaultState _fault; + + public FailingUserIdentifierStoreFactory( + IUserIdentifierStoreFactory inner, + UserIdentifierStoreFaultState fault) + { + _inner = inner; + _fault = fault; + } + + public IUserIdentifierStore Create(TenantKey tenant) + { + return new FailingUserIdentifierStore( + _inner.Create(tenant), + _fault); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/IntegrationTestClock.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/IntegrationTestClock.cs new file mode 100644 index 00000000..da1af454 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/IntegrationTestClock.cs @@ -0,0 +1,48 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; + +namespace CodeBeam.UltimateAuth.Tests.Integration.EfCore; + +public sealed class IntegrationTestClock : IClock +{ + private readonly object _sync = new(); + + private DateTimeOffset _utcNow = new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero); + + public DateTimeOffset UtcNow + { + get + { + lock (_sync) + { + return _utcNow; + } + } + } + + public void Advance(TimeSpan duration) + { + if (duration < TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(duration)); + + lock (_sync) + { + _utcNow = _utcNow.Add(duration); + } + } + + public void Set(DateTimeOffset value) + { + lock (_sync) + { + _utcNow = value.ToUniversalTime(); + } + } + + public void Reset() + { + lock (_sync) + { + _utcNow = new DateTimeOffset(2030, 1, 1, 0, 0, 0, TimeSpan.Zero); + } + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/TestAccessContext.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/TestAccessContext.cs new file mode 100644 index 00000000..89d3d961 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/TestAccessContext.cs @@ -0,0 +1,93 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Tests.Integration.EfCore; + +internal static class TestAccessContext +{ + public static AccessContext WithAction(string action) + { + return new AccessContext( + actorUserKey: null, + actorTenant: TenantKey.Single, + isAuthenticated: false, + isSystemActor: false, + actorChainId: null, + resource: "test", + targetUserKey: null, + resourceTenant: TenantKey.Single, + action: action, + attributes: EmptyAttributes.Instance + ); + } + + public static AccessContext ForUser( + UserKey userKey, + string action, + TenantKey? tenant = null, + SessionChainId? actorChainId = null, + string resource = "identifier") + { + var t = tenant ?? TenantKey.Single; + + return new AccessContext( + actorUserKey: userKey, + actorTenant: t, + isAuthenticated: true, + isSystemActor: false, + actorChainId: actorChainId, + resource: resource, + targetUserKey: userKey, + resourceTenant: t, + action: action, + attributes: EmptyAttributes.Instance + ); + } + + public static AccessContext ForTargetUser( + UserKey actorUserKey, + UserKey targetUserKey, + string action, + TenantKey? tenant = null, + SessionChainId? actorChainId = null, + string resource = "identifier") + { + var t = tenant ?? TenantKey.Single; + + return new AccessContext( + actorUserKey: actorUserKey, + actorTenant: t, + isAuthenticated: true, + isSystemActor: false, + actorChainId: actorChainId, + resource: resource, + targetUserKey: targetUserKey, + resourceTenant: t, + action: action, + attributes: EmptyAttributes.Instance + ); + } + + public static AccessContext ForUserCreation( + UserKey actorUserKey, + string action, + TenantKey? tenant = null, + SessionChainId? actorChainId = null) + { + var t = tenant ?? TenantKey.Single; + + return new AccessContext( + actorUserKey: actorUserKey, + actorTenant: t, + isAuthenticated: true, + isSystemActor: false, + actorChainId: actorChainId, + resource: "users", + targetUserKey: null, + resourceTenant: t, + action: action, + attributes: EmptyAttributes.Instance + ); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/TestUsers.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/TestUsers.cs new file mode 100644 index 00000000..fa5385ae --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/TestUsers.cs @@ -0,0 +1,9 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Tests.Integration.EfCore; + +public static class TestUsers +{ + public static readonly UserKey Admin = UserKey.FromGuid(Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")); + public static readonly UserKey User = UserKey.FromGuid(Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")); +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/UserIdentifierStoreFaultState.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/UserIdentifierStoreFaultState.cs new file mode 100644 index 00000000..04ba0f21 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/UserIdentifierStoreFaultState.cs @@ -0,0 +1,51 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users.Reference; + +namespace CodeBeam.UltimateAuth.Tests.Integration.EfCore; + +internal sealed class UserIdentifierStoreFaultState +{ + private int _addAttempts; + + public bool Enabled { get; private set; } + + public int FailOnAddAttempt { get; private set; } + + public int AddAttempts => _addAttempts; + + public UserIdentifierType? LastAttemptedType { get; private set; } + + public UserKey? LastAttemptedUserKey { get; private set; } + + public void Enable(int failOnAddAttempt) + { + if (failOnAddAttempt <= 0) + throw new ArgumentOutOfRangeException(nameof(failOnAddAttempt)); + + _addAttempts = 0; + LastAttemptedType = null; + LastAttemptedUserKey = null; + + FailOnAddAttempt = failOnAddAttempt; + Enabled = true; + } + + public void Disable() + { + Enabled = false; + } + + public bool ShouldFail(UserIdentifier identifier) + { + if (!Enabled) + return false; + + var attempt = Interlocked.Increment(ref _addAttempts); + + LastAttemptedType = identifier.Type; + LastAttemptedUserKey = identifier.UserKey; + + return attempt == FailOnAddAttempt; + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/UserCreationAtomicityTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/UserCreationAtomicityTests.cs new file mode 100644 index 00000000..486a598b --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/UserCreationAtomicityTests.cs @@ -0,0 +1,122 @@ +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users.Reference; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Tests.Integration.EfCore.Users; + +public sealed class UserCreationAtomicityTests +{ + [Fact] + public async Task CreateUser_WhenIdentifierPersistenceFails_ShouldRollbackAllUserState() + { + var fault = + new UserIdentifierStoreFaultState(); + + await using var runtime = + await EfCoreTestRuntime.CreateAsync( + services => + { + services.AddSingleton(fault); + + services.DecorateForTest( + (sp, inner) => new FailingUserIdentifierStoreFactory(inner, sp.GetRequiredService())); + }); + + // Fault injection is enabled only after the runtime and database + // have been fully initialized. + fault.Enable(failOnAddAttempt: 2); + + UserKey userKey; + TenantKey tenant; + + // + // Execute user creation in its own DI scope. + // + using (var scope = runtime.Services.CreateScope()) + { + var service = scope.ServiceProvider.GetRequiredService(); + + var context = TestAccessContext.ForUserCreation(TestUsers.Admin,UAuthActions.Users.CreateAnonymous); + + tenant = context.ResourceTenant; + + var request = + new CreateUserRequest + { + UserName = $"atomic-{Guid.NewGuid():N}", + Email = $"atomic-{Guid.NewGuid():N}@example.com", + FirstName = "Atomic", + LastName = "Failure" + }; + + var exception = + await Assert.ThrowsAsync(() => service.CreateUserAsync(context, request)); + + exception.Message.Should().Be("simulated_identifier_store_failure"); + + fault.AddAttempts.Should().Be(2); + + fault.LastAttemptedType.Should().Be(UserIdentifierType.Email); + + fault.LastAttemptedUserKey.Should().NotBeNull(); + + userKey = fault.LastAttemptedUserKey!.Value; + } + + // + // Verify using a fresh scope / DbContext. + // + // This ensures that the assertions observe persisted database + // state rather than the DbContext change tracker used by the + // failed operation. + // + using (var scope = runtime.Services.CreateScope()) + { + var lifecycleFactory = + scope.ServiceProvider + .GetRequiredService(); + + var profileFactory = + scope.ServiceProvider + .GetRequiredService(); + + var identifierFactory = + scope.ServiceProvider + .GetRequiredService(); + + var lifecycle = + await lifecycleFactory + .Create(tenant) + .GetAsync( + new UserLifecycleKey( + tenant, + userKey)); + + var profiles = + await profileFactory + .Create(tenant) + .GetAllProfilesByUserAsync(userKey); + + var identifiers = + await identifierFactory + .Create(tenant) + .GetByUserAsync(userKey); + + // + // Atomicity contract: + // + // A failed user creation must be observationally equivalent + // to the operation never having occurred. + // + lifecycle.Should().BeNull(); + + profiles.Should().BeEmpty(); + + identifiers.Should().BeEmpty(); + } + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/Extensions/ServiceCollectionTestExtensions.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/Extensions/ServiceCollectionTestExtensions.cs index f14a31b0..81939a7c 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/Extensions/ServiceCollectionTestExtensions.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/Extensions/ServiceCollectionTestExtensions.cs @@ -4,58 +4,43 @@ namespace CodeBeam.UltimateAuth.Tests.Integration.Infrastructure; internal static class ServiceCollectionTestExtensions { - public static void DecorateForTest( - this IServiceCollection services, - Func decorator) - where TService : class + public static void DecorateForTest(this IServiceCollection services, Func decorator) where TService : class { - var descriptor = services.LastOrDefault( - x => x.ServiceType == typeof(TService)); + var descriptor = services.LastOrDefault(x => x.ServiceType == typeof(TService)); if (descriptor is null) { - throw new InvalidOperationException( - $"Service '{typeof(TService).FullName}' is not registered."); + throw new InvalidOperationException($"Service '{typeof(TService).FullName}' is not registered."); } services.Remove(descriptor); services.Add( - ServiceDescriptor.Describe( - typeof(TService), + ServiceDescriptor.Describe(typeof(TService), sp => { - var inner = CreateInstance( - sp, - descriptor); + var inner = CreateInstance(sp, descriptor); return decorator(sp, inner); }, descriptor.Lifetime)); } - private static TService CreateInstance( - IServiceProvider serviceProvider, - ServiceDescriptor descriptor) - where TService : class + private static TService CreateInstance(IServiceProvider serviceProvider, ServiceDescriptor descriptor) where TService : class { if (descriptor.ImplementationInstance is TService instance) return instance; if (descriptor.ImplementationFactory is not null) { - return (TService)descriptor - .ImplementationFactory(serviceProvider); + return (TService)descriptor.ImplementationFactory(serviceProvider); } if (descriptor.ImplementationType is not null) { - return (TService)ActivatorUtilities.CreateInstance( - serviceProvider, - descriptor.ImplementationType); + return (TService)ActivatorUtilities.CreateInstance(serviceProvider,descriptor.ImplementationType); } - throw new InvalidOperationException( - $"Unable to construct decorated service '{typeof(TService).FullName}'."); + throw new InvalidOperationException($"Unable to construct decorated service '{typeof(TService).FullName}'."); } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/UAuthHubSampleSmokeTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/UAuthHubSampleSmokeTests.cs new file mode 100644 index 00000000..f224fa9a --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/UAuthHubSampleSmokeTests.cs @@ -0,0 +1,29 @@ +using CodeBeam.UltimateAuth.Tests.Integration.Infrastructure; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; + +public sealed class UAuthHubSampleSmokeTests : IClassFixture +{ + private readonly HttpClient _client; + + public UAuthHubSampleSmokeTests(AuthServerFactory factory) + { + _client = factory.CreateClient( + new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false + }); + } + + [Theory] + [InlineData("/")] + [InlineData("/login")] + public async Task CriticalPages_ShouldRenderWithoutServerError(string path) + { + var response = await _client.GetAsync(path); + + ((int)response.StatusCode) + .Should() + .BeLessThan(500); + } +} \ No newline at end of file diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/UserLifecycleTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/UserLifecycleTests.cs index b89955c0..05b18a09 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/UserLifecycleTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/UserLifecycleTests.cs @@ -1,6 +1,7 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Tests.Integration.Infrastructure; using CodeBeam.UltimateAuth.Users.Contracts; using CodeBeam.UltimateAuth.Users.Reference; @@ -392,68 +393,85 @@ await CreateUserAsync( .BeTrue(); } - //[Fact] - //public async Task CreateUser_WithDuplicateUsername_ShouldNotCreateSecondUser() - //{ - // _factory.Clock.Reset(); + [Fact] + public async Task CreateUser_WithExistingUsername_ShouldNotCreateSecondUser() + { + _factory.Clock.Reset(); - // using var client = CreateClient(); + using var client = CreateClient(); - // var username = - // $"duplicate-{Guid.NewGuid():N}"; + var username = $"duplicate-{Guid.NewGuid():N}"; - // var first = - // await CreateUserResponseAsync( - // client, - // username); + // First registration succeeds. + var firstResponse = await CreateUserResponseAsync(client, username); - // first.StatusCode.Should() - // .Be(HttpStatusCode.OK); + firstResponse.StatusCode.Should() + .Be(HttpStatusCode.OK); - // var second = - // await CreateUserResponseAsync( - // client, - // username); + var firstResult = + await firstResponse.Content + .ReadFromJsonAsync(); - // var secondResult = await second.Content.ReadFromJsonAsync(); + firstResult.Should().NotBeNull(); + firstResult!.Succeeded.Should().BeTrue(); - // secondResult.Should().NotBeNull(); + var firstUserKey = + GetUserKey(firstResult); - // secondResult!.Succeeded.Should() - // .BeFalse(); + // + // Second sequential registration with the same username + // must be rejected. + // + var secondResponse = + await CreateUserResponseAsync( + client, + username); - // secondResult.FailureReason.Should() - // .NotBeNullOrWhiteSpace(); + secondResponse.IsSuccessStatusCode.Should().BeFalse("creating a user with an identifier already owned by another user must be rejected"); + ((int)secondResponse.StatusCode).Should().BeInRange(400, 499); - // second.IsSuccessStatusCode.Should().BeFalse(); + var problem = await secondResponse.Content.ReadFromJsonAsync(); - // // Verify the important invariant: - // // only one active identifier owns this username. - // using var scope = _factory.Services.CreateScope(); + problem.Should().NotBeNull(); - // var factory = - // scope.ServiceProvider - // .GetRequiredService(); + problem!.Status.Should().Be((int)secondResponse.StatusCode); - // var store = - // factory.Create(TenantKeys.Single); + // + // Verify ownership did not change. + // + using var scope = + _factory.Services.CreateScope(); - // var normalized = - // scope.ServiceProvider - // .GetRequiredService() - // .Normalize( - // UserIdentifierType.Username, - // username); + var identifierFactory = + scope.ServiceProvider + .GetRequiredService(); - // var identifier = - // await store.GetAsync( - // UserIdentifierType.Username, - // normalized.Normalized); + var normalizer = + scope.ServiceProvider + .GetRequiredService(); + + var store = + identifierFactory.Create(TenantKeys.Single); - // identifier.Should().NotBeNull(); - // identifier!.IsDeleted.Should().BeFalse(); - //} + var normalized = + normalizer.Normalize( + UserIdentifierType.Username, + username); + + var identifier = + await store.GetAsync( + UserIdentifierType.Username, + normalized.Normalized); + + identifier.Should().NotBeNull(); + + identifier!.UserKey.Should().Be( + firstUserKey, + "the original user must remain the owner of the username"); + + identifier.IsDeleted.Should().BeFalse(); + } [Fact] public async Task CreateUser_ResultUserKey_ShouldMatchPersistedAggregate() diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authentication/EfCoreAuthenticationSecurityStateStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authentication/EfCoreAuthenticationSecurityStateStoreContractTests.cs index 003f5e51..decc065a 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authentication/EfCoreAuthenticationSecurityStateStoreContractTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authentication/EfCoreAuthenticationSecurityStateStoreContractTests.cs @@ -72,7 +72,4 @@ public async ValueTask DisposeAsync() await _connection.DisposeAsync(); } } - - // Aynı CreateState / MutateState / AssertMutationPersisted - // implementation'ı. } \ No newline at end of file diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Bundle/UAuthDbContextTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Bundle/UAuthDbContextTests.cs new file mode 100644 index 00000000..5fff3bdb --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Bundle/UAuthDbContextTests.cs @@ -0,0 +1,90 @@ +using CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; +using CodeBeam.UltimateAuth.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Users.EntityFrameworkCore; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Tests.Unit.EntityFrameworkCore; + +public sealed class UAuthDbContextTests +{ + [Fact] + public void Model_ShouldContainAllUltimateAuthProjectionTypes() + { + var options = + new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + using var context = new UAuthDbContext(options); + + var model = context.Model; + + model.FindEntityType(typeof(UserLifecycleProjection)) + .Should().NotBeNull(); + + model.FindEntityType(typeof(UserProfileProjection)) + .Should().NotBeNull(); + + model.FindEntityType(typeof(UserIdentifierProjection)) + .Should().NotBeNull(); + + model.FindEntityType(typeof(PasswordCredentialProjection)) + .Should().NotBeNull(); + + model.FindEntityType(typeof(RoleProjection)) + .Should().NotBeNull(); + + model.FindEntityType(typeof(RolePermissionProjection)) + .Should().NotBeNull(); + + model.FindEntityType(typeof(UserRoleProjection)) + .Should().NotBeNull(); + + model.FindEntityType(typeof(SessionRootProjection)) + .Should().NotBeNull(); + + model.FindEntityType(typeof(SessionChainProjection)) + .Should().NotBeNull(); + + model.FindEntityType(typeof(SessionProjection)) + .Should().NotBeNull(); + + model.FindEntityType(typeof(RefreshTokenProjection)) + .Should().NotBeNull(); + + model.FindEntityType(typeof(AuthenticationSecurityStateProjection)) + .Should().NotBeNull(); + } + + [Fact] + public void DbSets_ShouldBeAvailable() + { + var options = + new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + using var context = new UAuthDbContext(options); + + context.UserLifecycles.Should().NotBeNull(); + context.UserProfiles.Should().NotBeNull(); + context.UserIdentifiers.Should().NotBeNull(); + context.PasswordCredentials.Should().NotBeNull(); + + context.Roles.Should().NotBeNull(); + context.UserRoleAssignments.Should().NotBeNull(); + context.UserPermissions.Should().NotBeNull(); + + context.Roots.Should().NotBeNull(); + context.Chains.Should().NotBeNull(); + context.Sessions.Should().NotBeNull(); + + context.RefreshTokens.Should().NotBeNull(); + context.AuthenticationSecurityStates.Should().NotBeNull(); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Bundle/UAuthEfCoreOptionsTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Bundle/UAuthEfCoreOptionsTests.cs new file mode 100644 index 00000000..65d899eb --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Bundle/UAuthEfCoreOptionsTests.cs @@ -0,0 +1,52 @@ +using CodeBeam.UltimateAuth.EntityFrameworkCore; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Tests.Unit.EntityFrameworkCore; + +public sealed class UAuthEfCoreOptionsTests +{ + [Fact] + public void Resolve_WithSpecificConfiguration_ShouldReturnSpecific() + { + Action defaultConfig = _ => { }; + Action specificConfig = _ => { }; + + var options = new UAuthEfCoreOptions + { + Default = defaultConfig + }; + + var result = options.Resolve(specificConfig); + + result.Should().BeSameAs(specificConfig); + } + + [Fact] + public void Resolve_WithoutSpecificConfiguration_ShouldReturnDefault() + { + Action defaultConfig = _ => { }; + + var options = new UAuthEfCoreOptions + { + Default = defaultConfig + }; + + var result = options.Resolve(null); + + result.Should().BeSameAs(defaultConfig); + } + + [Fact] + public void Resolve_WithoutAnyConfiguration_ShouldThrow() + { + var options = new UAuthEfCoreOptions(); + + var act = () => options.Resolve(null); + + act.Should() + .Throw() + .WithMessage( + "No database configuration provided for UltimateAuth EFCore.*"); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Bundle/UltimateAuthEntityFrameworkCoreExtensionsTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Bundle/UltimateAuthEntityFrameworkCoreExtensionsTests.cs new file mode 100644 index 00000000..babbafe1 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Bundle/UltimateAuthEntityFrameworkCoreExtensionsTests.cs @@ -0,0 +1,130 @@ +using CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; +using CodeBeam.UltimateAuth.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Users.EntityFrameworkCore; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Tests.Unit.EntityFrameworkCore; + +public sealed class UltimateAuthEntityFrameworkCoreExtensionsTests +{ + [Fact] + public void AddUltimateAuthEntityFrameworkCore_WithUnifiedContext_ShouldRegisterUAuthDbContext() + { + var services = new ServiceCollection(); + + services.AddUltimateAuthEntityFrameworkCore(options => + options.UseInMemoryDatabase(Guid.NewGuid().ToString())); + + using var provider = services.BuildServiceProvider(); + + using var scope = provider.CreateScope(); + + var context = + scope.ServiceProvider.GetRequiredService(); + + context.Should().NotBeNull(); + } + + [Fact] + public void AddUltimateAuthEntityFrameworkCore_WithUnifiedContext_ShouldConfigureDatabaseProvider() + { + var services = new ServiceCollection(); + + services.AddUltimateAuthEntityFrameworkCore(options => + options.UseInMemoryDatabase("uauth-test")); + + using var provider = services.BuildServiceProvider(); + + using var scope = provider.CreateScope(); + + var context = + scope.ServiceProvider.GetRequiredService(); + + context.Database.ProviderName + .Should() + .Be("Microsoft.EntityFrameworkCore.InMemory"); + } + + [Fact] + public void AddUltimateAuthEntityFrameworkCore_WithDefaultConfiguration_ShouldResolveAllContexts() + { + var services = new ServiceCollection(); + + services.AddUltimateAuthEntityFrameworkCore(options => + { + options.Default = builder => + builder.UseInMemoryDatabase( + Guid.NewGuid().ToString()); + }); + + using var provider = services.BuildServiceProvider(); + + using var scope = provider.CreateScope(); + + scope.ServiceProvider + .GetRequiredService() + .Should().NotBeNull(); + + scope.ServiceProvider + .GetRequiredService() + .Should().NotBeNull(); + + scope.ServiceProvider + .GetRequiredService() + .Should().NotBeNull(); + + scope.ServiceProvider + .GetRequiredService() + .Should().NotBeNull(); + + scope.ServiceProvider + .GetRequiredService() + .Should().NotBeNull(); + + scope.ServiceProvider + .GetRequiredService() + .Should().NotBeNull(); + } + + [Fact] + public void AddUltimateAuthEntityFrameworkCore_SpecificConfiguration_ShouldOverrideDefault() + { + var services = new ServiceCollection(); + + services.AddUltimateAuthEntityFrameworkCore(options => + { + options.Default = builder => + builder.UseSqlite("Data Source=default.db"); + + options.Users = builder => + builder.UseSqlite("Data Source=users.db"); + }); + + using var provider = services.BuildServiceProvider(); + using var scope = provider.CreateScope(); + + var users = + scope.ServiceProvider + .GetRequiredService(); + + var sessions = + scope.ServiceProvider + .GetRequiredService(); + + users.Database.GetDbConnection() + .DataSource + .Should() + .Be("users.db"); + + sessions.Database.GetDbConnection() + .DataSource + .Should() + .Be("default.db"); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthLoginPageDiscoveryTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthLoginPageDiscoveryTests.cs new file mode 100644 index 00000000..2a8a2855 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthLoginPageDiscoveryTests.cs @@ -0,0 +1,219 @@ +using CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Client.Blazor.Infrastructure; +using CodeBeam.UltimateAuth.Client.Infrastructure; +using FluentAssertions; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Client.Infrastructure; + +public sealed class UAuthLoginPageDiscoveryTests +{ + [Fact] + public void ResolveRoute_WithNoRoutes_ShouldReturnDefaultLoginRoute() + { + var result = UAuthLoginPageDiscovery.ResolveRoute( + new UAuthLoginPageAttribute(), + Array.Empty()); + + result.Should().Be("/login"); + } + + [Fact] + public void ResolveRoute_WithSingleRoute_ShouldReturnRoute() + { + var result = UAuthLoginPageDiscovery.ResolveRoute( + new UAuthLoginPageAttribute(), + new[] + { + "/sign-in" + }); + + result.Should().Be("/sign-in"); + } + + [Fact] + public void ResolveRoute_WithRootRoute_ShouldPreferRoot() + { + var result = UAuthLoginPageDiscovery.ResolveRoute( + new UAuthLoginPageAttribute(), + new[] + { + "/other", + "/", + "/login" + }); + + result.Should().Be("/"); + } + + [Fact] + public void ResolveRoute_WithoutRoot_ShouldPreferLoginRoute() + { + var result = UAuthLoginPageDiscovery.ResolveRoute( + new UAuthLoginPageAttribute(), + new[] + { + "/account", + "/login", + "/signin" + }); + + result.Should().Be("/login"); + } + + [Fact] + public void ResolveRoute_WithPreferredRoute_ShouldPreferExplicitRoute() + { + var result = UAuthLoginPageDiscovery.ResolveRoute( + new UAuthLoginPageAttribute("/sign-in"), + new[] + { + "/", + "/login", + "/sign-in" + }); + + result.Should().Be("/sign-in"); + } + + [Fact] + public void ResolveRoute_WithPreferredRouteWithoutLeadingSlash_ShouldResolveRoute() + { + var result = UAuthLoginPageDiscovery.ResolveRoute( + new UAuthLoginPageAttribute("sign-in"), + new[] + { + "/", + "/sign-in" + }); + + result.Should().Be("/sign-in"); + } + + [Fact] + public void ResolveRoute_WithPreferredRoute_ShouldMatchCaseInsensitively() + { + var result = UAuthLoginPageDiscovery.ResolveRoute( + new UAuthLoginPageAttribute("/LOGIN"), + new[] + { + "/", + "/login" + }); + + result.Should().Be("/login"); + } + + [Fact] + public void ResolveRoute_WithPreferredRouteTrailingSlash_ShouldNormalizeRoute() + { + var result = UAuthLoginPageDiscovery.ResolveRoute( + new UAuthLoginPageAttribute("/login/"), + new[] + { + "/login" + }); + + result.Should().Be("/login"); + } + + [Fact] + public void ResolveRoute_WithUndefinedPreferredRoute_ShouldThrow() + { + var act = () => + UAuthLoginPageDiscovery.ResolveRoute( + new UAuthLoginPageAttribute("/sign-in"), + new[] + { + "/", + "/login" + }, + "TestLoginPage"); + + act.Should() + .Throw() + .WithMessage( + "*Preferred login route '/sign-in'*TestLoginPage*"); + } + + [Fact] + public void ResolveRoute_WithMultipleCustomRoutes_ShouldUseDeterministicFallback() + { + var result = UAuthLoginPageDiscovery.ResolveRoute( + new UAuthLoginPageAttribute(), + new[] + { + "/z-login", + "/custom-login", + "/account" + }); + + result.Should().Be("/account"); + } + + [Fact] + public void ResolveRoute_ShouldNormalizeRoutes() + { + var result = UAuthLoginPageDiscovery.ResolveRoute( + new UAuthLoginPageAttribute(), + new[] + { + "login/" + }); + + result.Should().Be("/login"); + } + + [Fact] + public void ResolveRoute_ShouldIgnoreDuplicateRoutes() + { + var result = UAuthLoginPageDiscovery.ResolveRoute( + new UAuthLoginPageAttribute(), + new[] + { + "/login", + "/LOGIN", + "/login/" + }); + + result.Should().Be("/login"); + } + + [Theory] + [InlineData("/", "/login")] + [InlineData("/login", "/")] + public void ResolveRoute_WithRootAndLogin_ShouldAlwaysPreferRoot_RegardlessOfDiscoveryOrder( + string first, + string second) + { + var result = UAuthLoginPageDiscovery.ResolveRoute( + new UAuthLoginPageAttribute(), + new[] + { + first, + second + }); + + result.Should().Be("/"); + } + + [Theory] + [InlineData("/z", "/a", "/m")] + [InlineData("/m", "/z", "/a")] + [InlineData("/a", "/m", "/z")] + public void ResolveRoute_CustomFallback_ShouldBeIndependentOfDiscoveryOrder( + string first, + string second, + string third) + { + var result = UAuthLoginPageDiscovery.ResolveRoute( + new UAuthLoginPageAttribute(), + new[] + { + first, + second, + third + }); + + result.Should().Be("/a"); + } +} \ No newline at end of file diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthLoginRedirectTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthLoginRedirectTests.cs index 1288b2b2..7650562c 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthLoginRedirectTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthLoginRedirectTests.cs @@ -1,25 +1,35 @@ using Bunit; using CodeBeam.UltimateAuth.Client.Blazor; +using CodeBeam.UltimateAuth.Client.Infrastructure; using CodeBeam.UltimateAuth.Core.Defaults; using FluentAssertions; using Microsoft.AspNetCore.Components; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; namespace CodeBeam.UltimateAuth.Tests.Unit.Client.Blazor; public sealed class UAuthLoginRedirectTests : BunitContext { - private NavigationManager Nav => - Services.GetRequiredService(); + private NavigationManager Nav => Services.GetRequiredService(); + + public UAuthLoginRedirectTests() + { + Services.AddSingleton(new TestLoginPageResolver("/login")); + UseLoginRoute("/login"); + } [Fact] public void Render_WithoutReturnUrl_NavigatesToLoginPage() { Navigate(UAuthConstants.Routes.LoginRedirect); - Render(); + var comp = Render(); - Nav.Uri.Should().Be("http://localhost/login"); + comp.WaitForAssertion(() => + { + Nav.Uri.Should().Be("http://localhost/login"); + }); } [Fact] @@ -27,10 +37,13 @@ public void Render_WithRelativeReturnUrl_PreservesReturnUrl() { NavigateToRedirect("/home"); - Render(); + var comp = Render(); - Nav.Uri.Should().Be( - "http://localhost/login?uauth_return_url=%2Fhome"); + comp.WaitForAssertion(() => + { + Nav.Uri.Should().Be( + "http://localhost/login?uauth_return_url=%2Fhome"); + }); } [Fact] @@ -38,20 +51,23 @@ public void Render_WithNestedRelativeReturnUrl_PreservesAndEncodesReturnUrl() { NavigateToRedirect("/account/security?tab=sessions"); - Render(); + var comp = Render(); - var uri = Nav.ToAbsoluteUri(Nav.Uri); + comp.WaitForAssertion(() => + { + var uri = Nav.ToAbsoluteUri(Nav.Uri); - uri.AbsolutePath.Should().Be("/login"); + uri.AbsolutePath.Should().Be("/login"); - var query = - Microsoft.AspNetCore.WebUtilities.QueryHelpers - .ParseQuery(uri.Query); + var query = + Microsoft.AspNetCore.WebUtilities.QueryHelpers + .ParseQuery(uri.Query); - query[UAuthConstants.Query.ReturnUrl] - .ToString() - .Should() - .Be("/account/security?tab=sessions"); + query[UAuthConstants.Query.ReturnUrl] + .ToString() + .Should() + .Be("/account/security?tab=sessions"); + }); } [Fact] @@ -113,20 +129,23 @@ public void Render_WithAbsoluteHttpReturnUrl_PreservesReturnUrl() [InlineData("ftp://example.com/file")] [InlineData("mailto:test@example.com")] public void Render_WithUnsupportedAbsoluteScheme_DropsReturnUrl( - string returnUrl) + string returnUrl) { NavigateToRedirect(returnUrl); - Render(); + var comp = Render(); - Nav.ToAbsoluteUri(Nav.Uri) - .AbsolutePath - .Should() - .Be("/login"); + comp.WaitForAssertion(() => + { + Nav.ToAbsoluteUri(Nav.Uri) + .AbsolutePath + .Should() + .Be("/login"); - GetReturnUrlFromCurrentUri() - .Should() - .BeNull(); + GetReturnUrlFromCurrentUri() + .Should() + .BeNull(); + }); } [Fact] @@ -205,6 +224,29 @@ public void Render_WithUAuthReturnUrl_ConsumesIt() .Be("/home"); } + [Fact] + public void Render_ShouldUseResolvedLoginRoute() + { + UseLoginRoute("/custom-sign-in"); + + Navigate(UAuthConstants.Routes.LoginRedirect); + + Render(); + + Nav.ToAbsoluteUri(Nav.Uri) + .AbsolutePath + .Should() + .Be("/custom-sign-in"); + } + + private void UseLoginRoute(string route) + { + Services.RemoveAll(); + + Services.AddSingleton( + new TestLoginPageResolver(route)); + } + private void NavigateToRedirect(string returnUrl) { Nav.NavigateTo(UAuthConstants.Routes.LoginRedirect); @@ -233,4 +275,19 @@ private void Navigate(string relativeUri) ? value.ToString() : null; } + + private sealed class TestLoginPageResolver : IUAuthLoginPageResolver + { + private readonly string _route; + + public TestLoginPageResolver(string route) + { + _route = route; + } + + public string Resolve() + { + return _route; + } + } } \ No newline at end of file diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj b/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj index f26c8560..9a20db38 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj @@ -26,6 +26,7 @@ + diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Policies/AccessPoliciesTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Policies/AccessPoliciesTests.cs new file mode 100644 index 00000000..8d5f79c9 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Policies/AccessPoliciesTests.cs @@ -0,0 +1,987 @@ +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Authorization.Policies; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Policies; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public sealed class AccessPoliciesTests +{ + + public sealed class RequireAuthenticatedPolicyTests + { + [Fact] + public void AppliesTo_NormalAction_ShouldReturnTrue() + { + var sut = new RequireAuthenticatedPolicy(); + + var context = TestAccessContext.WithAction("users.get.self"); + + sut.AppliesTo(context).Should().BeTrue(); + } + + [Fact] + public void AppliesTo_AnonymousAction_ShouldReturnFalse() + { + var sut = new RequireAuthenticatedPolicy(); + + var context = TestAccessContext.WithAction("users.create.anonymous"); + + sut.AppliesTo(context).Should().BeFalse(); + } + + [Fact] + public void Decide_UnauthenticatedActor_ShouldDeny() + { + var sut = new RequireAuthenticatedPolicy(); + + var context = TestAccessContext.WithAction("users.get.self"); + + var result = sut.Decide(context); + + result.IsAllowed.Should().BeFalse(); + result.DenyReason.Should().Be("unauthenticated"); + } + + [Fact] + public void Decide_AuthenticatedActor_ShouldAllow() + { + var sut = new RequireAuthenticatedPolicy(); + + var context = TestAccessContext.ForUser( + UserKey.New(), + "users.get.self"); + + var result = sut.Decide(context); + + result.IsAllowed.Should().BeTrue(); + } + } + + public sealed class DenyCrossTenantPolicyTests + { + [Fact] + public void AppliesTo_ShouldAlwaysReturnTrue() + { + var sut = new DenyCrossTenantPolicy(); + + sut.AppliesTo( + TestAccessContext.WithAction("users.get.self")) + .Should() + .BeTrue(); + } + + [Fact] + public void Decide_SameTenant_ShouldAllow() + { + var sut = new DenyCrossTenantPolicy(); + + var context = TestAccessContext.ForUser( + UserKey.New(), + "users.get.self"); + + var result = sut.Decide(context); + + result.IsAllowed.Should().BeTrue(); + } + + [Fact] + public void Decide_CrossTenant_ShouldDeny() + { + var sut = new DenyCrossTenantPolicy(); + + var actorTenant = TenantKey.FromExternal("tenant-a"); + var resourceTenant = TenantKey.FromExternal("tenant-b"); + + var context = new AccessContext( + actorUserKey: UserKey.New(), + actorTenant: actorTenant, + isAuthenticated: true, + isSystemActor: false, + actorChainId: null, + resource: "users", + targetUserKey: null, + resourceTenant: resourceTenant, + action: "users.get.admin", + attributes: EmptyAttributes.Instance); + + var result = sut.Decide(context); + + result.IsAllowed.Should().BeFalse(); + result.DenyReason.Should().Be("cross_tenant_access_denied"); + } + } + + public sealed class RequireSelfPolicyTests + { + [Fact] + public void AppliesTo_SelfAction_ShouldReturnTrue() + { + var sut = new RequireSelfPolicy(); + + var context = + TestAccessContext.WithAction("users.update.self"); + + sut.AppliesTo(context).Should().BeTrue(); + } + + [Theory] + [InlineData("users.update.admin")] + [InlineData("users.update.system")] + [InlineData("users.update")] + public void AppliesTo_NonSelfAction_ShouldReturnFalse(string action) + { + var sut = new RequireSelfPolicy(); + + var context = + TestAccessContext.WithAction(action); + + sut.AppliesTo(context).Should().BeFalse(); + } + + [Fact] + public void Decide_UnauthenticatedActor_ShouldDeny() + { + var sut = new RequireSelfPolicy(); + + var context = + TestAccessContext.WithAction("users.update.self"); + + var result = sut.Decide(context); + + result.IsAllowed.Should().BeFalse(); + result.DenyReason.Should().Be("unauthenticated"); + } + + [Fact] + public void Decide_WhenActorIsTarget_ShouldAllow() + { + var sut = new RequireSelfPolicy(); + + var userKey = UserKey.New(); + + var context = + TestAccessContext.ForUser( + userKey, + "users.update.self"); + + var result = sut.Decide(context); + + result.IsAllowed.Should().BeTrue(); + result.DenyReason.Should().BeNull(); + } + + [Fact] + public void Decide_WhenActorIsNotTarget_ShouldDeny() + { + var sut = new RequireSelfPolicy(); + + var actor = UserKey.New(); + var target = UserKey.New(); + + var context = + TestAccessContext.ForTargetUser( + actor, + target, + "users.update.self"); + + var result = sut.Decide(context); + + result.IsAllowed.Should().BeFalse(); + result.DenyReason.Should().Be("not_self"); + } + } + + public sealed class RequireSystemPolicyTests + { + [Fact] + public void AppliesTo_SystemAction_ShouldReturnTrue() + { + var sut = new RequireSystemPolicy(); + + var context = + TestAccessContext.WithAction("users.repair.system"); + + sut.AppliesTo(context).Should().BeTrue(); + } + + [Theory] + [InlineData("users.repair.admin")] + [InlineData("users.repair.self")] + [InlineData("users.repair")] + public void AppliesTo_NonSystemAction_ShouldReturnFalse(string action) + { + var sut = new RequireSystemPolicy(); + + var context = + TestAccessContext.WithAction(action); + + sut.AppliesTo(context).Should().BeFalse(); + } + + [Fact] + public void Decide_NormalActor_ShouldDeny() + { + var sut = new RequireSystemPolicy(); + + var context = + TestAccessContext.WithAction("users.repair.system"); + + var result = sut.Decide(context); + + result.IsAllowed.Should().BeFalse(); + result.DenyReason.Should().Be("system_actor_required"); + } + + [Fact] + public void Decide_SystemActor_ShouldAllow() + { + var sut = new RequireSystemPolicy(); + + var context = new AccessContext( + actorUserKey: null, + actorTenant: TenantKey.System, + isAuthenticated: false, + isSystemActor: true, + actorChainId: null, + resource: "users", + targetUserKey: null, + resourceTenant: TenantKey.Single, + action: "users.repair.system", + attributes: EmptyAttributes.Instance); + + var result = sut.Decide(context); + + result.IsAllowed.Should().BeTrue(); + result.DenyReason.Should().BeNull(); + } + + [Fact] + public void AppliesTo_SystemSuffixWithDifferentCasing_ShouldReturnFalse() + { + var sut = new RequireSystemPolicy(); + + var context = + TestAccessContext.WithAction("users.repair.SYSTEM"); + + sut.AppliesTo(context).Should().BeFalse(); + } + } + + public sealed class DenyAdminSelfModificationPolicyTests + { + [Fact] + public void AppliesTo_AdminModificationWithTarget_ShouldReturnTrue() + { + var sut = new DenyAdminSelfModificationPolicy(); + + var context = + TestAccessContext.ForTargetUser( + UserKey.New(), + UserKey.New(), + "users.update.admin"); + + sut.AppliesTo(context).Should().BeTrue(); + } + + [Theory] + [InlineData("users.update.self")] + [InlineData("users.update.system")] + [InlineData("users.update")] + public void AppliesTo_NonAdminAction_ShouldReturnFalse(string action) + { + var sut = new DenyAdminSelfModificationPolicy(); + + var context = + TestAccessContext.ForTargetUser( + UserKey.New(), + UserKey.New(), + action); + + sut.AppliesTo(context).Should().BeFalse(); + } + + [Fact] + public void AppliesTo_AdminActionWithoutTarget_ShouldReturnFalse() + { + var sut = new DenyAdminSelfModificationPolicy(); + + var context = + TestAccessContext.WithAction("users.update.admin"); + + sut.AppliesTo(context).Should().BeFalse(); + } + + [Theory] + [InlineData("users.get.admin")] + [InlineData("users.read.admin")] + [InlineData("users.query.admin")] + public void AppliesTo_AdminReadAction_ShouldReturnFalse(string action) + { + var sut = new DenyAdminSelfModificationPolicy(); + + var context = + TestAccessContext.ForTargetUser( + UserKey.New(), + UserKey.New(), + action); + + sut.AppliesTo(context).Should().BeFalse(); + } + + [Fact] + public void Decide_UnauthenticatedActor_ShouldDeny() + { + var sut = new DenyAdminSelfModificationPolicy(); + + var context = + TestAccessContext.WithAction("users.update.admin"); + + var result = sut.Decide(context); + + result.IsAllowed.Should().BeFalse(); + result.DenyReason.Should().Be("unauthenticated"); + } + + [Fact] + public void Decide_AdminModifyingOwnAccount_ShouldDeny() + { + var sut = new DenyAdminSelfModificationPolicy(); + + var userKey = UserKey.New(); + + var context = + TestAccessContext.ForTargetUser( + userKey, + userKey, + "users.update.admin"); + + var result = sut.Decide(context); + + result.IsAllowed.Should().BeFalse(); + result.DenyReason.Should() + .Be("admin_cannot_modify_own_account"); + } + + [Fact] + public void Decide_AdminDeletingOwnAccount_ShouldDeny() + { + var sut = new DenyAdminSelfModificationPolicy(); + + var userKey = UserKey.New(); + + var context = + TestAccessContext.ForTargetUser( + userKey, + userKey, + "users.delete.admin"); + + var result = sut.Decide(context); + + result.IsAllowed.Should().BeFalse(); + result.DenyReason.Should() + .Be("admin_cannot_modify_own_account"); + } + + [Fact] + public void Decide_AdminModifyingDifferentUser_ShouldAllow() + { + var sut = new DenyAdminSelfModificationPolicy(); + + var actor = UserKey.New(); + var target = UserKey.New(); + + var context = + TestAccessContext.ForTargetUser( + actor, + target, + "users.update.admin"); + + var result = sut.Decide(context); + + result.IsAllowed.Should().BeTrue(); + result.DenyReason.Should().BeNull(); + } + } + + public sealed class ConditionalAccessPolicyTests + { + [Fact] + public void AppliesTo_WhenConditionMatchesExpectedTrue_ShouldReturnTrue() + { + var inner = new TestPolicy(); + + var sut = new ConditionalAccessPolicy( + _ => true, + expected: true, + inner); + + var context = + TestAccessContext.WithAction("users.get.self"); + + sut.AppliesTo(context).Should().BeTrue(); + } + + [Fact] + public void AppliesTo_WhenConditionDoesNotMatchExpectedTrue_ShouldReturnFalse() + { + var inner = new TestPolicy(); + + var sut = new ConditionalAccessPolicy( + _ => false, + expected: true, + inner); + + var context = + TestAccessContext.WithAction("users.get.self"); + + sut.AppliesTo(context).Should().BeFalse(); + } + + [Fact] + public void AppliesTo_WhenConditionMatchesExpectedFalse_ShouldReturnTrue() + { + var inner = new TestPolicy(); + + var sut = new ConditionalAccessPolicy( + _ => false, + expected: false, + inner); + + var context = + TestAccessContext.WithAction("users.get.self"); + + sut.AppliesTo(context).Should().BeTrue(); + } + + [Fact] + public void AppliesTo_WhenConditionDoesNotMatchExpectedFalse_ShouldReturnFalse() + { + var inner = new TestPolicy(); + + var sut = new ConditionalAccessPolicy( + _ => true, + expected: false, + inner); + + var context = + TestAccessContext.WithAction("users.get.self"); + + sut.AppliesTo(context).Should().BeFalse(); + } + + [Fact] + public void AppliesTo_ShouldPassContextToCondition() + { + AccessContext? receivedContext = null; + + var inner = new TestPolicy(); + + var sut = new ConditionalAccessPolicy( + context => + { + receivedContext = context; + return true; + }, + expected: true, + inner); + + var context = + TestAccessContext.WithAction("users.get.self"); + + sut.AppliesTo(context); + + receivedContext.Should().BeSameAs(context); + } + + [Fact] + public void Decide_ShouldDelegateToInnerPolicy() + { + var inner = new TestPolicy( + AccessDecision.Deny("inner_denied")); + + var sut = new ConditionalAccessPolicy( + _ => true, + expected: true, + inner); + + var context = + TestAccessContext.WithAction("users.get.self"); + + var result = sut.Decide(context); + + result.IsAllowed.Should().BeFalse(); + result.DenyReason.Should().Be("inner_denied"); + + inner.DecideCallCount.Should().Be(1); + inner.LastContext.Should().BeSameAs(context); + } + + [Fact] + public void Decide_ShouldReturnInnerAllowDecision() + { + var inner = new TestPolicy( + AccessDecision.Allow()); + + var sut = new ConditionalAccessPolicy( + _ => true, + expected: true, + inner); + + var context = + TestAccessContext.WithAction("users.get.self"); + + var result = sut.Decide(context); + + result.IsAllowed.Should().BeTrue(); + inner.DecideCallCount.Should().Be(1); + } + + private sealed class TestPolicy : IAccessPolicy + { + private readonly AccessDecision _decision; + + public int DecideCallCount { get; private set; } + + public AccessContext? LastContext { get; private set; } + + public TestPolicy() + : this(AccessDecision.Allow()) + { + } + + public TestPolicy(AccessDecision decision) + { + _decision = decision; + } + + public bool AppliesTo(AccessContext context) + { + return true; + } + + public AccessDecision Decide(AccessContext context) + { + DecideCallCount++; + LastContext = context; + + return _decision; + } + } + } + + public sealed class MustHavePermissionPolicyTests + { + [Fact] + public void AppliesTo_AdminAction_ShouldReturnTrue() + { + var sut = new MustHavePermissionPolicy(); + + var context = + TestAccessContext.WithAction("users.update.admin"); + + sut.AppliesTo(context).Should().BeTrue(); + } + + [Fact] + public void AppliesTo_AdminActionWithDifferentCasing_ShouldReturnTrue() + { + var sut = new MustHavePermissionPolicy(); + + var context = + TestAccessContext.WithAction("users.update.ADMIN"); + + sut.AppliesTo(context).Should().BeTrue(); + } + + [Theory] + [InlineData("users.update.self")] + [InlineData("users.update.system")] + [InlineData("users.update.anonymous")] + [InlineData("users.update")] + public void AppliesTo_NonAdminAction_ShouldReturnFalse(string action) + { + var sut = new MustHavePermissionPolicy(); + + var context = + TestAccessContext.WithAction(action); + + sut.AppliesTo(context).Should().BeFalse(); + } + + [Fact] + public void Decide_WhenPermissionsAttributeIsMissing_ShouldDeny() + { + var sut = new MustHavePermissionPolicy(); + + var context = + TestAccessContext.WithAction("users.update.admin"); + + var result = sut.Decide(context); + + result.IsAllowed.Should().BeFalse(); + result.DenyReason.Should().Be("missing_permission"); + } + + [Fact] + public void Decide_WhenPermissionsAttributeHasWrongType_ShouldDeny() + { + var sut = new MustHavePermissionPolicy(); + + var context = + TestAccessContext + .WithAction("users.update.admin") + .WithAttribute( + UAuthConstants.Access.Permissions, + "invalid-permissions"); + + var result = sut.Decide(context); + + result.IsAllowed.Should().BeFalse(); + result.DenyReason.Should().Be("missing_permission"); + } + } + + [Fact] + public void Decide_WhenPermissionAllowsAction_ShouldAllow() + { + var sut = new MustHavePermissionPolicy(); + + var permissions = + CreatePermissions("users.update.admin"); + + var context = + TestAccessContext + .WithAction("users.update.admin") + .WithAttribute( + UAuthConstants.Access.Permissions, + permissions); + + var result = sut.Decide(context); + + result.IsAllowed.Should().BeTrue(); + } + + [Fact] + public void Decide_WhenPermissionDoesNotAllowAction_ShouldDeny() + { + var sut = new MustHavePermissionPolicy(); + + var permissions = + CreatePermissions("sessions.revoke.admin"); + + var context = + TestAccessContext + .WithAction("users.update.admin") + .WithAttribute( + UAuthConstants.Access.Permissions, + permissions); + + var result = sut.Decide(context); + + result.IsAllowed.Should().BeFalse(); + result.DenyReason.Should().Be("missing_permission"); + } + + [Fact] + public void Decide_WhenPermissionAllowsExactAction_ShouldAllow() + { + var sut = new MustHavePermissionPolicy(); + + var permissions = + CreatePermissions("users.update.admin"); + + var context = + TestAccessContext + .WithAction("users.update.admin") + .WithAttribute( + UAuthConstants.Access.Permissions, + permissions); + + var result = sut.Decide(context); + + result.IsAllowed.Should().BeTrue(); + result.DenyReason.Should().BeNull(); + } + + public sealed class RequireActiveUserPolicyTests + { + [Fact] + public void AppliesTo_AuthenticatedUser_ShouldReturnTrue() + { + var sut = CreatePolicy(); + + var context = TestAccessContext.ForUser( + UserKey.New(), + "users.get.self"); + + sut.AppliesTo(context).Should().BeTrue(); + } + + [Fact] + public void AppliesTo_UnauthenticatedUser_ShouldReturnFalse() + { + var sut = CreatePolicy(); + + var context = + TestAccessContext.WithAction("users.get.self"); + + sut.AppliesTo(context).Should().BeFalse(); + } + + [Fact] + public void AppliesTo_AnonymousAction_ShouldReturnFalse() + { + var sut = CreatePolicy(); + + var context = TestAccessContext.ForUser( + UserKey.New(), + "users.create.anonymous"); + + sut.AppliesTo(context).Should().BeFalse(); + } + + [Fact] + public void AppliesTo_AllowedInactiveAction_ShouldReturnFalse() + { + var sut = CreatePolicy(); + + var context = TestAccessContext.ForUser( + UserKey.New(), + UAuthActions.Users.ChangeStatusSelf); + + sut.AppliesTo(context).Should().BeFalse(); + } + + [Fact] + public void AppliesTo_SystemActor_ShouldReturnFalse() + { + var sut = CreatePolicy(); + + var context = new AccessContext( + actorUserKey: null, + actorTenant: TenantKey.System, + isAuthenticated: true, + isSystemActor: true, + actorChainId: null, + resource: "users", + targetUserKey: null, + resourceTenant: TenantKey.System, + action: "users.get.admin", + attributes: EmptyAttributes.Instance); + + sut.AppliesTo(context).Should().BeFalse(); + } + + [Fact] + public void Decide_WhenActorIsMissing_ShouldDeny() + { + var runtime = new TestUserRuntimeStateProvider(); + var sut = new RequireActiveUserPolicy(runtime); + + var context = + TestAccessContext.WithAction("users.get.self"); + + var result = sut.Decide(context); + + result.IsAllowed.Should().BeFalse(); + result.DenyReason.Should().Be("missing_actor"); + + runtime.CallCount.Should().Be(0); + } + + [Fact] + public void Decide_WhenRuntimeStateIsMissing_ShouldDeny() + { + var runtime = new TestUserRuntimeStateProvider + { + Result = null + }; + + var sut = new RequireActiveUserPolicy(runtime); + + var context = TestAccessContext.ForUser( + UserKey.New(), + "users.get.self"); + + var result = sut.Decide(context); + + result.IsAllowed.Should().BeFalse(); + result.DenyReason.Should().Be("user_not_found"); + } + + [Fact] + public void Decide_WhenUserDoesNotExist_ShouldDeny() + { + var runtime = new TestUserRuntimeStateProvider + { + Result = CreateState( + exists: false, + isDeleted: false, + isActive: false) + }; + + var sut = new RequireActiveUserPolicy(runtime); + + var context = TestAccessContext.ForUser( + UserKey.New(), + "users.get.self"); + + var result = sut.Decide(context); + + result.IsAllowed.Should().BeFalse(); + result.DenyReason.Should().Be("user_not_found"); + } + + [Fact] + public void Decide_WhenUserIsDeleted_ShouldDeny() + { + var runtime = new TestUserRuntimeStateProvider + { + Result = CreateState( + exists: true, + isDeleted: true, + isActive: false) + }; + + var sut = new RequireActiveUserPolicy(runtime); + + var context = TestAccessContext.ForUser( + UserKey.New(), + "users.get.self"); + + var result = sut.Decide(context); + + result.IsAllowed.Should().BeFalse(); + result.DenyReason.Should().Be("user_not_found"); + } + + [Fact] + public void Decide_WhenUserIsInactive_ShouldDeny() + { + var runtime = new TestUserRuntimeStateProvider + { + Result = CreateState( + exists: true, + isDeleted: false, + isActive: false) + }; + + var sut = new RequireActiveUserPolicy(runtime); + + var context = TestAccessContext.ForUser( + UserKey.New(), + "users.get.self"); + + var result = sut.Decide(context); + + result.IsAllowed.Should().BeFalse(); + result.DenyReason.Should().Be("user_not_active"); + } + + [Fact] + public void Decide_WhenUserIsActive_ShouldAllow() + { + var runtime = new TestUserRuntimeStateProvider + { + Result = CreateState( + exists: true, + isDeleted: false, + isActive: true) + }; + + var sut = new RequireActiveUserPolicy(runtime); + + var context = TestAccessContext.ForUser( + UserKey.New(), + "users.get.self"); + + var result = sut.Decide(context); + + result.IsAllowed.Should().BeTrue(); + result.DenyReason.Should().BeNull(); + } + + [Fact] + public void Decide_ShouldQueryRuntimeUsingActorTenantAndUserKey() + { + var userKey = UserKey.New(); + var tenant = TenantKey.FromExternal("tenant-a"); + + var runtime = new TestUserRuntimeStateProvider + { + Result = CreateState( + exists: true, + isDeleted: false, + isActive: true, + userKey: userKey) + }; + + var sut = new RequireActiveUserPolicy(runtime); + var context = TestAccessContext.ForUser(userKey, "users.get.self", tenant); + + sut.Decide(context); + + runtime.LastTenant.Should().Be(tenant); + runtime.LastUserKey.Should().Be(userKey); + runtime.CallCount.Should().Be(1); + } + + private static RequireActiveUserPolicy CreatePolicy() + { + return new RequireActiveUserPolicy( + new TestUserRuntimeStateProvider()); + } + + private static UserRuntimeRecord CreateState(bool exists, bool isDeleted, bool isActive, UserKey? userKey = null) + { + return new UserRuntimeRecord + { + UserKey = userKey ?? UserKey.New(), + Exists = exists, + IsDeleted = isDeleted, + IsActive = isActive, + CanAuthenticate = isActive + }; + } + + private sealed class TestUserRuntimeStateProvider : IUserRuntimeStateProvider + { + public UserRuntimeRecord? Result { get; init; } + + public int CallCount { get; private set; } + + public TenantKey? LastTenant { get; private set; } + + public UserKey? LastUserKey { get; private set; } + + public Task GetAsync( + TenantKey tenant, + UserKey userKey, + CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + CallCount++; + + LastTenant = tenant; + LastUserKey = userKey; + + return Task.FromResult(Result); + } + } + } + + private static CompiledPermissionSet CreatePermissions( + params string[] permissions) + { + return new CompiledPermissionSet( + permissions.Select(Permission.From)); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Policies/PolicyBuilderTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Policies/PolicyBuilderTests.cs new file mode 100644 index 00000000..613a90f1 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Policies/PolicyBuilderTests.cs @@ -0,0 +1,390 @@ +using CodeBeam.UltimateAuth.Authorization.Policies; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Policies; +using CodeBeam.UltimateAuth.Policies.Registry; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public sealed class PolicyBuilderTests +{ + [Fact] + public void For_ShouldRegisterPolicyForSpecifiedPrefix() + { + var (builder, registry, services) = CreateBuilder(); + + builder + .For("users.") + .RequireAuthenticated(); + + var compiled = registry.Build(); + + var policies = compiled.Resolve( + TestAccessContext.WithAction("users.get.self"), + services); + + policies.Should().ContainSingle() + .Which.Should().BeOfType(); + } + + [Fact] + public void For_ShouldNotApplyPolicyToDifferentPrefix() + { + var (builder, registry, services) = CreateBuilder(); + + builder + .For("users.") + .RequireAuthenticated(); + + var compiled = registry.Build(); + + var policies = compiled.Resolve( + TestAccessContext.WithAction("sessions.get.self"), + services); + + policies.Should().BeEmpty(); + } + + [Fact] + public void Global_ShouldRegisterPolicyForEveryAction() + { + var (builder, registry, services) = CreateBuilder(); + + builder + .Global() + .DenyCrossTenant(); + + var compiled = registry.Build(); + + var users = compiled.Resolve( + TestAccessContext.WithAction("users.get.self"), + services); + + var sessions = compiled.Resolve( + TestAccessContext.WithAction("sessions.revoke.self"), + services); + + users.Should().ContainSingle() + .Which.Should().BeOfType(); + + sessions.Should().ContainSingle() + .Which.Should().BeOfType(); + } + + [Fact] + public void ScopeBuilder_ShouldSupportFluentPolicyRegistration() + { + var (builder, registry, services) = CreateBuilder(); + + builder + .For("users.") + .RequireAuthenticated() + .RequireSelf() + .RequirePermission() + .DenyCrossTenant(); + + var compiled = registry.Build(); + + var selfPolicies = compiled.Resolve( + TestAccessContext.WithAction("users.update.self"), + services); + + selfPolicies.Should().Contain(x => + x is RequireAuthenticatedPolicy); + + selfPolicies.Should().Contain(x => + x is RequireSelfPolicy); + + selfPolicies.Should().Contain(x => + x is DenyCrossTenantPolicy); + + selfPolicies.Should().NotContain(x => + x is MustHavePermissionPolicy); + + var adminPolicies = compiled.Resolve( + TestAccessContext.WithAction("users.update.admin"), + services); + + adminPolicies.Should().Contain(x => + x is RequireAuthenticatedPolicy); + + adminPolicies.Should().Contain(x => + x is MustHavePermissionPolicy); + + adminPolicies.Should().Contain(x => + x is DenyCrossTenantPolicy); + + adminPolicies.Should().NotContain(x => + x is RequireSelfPolicy); + } + + [Fact] + public void RequireAuthenticated_ShouldRegisterCorrectPolicy() + { + AssertRegisteredPolicy( + scope => scope.RequireAuthenticated()); + } + + [Fact] + public void RequireSelf_ShouldRegisterCorrectPolicy() + { + AssertRegisteredPolicy(scope => scope.RequireSelf(), "users.test.self"); + } + + [Fact] + public void RequirePermission_ShouldRegisterCorrectPolicy() + { + AssertRegisteredPolicy(scope => scope.RequirePermission(), "users.test.admin"); + } + + [Fact] + public void DenyCrossTenant_ShouldRegisterCorrectPolicy() + { + AssertRegisteredPolicy( + scope => scope.DenyCrossTenant()); + } + + private static void AssertRegisteredPolicy( + Action configure) + where TPolicy : IAccessPolicy + { + var (builder, registry, services) = CreateBuilder(); + + configure(builder.For("users.")); + + var compiled = registry.Build(); + + var policies = compiled.Resolve( + TestAccessContext.WithAction("users.test"), + services); + + policies.Should().ContainSingle() + .Which.Should().BeOfType(); + } + + [Fact] + public void Then_WhenConditionIsTrue_ShouldIncludePolicy() + { + var (scope, registry, services) = CreateScope(); + + scope + .When(_ => true) + .Then() + .RequireAuthenticated(); + + var compiled = registry.Build(); + + var policies = compiled.Resolve( + TestAccessContext.WithAction("users.get.self"), + services); + + policies.Should().ContainSingle(); + + policies.Single() + .Should() + .BeOfType(); + } + + [Fact] + public void Then_WhenConditionIsFalse_ShouldExcludePolicy() + { + var (scope, registry, services) = CreateScope(); + + scope + .When(_ => false) + .Then() + .RequireAuthenticated(); + + var compiled = registry.Build(); + + var policies = compiled.Resolve( + TestAccessContext.WithAction("users.get.self"), + services); + + policies.Should().BeEmpty(); + } + + [Fact] + public void Otherwise_WhenConditionIsFalse_ShouldIncludePolicy() + { + var (scope, registry, services) = CreateScope(); + + scope + .When(_ => false) + .Otherwise() + .RequireAuthenticated(); + + var compiled = registry.Build(); + + var policies = compiled.Resolve( + TestAccessContext.WithAction("users.get.self"), + services); + + policies.Should().ContainSingle(); + + policies.Single() + .Should() + .BeOfType(); + } + + [Fact] + public void Otherwise_WhenConditionIsTrue_ShouldExcludePolicy() + { + var (scope, registry, services) = CreateScope(); + + scope + .When(_ => true) + .Otherwise() + .RequireAuthenticated(); + + var compiled = registry.Build(); + + var policies = compiled.Resolve( + TestAccessContext.WithAction("users.get.self"), + services); + + policies.Should().BeEmpty(); + } + + [Fact] + public void ConditionalPolicy_ShouldReceiveRuntimeAccessContext() + { + var (scope, registry, services) = CreateScope(); + + scope + .When(context => + context.Action == "users.update.self") + .Then() + .RequireAuthenticated(); + + var compiled = registry.Build(); + + var matching = compiled.Resolve( + TestAccessContext.WithAction("users.update.self"), + services); + + var nonMatching = compiled.Resolve( + TestAccessContext.WithAction("users.get.self"), + services); + + matching.Should().ContainSingle(); + nonMatching.Should().BeEmpty(); + } + + [Fact] + public void Then_ShouldSupportMultiplePolicies() + { + var (scope, registry, services) = CreateScope(); + + scope + .When(_ => true) + .Then() + .RequireAuthenticated() + .RequireSelf() + .RequirePermission() + .DenyCrossTenant(); + + var compiled = registry.Build(); + + var policies = compiled.Resolve( + TestAccessContext.WithAction("users.update.self"), + services); + + policies.Should().HaveCount(4); + + policies.Should() + .OnlyContain(x => x is ConditionalAccessPolicy); + } + + [Fact] + public void Then_ShouldPreserveActionPrefix() + { + var services = new ServiceCollection() + .BuildServiceProvider(); + + var registry = new AccessPolicyRegistry(); + + var scope = + new PolicyScopeBuilder( + "users.", + registry, + services); + + scope + .When(_ => true) + .Then() + .RequireAuthenticated(); + + var compiled = registry.Build(); + + var matching = compiled.Resolve( + TestAccessContext.WithAction("users.get.self"), + services); + + var differentPrefix = compiled.Resolve( + TestAccessContext.WithAction("sessions.get.self"), + services); + + matching.Should().ContainSingle(); + differentPrefix.Should().BeEmpty(); + } + + [Fact] + public void For_WhenThen_ShouldBeAvailableThroughPublicBuilderContract() + { + var services = new ServiceCollection() + .BuildServiceProvider(); + + var registry = new AccessPolicyRegistry(); + + IPolicyBuilder builder = + new PolicyBuilder(registry, services); + + builder + .For("users.") + .When(_ => true) + .Then() + .RequireAuthenticated(); + + var compiled = registry.Build(); + + var policies = compiled.Resolve( + TestAccessContext.WithAction("users.get.self"), + services); + + policies.Should().ContainSingle(); + } + + private static void AssertRegisteredPolicy(Action configure, string action) where TPolicy : IAccessPolicy + { + var (builder, registry, services) = CreateBuilder(); + + configure(builder.For("users.")); + + var compiled = registry.Build(); + var policies = compiled.Resolve(TestAccessContext.WithAction(action), services); + + policies.Should().ContainSingle().Which.Should().BeOfType(); + } + + private static (PolicyScopeBuilder Scope, AccessPolicyRegistry Registry, ServiceProvider Services) CreateScope() + { + var services = new ServiceCollection().BuildServiceProvider(); + var registry = new AccessPolicyRegistry(); + + return ( + new PolicyScopeBuilder("users.", registry, services), + registry, + services); + } + + private static (PolicyBuilder Builder, AccessPolicyRegistry Registry, ServiceProvider Services) CreateBuilder() + { + var services = new ServiceCollection().BuildServiceProvider(); + var registry = new AccessPolicyRegistry(); + + return (new PolicyBuilder(registry, services), registry, services); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Policies/PolicyTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Policies/PolicyTests.cs new file mode 100644 index 00000000..8f25e3f0 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Policies/PolicyTests.cs @@ -0,0 +1,331 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Policies.Registry; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Policies; + +public sealed class PolicyTests +{ + [Fact] + public void Constructor_ShouldStoreActionPrefixAndFactory() + { + Func factory = _ => new TestPolicy(); + + var rule = new PolicyRule("users.create", factory); + + rule.ActionPrefix.Should().Be("users.create"); + rule.Factory.Should().BeSameAs(factory); + } + + [Theory] + [InlineData("users.create")] + [InlineData("users.create.admin")] + [InlineData("USERS.CREATE.ADMIN")] + public void Matches_WhenActionStartsWithPrefix_ShouldReturnTrue(string action) + { + var rule = new PolicyRule("users.create", _ => new TestPolicy()); + + rule.Matches(action).Should().BeTrue(); + } + + [Theory] + [InlineData("users.update")] + [InlineData("sessions.create")] + [InlineData("user.create")] + public void Matches_WhenActionDoesNotStartWithPrefix_ShouldReturnFalse(string action) + { + var rule = new PolicyRule("users.create", _ => new TestPolicy()); + + rule.Matches(action).Should().BeFalse(); + } + + [Fact] + public void Build_WhenCalledTwice_ShouldThrow() + { + var registry = new AccessPolicyRegistry(); + + registry.Build(); + + var act = () => registry.Build(); + + act.Should() + .Throw() + .WithMessage( + "AccessPolicyRegistry.Build() can only be called once."); + } + + [Fact] + public void Add_AfterBuild_ShouldThrow() + { + var registry = new AccessPolicyRegistry(); + + registry.Build(); + + var act = () => + registry.Add( + "users.", + _ => new TestPolicy()); + + act.Should() + .Throw() + .WithMessage( + "AccessPolicyRegistry is already built. Policies cannot be modified after Build()."); + } + + [Fact] + public void Resolve_ShouldReturnPoliciesMatchingActionPrefix() + { + var registry = new AccessPolicyRegistry(); + + registry.Add( + "users.", + _ => new TestPolicy("users")); + + registry.Add( + "sessions.", + _ => new TestPolicy("sessions")); + + var context = CreateContext("users.create"); + + using var services = + new ServiceCollection().BuildServiceProvider(); + + var policies = + registry.Resolve(context, services); + + policies.Should().ContainSingle(); + + policies + .Cast() + .Single() + .Name.Should() + .Be("users"); + } + + [Fact] + public void Resolve_ShouldMatchPrefixCaseInsensitively() + { + var registry = new AccessPolicyRegistry(); + + registry.Add("USERS.", _ => new TestPolicy("users")); + + var context = CreateContext("users.create"); + + using var services = new ServiceCollection().BuildServiceProvider(); + + var policies = registry.Resolve(context, services); + + policies.Should().ContainSingle(); + } + + [Fact] + public void Resolve_WhenNoPrefixMatches_ShouldReturnEmpty() + { + var registry = new AccessPolicyRegistry(); + + registry.Add("sessions.", _ => new TestPolicy("sessions")); + + var context = CreateContext("users.create"); + + using var services = + new ServiceCollection().BuildServiceProvider(); + + var policies = + registry.Resolve(context, services); + + policies.Should().BeEmpty(); + } + + [Fact] + public void CompiledSet_ShouldIncludePolicy_WhenPrefixMatchesAndPolicyApplies() + { + var registry = new AccessPolicyRegistry(); + + registry.Add( + "users.", + _ => new TestPolicy( + name: "matching", + applies: true)); + + var compiled = registry.Build(); + + var context = CreateContext("users.create"); + + using var services = + new ServiceCollection().BuildServiceProvider(); + + var policies = + compiled.Resolve(context, services); + + policies.Should().ContainSingle(); + } + + [Fact] + public void CompiledSet_ShouldExcludePolicy_WhenPolicyDoesNotApply() + { + var registry = new AccessPolicyRegistry(); + + registry.Add( + "users.", + _ => new TestPolicy( + name: "not-applicable", + applies: false)); + + var compiled = registry.Build(); + + var context = CreateContext("users.create"); + + using var services = + new ServiceCollection().BuildServiceProvider(); + + var policies = + compiled.Resolve(context, services); + + policies.Should().BeEmpty(); + } + + [Fact] + public void CompiledSet_ShouldNotCreatePolicy_WhenPrefixDoesNotMatch() + { + var registry = new AccessPolicyRegistry(); + + var factoryCalled = false; + + registry.Add( + "sessions.", + _ => + { + factoryCalled = true; + return new TestPolicy("sessions", true); + }); + + var compiled = registry.Build(); + + var context = CreateContext("users.create"); + + using var services = + new ServiceCollection().BuildServiceProvider(); + + var policies = + compiled.Resolve(context, services); + + policies.Should().BeEmpty(); + factoryCalled.Should().BeFalse(); + } + + [Fact] + public void CompiledSet_ShouldProvideServiceProviderToPolicyFactory() + { + var dependency = new TestDependency(); + + var services = new ServiceCollection() + .AddSingleton(dependency) + .BuildServiceProvider(); + + var registry = new AccessPolicyRegistry(); + + registry.Add( + "users.", + sp => new DependencyPolicy( + sp.GetRequiredService())); + + var compiled = registry.Build(); + + var policies = + compiled.Resolve( + CreateContext("users.create"), + services); + + var policy = + policies.Should() + .ContainSingle() + .Subject + .Should() + .BeOfType() + .Subject; + + policy.Dependency.Should().BeSameAs(dependency); + } + + [Fact] + public void Build_ShouldOrderPoliciesByPrefixLength() + { + var registry = new AccessPolicyRegistry(); + + registry.Add( + "users.create.", + _ => new TestPolicy("specific")); + + registry.Add( + "", + _ => new TestPolicy("global")); + + registry.Add( + "users.", + _ => new TestPolicy("users")); + + var compiled = registry.Build(); + + using var services = + new ServiceCollection().BuildServiceProvider(); + + var policies = + compiled.Resolve( + CreateContext("users.create.admin"), + services); + + policies + .Cast() + .Select(x => x.Name) + .Should() + .ContainInOrder( + "global", + "users", + "specific"); + } + + private sealed class TestPolicy : IAccessPolicy + { + private readonly bool _applies; + + public string? Name { get; } + + public TestPolicy(string? name = null, bool applies = true) + { + Name = name; + _applies = applies; + } + + public bool AppliesTo(AccessContext context) => _applies; + + public AccessDecision Decide(AccessContext context) => AccessDecision.Allow(); + } + + private sealed class TestDependency + { + } + + private sealed class DependencyPolicy : IAccessPolicy + { + public TestDependency Dependency { get; } + + public DependencyPolicy(TestDependency dependency) + { + Dependency = dependency; + } + + public bool AppliesTo(AccessContext context) + => true; + + public AccessDecision Decide(AccessContext context) + => AccessDecision.Allow(); + } + + private static AccessContext CreateContext(string action) + { + return TestAccessContext.WithAction(action); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Samples/ComponentTestBase.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Samples/ComponentTestBase.cs new file mode 100644 index 00000000..37f1fcf7 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Samples/ComponentTestBase.cs @@ -0,0 +1,27 @@ +using Bunit; +using CodeBeam.UltimateAuth.Client.Blazor.Extensions; +using CodeBeam.UltimateAuth.InMemory; +using CodeBeam.UltimateAuth.Server.Extensions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using MudBlazor.Services; +using MudExtensions.Services; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Samples; + +public abstract class UAuthHubComponentTestBase : BunitContext +{ + protected UAuthHubComponentTestBase() + { + JSInterop.Mode = JSRuntimeMode.Loose; + + var configuration = new ConfigurationBuilder().AddInMemoryCollection().Build(); + + Services.AddSingleton(configuration); + + Services.AddMudServices(); + Services.AddMudExtensions(); + Services.AddUltimateAuthServer().AddUltimateAuthInMemory().AddUAuthHub(); + Services.AddUltimateAuthClientBlazor(); + } +} \ No newline at end of file diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Samples/HomeTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Samples/HomeTests.cs new file mode 100644 index 00000000..2d62992c --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Samples/HomeTests.cs @@ -0,0 +1,24 @@ +using Bunit; +using CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Client.Infrastructure; +using CodeBeam.UltimateAuth.Sample.UAuthHub.Components.Layout; +using CodeBeam.UltimateAuth.Sample.UAuthHub.Components.Pages; +using FluentAssertions; +using Microsoft.AspNetCore.Components; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Samples.UAuthHub; + +public sealed class HomeTests : UAuthHubComponentTestBase +{ + [Fact] + public void Home_ShouldRender() + { + var state = UAuthState.Anonymous(); + + var act = () => Render>(parameters => parameters + .Add(p => p.Value, state) + .AddChildContent()); + + act.Should().NotThrow(); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Security/Argon2PasswordHasherTest.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Security/Argon2PasswordHasherTest.cs index 95474a45..ea9342a7 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Security/Argon2PasswordHasherTest.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Security/Argon2PasswordHasherTest.cs @@ -9,12 +9,6 @@ namespace CodeBeam.UltimateAuth.Tests.Unit; public class Argon2PasswordHasherTests { - private Argon2PasswordHasher CreateHasher() - { - var options = Options.Create(new Argon2Options()); - return new Argon2PasswordHasher(options); - } - [Fact] public void Hash_Should_Return_Valid_PasswordHash() { @@ -34,9 +28,7 @@ public void Hash_Should_Return_Valid_PasswordHash() public void Verify_Should_Return_True_For_Correct_Password() { var hasher = CreateHasher(); - var hash = hasher.Hash("password123"); - var result = hasher.Verify(hash, "password123"); result.Should().BeTrue(); @@ -46,9 +38,7 @@ public void Verify_Should_Return_True_For_Correct_Password() public void Verify_Should_Return_False_For_Wrong_Password() { var hasher = CreateHasher(); - var hash = hasher.Hash("password123"); - var result = hasher.Verify(hash, "wrong"); result.Should().BeFalse(); @@ -58,9 +48,7 @@ public void Verify_Should_Return_False_For_Wrong_Password() public void Verify_Should_Return_False_For_Invalid_Format() { var hasher = CreateHasher(); - var invalid = PasswordHash.Create(PasswordAlgorithms.Argon2, "invalid"); - var result = hasher.Verify(invalid, "password"); result.Should().BeFalse(); @@ -89,10 +77,8 @@ public void Hash_Should_Produce_Different_Hashes_For_Same_Password() public void Verify_Should_Use_Embedded_Salt_And_Parameters() { var hasher = CreateHasher(); - var hash = hasher.Hash("password123"); - // parametreleri değiştir (simulate config drift) var differentOptions = Options.Create(new Argon2Options { Iterations = 999, @@ -104,7 +90,6 @@ public void Verify_Should_Use_Embedded_Salt_And_Parameters() var differentHasher = new Argon2PasswordHasher(differentOptions); - // 🔥 yine de doğrulamalı var result = differentHasher.Verify(hash, "password123"); result.Should().BeTrue(); @@ -114,7 +99,6 @@ public void Verify_Should_Use_Embedded_Salt_And_Parameters() public void NeedsRehash_Should_Return_True_When_Parameters_Changed() { var hasher = CreateHasher(); - var hash = hasher.Hash("password123"); var differentOptions = Options.Create(new Argon2Options @@ -137,11 +121,185 @@ public void NeedsRehash_Should_Return_True_When_Parameters_Changed() public void NeedsRehash_Should_Return_False_When_Parameters_Match() { var hasher = CreateHasher(); + var hash = hasher.Hash("password123"); + var result = hasher.NeedsRehash(hash); + + result.Should().BeFalse(); + } + + [Theory] + [InlineData("")] + [InlineData(null)] + public void Hash_Should_Throw_When_Password_Is_Null_Or_Empty( + string? password) + { + var hasher = CreateHasher(); + var act = () => hasher.Hash(password!); + act.Should().Throw(); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(" ")] + [InlineData(null)] + public void Verify_Should_Return_False_When_Secret_Is_Invalid( + string? secret) + { + var hasher = CreateHasher(); var hash = hasher.Hash("password123"); + var result = hasher.Verify(hash, secret!); - var result = hasher.NeedsRehash(hash); + result.Should().BeFalse(); + } + + [Fact] + public void Verify_Should_Return_False_When_Algorithm_Is_Not_Argon2() + { + var hasher = CreateHasher(); + + var hash = PasswordHash.Create("different-algorithm", "3.65536.1.c2FsdA==.aGFzaA=="); + + var result = hasher.Verify(hash, "password123"); result.Should().BeFalse(); } + + [Theory] + [InlineData("invalid.65536.1.c2FsdA==.aGFzaA==")] + [InlineData("3.invalid.1.c2FsdA==.aGFzaA==")] + [InlineData("3.65536.invalid.c2FsdA==.aGFzaA==")] + public void Verify_Should_Return_False_When_Parameters_Are_Invalid(string encoded) + { + var hasher = CreateHasher(); + var hash = PasswordHash.Create(PasswordAlgorithms.Argon2, encoded); + var result = hasher.Verify(hash, "password123"); + + result.Should().BeFalse(); + } + + [Theory] + [InlineData("3.65536.1.NOT_BASE64.aGFzaA==")] + [InlineData("3.65536.1.c2FsdA==.NOT_BASE64")] + public void Verify_Should_Return_False_When_Hash_Contains_Invalid_Base64( + string encoded) + { + var hasher = CreateHasher(); + var hash = PasswordHash.Create(PasswordAlgorithms.Argon2, encoded); + var result = hasher.Verify(hash, "password123"); + + result.Should().BeFalse(); + } + + [Fact] + public void NeedsRehash_Should_Return_True_When_Algorithm_Is_Not_Argon2() + { + var hasher = CreateHasher(); + var hash = PasswordHash.Create("different-algorithm", "anything"); + + hasher.NeedsRehash(hash).Should().BeTrue(); + } + + [Theory] + [InlineData("invalid")] + [InlineData("1.2.3")] + [InlineData("1.2.3.4")] + [InlineData("1.2.3.4.5.6")] + public void NeedsRehash_Should_Return_True_When_Format_Is_Invalid( + string encoded) + { + var hasher = CreateHasher(); + + var hash = PasswordHash.Create( + PasswordAlgorithms.Argon2, + encoded); + + hasher.NeedsRehash(hash) + .Should() + .BeTrue(); + } + + [Theory] + [InlineData("invalid.65536.1.c2FsdA==.aGFzaA==")] + [InlineData("3.invalid.1.c2FsdA==.aGFzaA==")] + [InlineData("3.65536.invalid.c2FsdA==.aGFzaA==")] + public void NeedsRehash_Should_Return_True_When_Parameters_Are_Invalid( + string encoded) + { + var hasher = CreateHasher(); + + var hash = PasswordHash.Create( + PasswordAlgorithms.Argon2, + encoded); + + hasher.NeedsRehash(hash) + .Should() + .BeTrue(); + } + + [Fact] + public void NeedsRehash_Should_Return_True_When_Iterations_Changed() + { + var hasher = CreateHasher(); + var hash = hasher.Hash("password123"); + + var differentHasher = CreateHasher(new Argon2Options + { + Iterations = 4 + }); + + differentHasher.NeedsRehash(hash) + .Should() + .BeTrue(); + } + + [Fact] + public void NeedsRehash_Should_Return_True_When_Memory_Size_Changed() + { + var hasher = CreateHasher(); + var hash = hasher.Hash("password123"); + + var differentHasher = CreateHasher(new Argon2Options + { + MemorySizeKb = 32 * 1024 + }); + + differentHasher.NeedsRehash(hash) + .Should() + .BeTrue(); + } + + [Fact] + public void NeedsRehash_Should_Return_True_When_Parallelism_Changed() + { + var hasher = CreateHasher(); + var hash = hasher.Hash("password123"); + + var differentParallelism = + new Argon2Options().Parallelism == 1 + ? 2 + : 1; + + var differentHasher = CreateHasher(new Argon2Options + { + Parallelism = differentParallelism + }); + + differentHasher.NeedsRehash(hash) + .Should() + .BeTrue(); + } + + private static Argon2PasswordHasher CreateHasher() + { + return CreateHasher(new Argon2Options()); + } + + private static Argon2PasswordHasher CreateHasher( + Argon2Options options) + { + return new Argon2PasswordHasher( + Options.Create(options)); + } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Security/Argon2ServiceCollectionExtensionsTest.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Security/Argon2ServiceCollectionExtensionsTest.cs new file mode 100644 index 00000000..836b3acf --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Security/Argon2ServiceCollectionExtensionsTest.cs @@ -0,0 +1,88 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Security.Argon2; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public sealed class Argon2ServiceCollectionExtensionsTests +{ + [Fact] + public void AddUltimateAuthArgon2_ShouldRegisterPasswordHasher() + { + var services = new ServiceCollection(); + services.AddUltimateAuthArgon2(); + + using var provider = services.BuildServiceProvider(); + + var hasher = provider.GetRequiredService(); + + hasher.Should().BeOfType(); + } + + [Fact] + public void AddUltimateAuthArgon2_WithoutConfiguration_ShouldRegisterDefaultOptions() + { + var services = new ServiceCollection(); + + services.AddUltimateAuthArgon2(); + + using var provider = services.BuildServiceProvider(); + + var options = provider.GetRequiredService>().Value; + + options.Should().NotBeNull(); + options.Iterations.Should().Be(3); + options.MemorySizeKb.Should().Be(64 * 1024); + options.SaltSize.Should().Be(16); + options.HashSize.Should().Be(32); + } + + [Fact] + public void AddUltimateAuthArgon2_WithConfiguration_ShouldApplyConfiguration() + { + var services = new ServiceCollection(); + + services.AddUltimateAuthArgon2(options => + { + options.Iterations = 7; + options.MemorySizeKb = 32768; + options.Parallelism = 2; + options.SaltSize = 24; + options.HashSize = 48; + }); + + using var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().Value; + + options.Iterations.Should().Be(7); + options.MemorySizeKb.Should().Be(32768); + options.Parallelism.Should().Be(2); + options.SaltSize.Should().Be(24); + options.HashSize.Should().Be(48); + } + + [Fact] + public void AddUltimateAuthArgon2_ConfiguredParameters_ShouldBeUsedByHasher() + { + var services = new ServiceCollection(); + + services.AddUltimateAuthArgon2(options => + { + options.Iterations = 4; + options.MemorySizeKb = 16384; + options.Parallelism = 2; + }); + + using var provider = services.BuildServiceProvider(); + var hasher = provider.GetRequiredService(); + var hash = hasher.Hash("Password123!"); + var parts = hash.Hash.Split('.'); + + parts.Should().HaveCount(5); + parts[0].Should().Be("4"); + parts[1].Should().Be("16384"); + parts[2].Should().Be("2"); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserApplicationServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserApplicationServiceTests.cs index 3ed0280a..0c2a902a 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserApplicationServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserApplicationServiceTests.cs @@ -1,5 +1,4 @@ -using CodeBeam.UltimateAuth.Authorization; -using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Core.Domain; @@ -7,6 +6,7 @@ using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Server.Services; using CodeBeam.UltimateAuth.Users; using CodeBeam.UltimateAuth.Users.Contracts; using CodeBeam.UltimateAuth.Users.Reference;