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;