adds unit tests to cover 80%

This commit is contained in:
2026-06-26 00:27:27 +02:00
parent b84e2f8e3e
commit 0e01ca1e1c
69 changed files with 90596 additions and 46 deletions
@@ -15,8 +15,11 @@ using SlpModularCms.Api.Infrastructure;
using System.Text;
using System.Threading.RateLimiting;
using System.Diagnostics.CodeAnalysis;
namespace SlpModularCms.Api.Extensions;
[ExcludeFromCodeCoverage]
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddCoreInfrastructure(this IServiceCollection services, IConfiguration configuration)
@@ -0,0 +1,6 @@
using System.Diagnostics.CodeAnalysis;
// Excludes the top-level-statement Program class from code coverage.
// Program.cs is application bootstrapping and cannot be meaningfully unit tested.
[ExcludeFromCodeCoverage]
public partial class Program { }
@@ -0,0 +1,251 @@
using FluentAssertions;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using NSubstitute;
using SlpModularCms.Core.Data;
using SlpModularCms.Core.Exceptions;
using SlpModularCms.Core.Identity.Entities;
using SlpModularCms.Core.Identity.Models;
using SlpModularCms.Core.Identity.Services;
using Xunit;
namespace SlpModularCms.Core.Tests.Identity;
public class AuthServiceTests
{
private readonly ApplicationDbContext _context;
private readonly UserManager<ApplicationUser> _userManager;
private readonly AuthService _service;
public AuthServiceTests()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
_context = new ApplicationDbContext(options);
var store = Substitute.For<IUserStore<ApplicationUser>>();
_userManager = Substitute.For<UserManager<ApplicationUser>>(
store, null, null, null, null, null, null, null, null);
var jwtSettings = new JwtSettings
{
Secret = "test-signing-key-at-least-32-chars-long!",
Issuer = "test-issuer",
Audience = "test-audience",
ExpiryMinutes = 60,
RefreshTokenExpiryDays = 7
};
_service = new AuthService(_userManager, _context, Options.Create(jwtSettings));
}
[Fact]
public async Task AuthenticateAsync_ShouldThrow_WhenUserNotFound()
{
_userManager.FindByEmailAsync(Arg.Any<string>()).Returns((ApplicationUser?)null);
var act = () => _service.AuthenticateAsync("nouser@example.com", "password");
await act.Should().ThrowAsync<UnauthorizedException>();
}
[Fact]
public async Task AuthenticateAsync_ShouldThrow_WhenUserIsInactive()
{
var user = new ApplicationUser { Email = "user@example.com", IsActive = false };
_userManager.FindByEmailAsync(Arg.Any<string>()).Returns(user);
var act = () => _service.AuthenticateAsync("user@example.com", "password");
await act.Should().ThrowAsync<UnauthorizedException>();
}
[Fact]
public async Task AuthenticateAsync_ShouldThrow_WhenPasswordInvalid()
{
var user = new ApplicationUser { Email = "user@example.com", IsActive = true };
_userManager.FindByEmailAsync(Arg.Any<string>()).Returns(user);
_userManager.CheckPasswordAsync(user, Arg.Any<string>()).Returns(false);
var act = () => _service.AuthenticateAsync("user@example.com", "wrongpassword");
await act.Should().ThrowAsync<UnauthorizedException>();
}
[Fact]
public async Task AuthenticateAsync_ShouldReturnTokenResponse_WhenCredentialsValid()
{
var user = new ApplicationUser
{
Id = Guid.NewGuid(),
Email = "user@example.com",
IsActive = true,
DisplayName = "Test User"
};
_userManager.FindByEmailAsync(Arg.Any<string>()).Returns(user);
_userManager.CheckPasswordAsync(user, "Password123!").Returns(true);
_userManager.GetRolesAsync(user).Returns(new List<string> { "User" });
var result = await _service.AuthenticateAsync("user@example.com", "Password123!");
result.Should().NotBeNull();
result.AccessToken.Should().NotBeNullOrEmpty();
result.RefreshToken.Should().NotBeNullOrEmpty();
result.User.Email.Should().Be("user@example.com");
var savedToken = await _context.RefreshTokens.FirstOrDefaultAsync();
savedToken.Should().NotBeNull();
}
[Fact]
public async Task RefreshTokenAsync_ShouldThrow_WhenTokenNotFound()
{
var act = () => _service.RefreshTokenAsync("nonexistent-token");
await act.Should().ThrowAsync<UnauthorizedException>();
}
[Fact]
public async Task RefreshTokenAsync_ShouldThrow_WhenTokenRevoked()
{
var user = new ApplicationUser { Id = Guid.NewGuid(), Email = "user@example.com" };
_context.Users.Add(user);
await _context.SaveChangesAsync();
_context.RefreshTokens.Add(new RefreshToken
{
Token = "revoked-token",
UserId = user.Id,
ExpiryDate = DateTimeOffset.UtcNow.AddDays(1),
IsRevoked = true
});
await _context.SaveChangesAsync();
var act = () => _service.RefreshTokenAsync("revoked-token");
await act.Should().ThrowAsync<UnauthorizedException>();
}
[Fact]
public async Task RefreshTokenAsync_ShouldRevokeAllTokensAndThrow_WhenTokenAlreadyUsed()
{
var user = new ApplicationUser { Id = Guid.NewGuid(), Email = "user@example.com" };
_context.Users.Add(user);
await _context.SaveChangesAsync();
_context.RefreshTokens.Add(new RefreshToken
{
Token = "used-token",
UserId = user.Id,
ExpiryDate = DateTimeOffset.UtcNow.AddDays(1),
IsUsed = true
});
await _context.SaveChangesAsync();
var act = () => _service.RefreshTokenAsync("used-token");
await act.Should().ThrowAsync<UnauthorizedException>();
var allTokens = await _context.RefreshTokens.ToListAsync();
allTokens.Should().AllSatisfy(t => t.IsRevoked.Should().BeTrue());
}
[Fact]
public async Task RefreshTokenAsync_ShouldReturnNewTokens_WhenTokenValid()
{
var user = new ApplicationUser
{
Id = Guid.NewGuid(),
Email = "user@example.com",
IsActive = true
};
_context.Users.Add(user);
await _context.SaveChangesAsync();
_context.RefreshTokens.Add(new RefreshToken
{
Token = "valid-refresh-token",
UserId = user.Id,
ExpiryDate = DateTimeOffset.UtcNow.AddDays(7)
});
await _context.SaveChangesAsync();
_userManager.GetRolesAsync(Arg.Any<ApplicationUser>()).Returns(new List<string> { "User" });
var result = await _service.RefreshTokenAsync("valid-refresh-token");
result.Should().NotBeNull();
result.AccessToken.Should().NotBeNullOrEmpty();
result.RefreshToken.Should().NotBeNullOrEmpty();
var oldToken = await _context.RefreshTokens.FirstAsync(t => t.Token == "valid-refresh-token");
oldToken.IsUsed.Should().BeTrue();
}
[Fact]
public async Task RevokeTokenAsync_ShouldMarkTokenRevoked()
{
var user = new ApplicationUser { Id = Guid.NewGuid(), Email = "user@example.com" };
_context.Users.Add(user);
await _context.SaveChangesAsync();
_context.RefreshTokens.Add(new RefreshToken
{
Token = "token-to-revoke",
UserId = user.Id,
ExpiryDate = DateTimeOffset.UtcNow.AddDays(1)
});
await _context.SaveChangesAsync();
await _service.RevokeTokenAsync("token-to-revoke");
var saved = await _context.RefreshTokens.FirstAsync(t => t.Token == "token-to-revoke");
saved.IsRevoked.Should().BeTrue();
}
[Fact]
public async Task RevokeTokenAsync_ShouldNotThrow_WhenTokenNotFound()
{
var act = () => _service.RevokeTokenAsync("nonexistent-token");
await act.Should().NotThrowAsync();
}
[Fact]
public async Task ChangePasswordAsync_ShouldThrow_WhenUserNotFound()
{
_userManager.FindByIdAsync(Arg.Any<string>()).Returns((ApplicationUser?)null);
var act = () => _service.ChangePasswordAsync(Guid.NewGuid(), "old", "new");
await act.Should().ThrowAsync<UnauthorizedException>();
}
[Fact]
public async Task ChangePasswordAsync_ShouldThrow_WhenPasswordChangeFails()
{
var user = new ApplicationUser { Id = Guid.NewGuid(), Email = "user@example.com" };
_userManager.FindByIdAsync(user.Id.ToString()).Returns(user);
_userManager.ChangePasswordAsync(user, Arg.Any<string>(), Arg.Any<string>())
.Returns(IdentityResult.Failed(new IdentityError { Description = "Password too weak" }));
var act = () => _service.ChangePasswordAsync(user.Id, "old", "weak");
await act.Should().ThrowAsync<ValidationException>();
}
[Fact]
public async Task ChangePasswordAsync_ShouldSucceed_WhenValid()
{
var user = new ApplicationUser { Id = Guid.NewGuid(), Email = "user@example.com" };
_userManager.FindByIdAsync(user.Id.ToString()).Returns(user);
_userManager.ChangePasswordAsync(user, "OldPassword1!", "NewPassword1!")
.Returns(IdentityResult.Success);
var act = () => _service.ChangePasswordAsync(user.Id, "OldPassword1!", "NewPassword1!");
await act.Should().NotThrowAsync();
}
}
@@ -88,10 +88,124 @@ public class InvitationServiceTests
[Fact]
public async Task CompleteInvitationAsync_ShouldThrow_WhenTokenInvalid()
{
// Act
var act = () => _service.CompleteInvitationAsync("invalid-token", "password");
// Assert
await act.Should().ThrowAsync<UnauthorizedException>();
}
[Fact]
public async Task CreateInvitationAsync_ShouldThrow_WhenInvitationAlreadyExists()
{
await _service.CreateInvitationAsync("test@example.com", "User");
var act = () => _service.CreateInvitationAsync("test@example.com", "User");
await act.Should().ThrowAsync<InvitationOrUserAlreadyExistsException>();
}
[Fact]
public async Task CreateInvitationAsync_ShouldThrow_WhenUserAlreadyExists()
{
var email = "existing@example.com";
_userManager.FindByEmailAsync(email)
.Returns(new ApplicationUser { Email = email });
var act = () => _service.CreateInvitationAsync(email, "User");
await act.Should().ThrowAsync<InvitationOrUserAlreadyExistsException>();
}
[Fact]
public async Task ValidateInvitationAsync_ShouldReturnFalse_WhenTokenInvalid()
{
var isValid = await _service.ValidateInvitationAsync("nonexistent-token");
isValid.Should().BeFalse();
}
[Fact]
public async Task GetPendingInvitationByEmailAsync_ShouldReturnFalse_WhenNoInvitation()
{
var (isPending, token) = await _service.GetPendingInvitationByEmailAsync("nobody@example.com");
isPending.Should().BeFalse();
token.Should().BeNull();
}
[Fact]
public async Task GetPendingInvitationByEmailAsync_ShouldReturnTrue_WhenPendingInvitationExists()
{
var invitationToken = await _service.CreateInvitationAsync("invited@example.com", "User");
var (isPending, token) = await _service.GetPendingInvitationByEmailAsync("invited@example.com");
isPending.Should().BeTrue();
token.Should().Be(invitationToken);
}
[Fact]
public async Task DeletePendingInvitationsByEmailAsync_ShouldRemoveMatchingInvitations()
{
await _service.CreateInvitationAsync("invited@example.com", "User");
await _service.DeletePendingInvitationsByEmailAsync("invited@example.com");
var (isPending, _) = await _service.GetPendingInvitationByEmailAsync("invited@example.com");
isPending.Should().BeFalse();
}
[Fact]
public async Task GetAllPendingInvitationsAsync_ShouldReturnAllActiveInvitations()
{
await _service.CreateInvitationAsync("user1@example.com", "User");
await _service.CreateInvitationAsync("user2@example.com", "Administrator");
var invitations = await _service.GetAllPendingInvitationsAsync();
invitations.Should().HaveCount(2);
invitations.Select(i => i.Email).Should().Contain(["user1@example.com", "user2@example.com"]);
}
[Fact]
public async Task DeleteInvitationByIdAsync_ShouldReturnTrue_WhenFound()
{
await _service.CreateInvitationAsync("invited@example.com", "User");
var invitations = await _service.GetAllPendingInvitationsAsync();
var id = invitations[0].Id;
var result = await _service.DeleteInvitationByIdAsync(id);
result.Should().BeTrue();
(await _service.GetAllPendingInvitationsAsync()).Should().BeEmpty();
}
[Fact]
public async Task DeleteInvitationByIdAsync_ShouldReturnFalse_WhenNotFound()
{
var result = await _service.DeleteInvitationByIdAsync(Guid.NewGuid());
result.Should().BeFalse();
}
[Fact]
public async Task UpdateInvitationRoleAsync_ShouldUpdateRole()
{
await _service.CreateInvitationAsync("invited@example.com", "User");
var invitations = await _service.GetAllPendingInvitationsAsync();
var id = invitations[0].Id;
var result = await _service.UpdateInvitationRoleAsync(id, "Administrator");
result.Should().BeTrue();
var updated = await _service.GetAllPendingInvitationsAsync();
updated[0].Role.Should().Be("Administrator");
}
[Fact]
public async Task UpdateInvitationRoleAsync_ShouldReturnFalse_WhenNotFound()
{
var result = await _service.UpdateInvitationRoleAsync(Guid.NewGuid(), "Administrator");
result.Should().BeFalse();
}
}
@@ -0,0 +1,141 @@
using FluentAssertions;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using SlpModularCms.Core.Data;
using SlpModularCms.Core.Exceptions;
using SlpModularCms.Core.Identity.Entities;
using SlpModularCms.Core.Identity.Services;
using Xunit;
namespace SlpModularCms.Core.Tests.Identity;
public class SetupServiceTests
{
private static ApplicationDbContext CreateContext() =>
new(new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options);
private static UserManager<ApplicationUser> CreateUserManager(ApplicationDbContext context)
{
var store = new UserStore<ApplicationUser, ApplicationRole, ApplicationDbContext, Guid>(context);
return new UserManager<ApplicationUser>(
store,
Options.Create(new IdentityOptions()),
new PasswordHasher<ApplicationUser>(),
new IUserValidator<ApplicationUser>[] { new UserValidator<ApplicationUser>() },
new IPasswordValidator<ApplicationUser>[] { new PasswordValidator<ApplicationUser>() },
new UpperInvariantLookupNormalizer(),
new IdentityErrorDescriber(),
null!,
NullLogger<UserManager<ApplicationUser>>.Instance);
}
private static RoleManager<ApplicationRole> CreateRoleManager(ApplicationDbContext context)
{
var store = new RoleStore<ApplicationRole, ApplicationDbContext, Guid>(context);
return new RoleManager<ApplicationRole>(
store,
new IRoleValidator<ApplicationRole>[] { new RoleValidator<ApplicationRole>() },
new UpperInvariantLookupNormalizer(),
new IdentityErrorDescriber(),
NullLogger<RoleManager<ApplicationRole>>.Instance);
}
[Fact]
public async Task IsSystemInitializedAsync_ShouldReturnFalse_WhenNoUsers()
{
var context = CreateContext();
var service = new SetupService(CreateUserManager(context), CreateRoleManager(context));
var result = await service.IsSystemInitializedAsync();
result.Should().BeFalse();
}
[Fact]
public async Task IsSystemInitializedAsync_ShouldReturnTrue_WhenUsersExist()
{
var context = CreateContext();
var userManager = CreateUserManager(context);
var service = new SetupService(userManager, CreateRoleManager(context));
await userManager.CreateAsync(
new ApplicationUser { UserName = "owner@example.com", Email = "owner@example.com" },
"Password123!");
var result = await service.IsSystemInitializedAsync();
result.Should().BeTrue();
}
[Fact]
public async Task CreateInitialOwnerAsync_ShouldThrow_WhenSystemAlreadyInitialized()
{
var context = CreateContext();
var userManager = CreateUserManager(context);
var service = new SetupService(userManager, CreateRoleManager(context));
await userManager.CreateAsync(
new ApplicationUser { UserName = "existing@example.com", Email = "existing@example.com" },
"Password123!");
var act = () => service.CreateInitialOwnerAsync("Owner", "owner@example.com", "Password123!");
await act.Should().ThrowAsync<ValidationException>();
}
[Fact]
public async Task CreateInitialOwnerAsync_ShouldCreateUserWithOwnerRole()
{
var context = CreateContext();
var userManager = CreateUserManager(context);
var roleManager = CreateRoleManager(context);
var service = new SetupService(userManager, roleManager);
await service.CreateInitialOwnerAsync("Test Owner", "owner@example.com", "Password123!");
var user = await userManager.FindByEmailAsync("owner@example.com");
user.Should().NotBeNull();
user!.DisplayName.Should().Be("Test Owner");
user.IsActive.Should().BeTrue();
var roles = await userManager.GetRolesAsync(user);
roles.Should().Contain("Owner");
}
[Fact]
public async Task CreateInitialOwnerAsync_ShouldCreateAllRoles()
{
var context = CreateContext();
var userManager = CreateUserManager(context);
var roleManager = CreateRoleManager(context);
var service = new SetupService(userManager, roleManager);
await service.CreateInitialOwnerAsync("Test Owner", "owner@example.com", "Password123!");
(await roleManager.RoleExistsAsync("Owner")).Should().BeTrue();
(await roleManager.RoleExistsAsync("Administrator")).Should().BeTrue();
(await roleManager.RoleExistsAsync("User")).Should().BeTrue();
}
[Fact]
public async Task CreateInitialOwnerAsync_ShouldNotDuplicateRoles_WhenCalledAfterPartialSetup()
{
var context = CreateContext();
var userManager = CreateUserManager(context);
var roleManager = CreateRoleManager(context);
var service = new SetupService(userManager, roleManager);
// Pre-create the Owner role to simulate partial setup
await roleManager.CreateAsync(new ApplicationRole { Name = "Owner" });
await service.CreateInitialOwnerAsync("Test Owner", "owner@example.com", "Password123!");
var ownerRoles = await context.Roles.Where(r => r.Name == "Owner").ToListAsync();
ownerRoles.Should().HaveCount(1);
}
}
@@ -1,8 +1,11 @@
using System.Diagnostics.CodeAnalysis;
namespace SlpModularCms.Core.Availability;
/// <summary>
/// Configuratie-opties voor de Availability module.
/// </summary>
[ExcludeFromCodeCoverage]
public class AvailabilityOptions
{
/// <summary>
@@ -1,6 +1,9 @@
using System.Diagnostics.CodeAnalysis;
namespace SlpModularCms.Core.Availability;
/// <summary>
/// Status en optionele admin-melding van de systeembeschikbaarheid.
/// </summary>
[ExcludeFromCodeCoverage]
public record AvailabilityStatusDetails(AvailabilityStatus Status, string? Message);
@@ -1,8 +1,8 @@
using System.Diagnostics.CodeAnalysis;
namespace SlpModularCms.Core.Exceptions;
/// <summary>
/// Exception die gegooid wordt bij authenticatie- of autorisatiefouten.
/// </summary>
[ExcludeFromCodeCoverage]
public class InvitationOrUserAlreadyExistsException : Exception
{
public InvitationOrUserAlreadyExistsException(string message) : base(message)
@@ -1,8 +1,8 @@
using System.Diagnostics.CodeAnalysis;
namespace SlpModularCms.Core.Exceptions;
/// <summary>
/// Exception die gegooid wordt bij authenticatie- of autorisatiefouten.
/// </summary>
[ExcludeFromCodeCoverage]
public class UnauthorizedException : Exception
{
public UnauthorizedException(string message) : base(message)
@@ -1,8 +1,8 @@
using System.Diagnostics.CodeAnalysis;
namespace SlpModularCms.Core.Exceptions;
/// <summary>
/// Exception die gegooid wordt bij validatiefouten.
/// </summary>
[ExcludeFromCodeCoverage]
public class ValidationException : Exception
{
public ValidationException(string message) : base(message)
@@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Authorization;
namespace SlpModularCms.Core.Identity.Authorization;
@@ -5,6 +6,7 @@ namespace SlpModularCms.Core.Identity.Authorization;
/// <summary>
/// Requirement voor hiërarchische rol-gebaseerde autorisatie.
/// </summary>
[ExcludeFromCodeCoverage]
public class HierarchicalRoleRequirement : IAuthorizationRequirement
{
public string MinimumRole { get; }
@@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Identity;
namespace SlpModularCms.Core.Identity.Entities;
@@ -5,6 +6,7 @@ namespace SlpModularCms.Core.Identity.Entities;
/// <summary>
/// Aangepaste Role entiteit voor SlpModularCms.
/// </summary>
[ExcludeFromCodeCoverage]
public class ApplicationRole : IdentityRole<Guid>
{
public ApplicationRole() : base()
@@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Identity;
namespace SlpModularCms.Core.Identity.Entities;
@@ -5,6 +6,7 @@ namespace SlpModularCms.Core.Identity.Entities;
/// <summary>
/// Aangepaste User entiteit voor SlpModularCms.
/// </summary>
[ExcludeFromCodeCoverage]
public class ApplicationUser : IdentityUser<Guid>
{
/// <summary>
@@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using SlpModularCms.Core.Availability;
namespace SlpModularCms.Core.Identity.Entities;
@@ -5,6 +6,7 @@ namespace SlpModularCms.Core.Identity.Entities;
/// <summary>
/// Persistente opslag voor de globale systeemstatus.
/// </summary>
[ExcludeFromCodeCoverage]
public class GlobalAvailabilityState
{
public Guid Id { get; set; }
@@ -1,8 +1,11 @@
using System.Diagnostics.CodeAnalysis;
namespace SlpModularCms.Core.Identity.Entities;
/// <summary>
/// Entiteit voor gebruikersuitnodigingen.
/// </summary>
[ExcludeFromCodeCoverage]
public class Invitation
{
public Guid Id { get; set; }
@@ -1,8 +1,11 @@
using System.Diagnostics.CodeAnalysis;
namespace SlpModularCms.Core.Identity.Entities;
/// <summary>
/// Entiteit voor module-specifieke rechten per gebruiker.
/// </summary>
[ExcludeFromCodeCoverage]
public class ModulePermission
{
public Guid UserId { get; set; }
@@ -1,8 +1,11 @@
using System.Diagnostics.CodeAnalysis;
namespace SlpModularCms.Core.Identity.Entities;
/// <summary>
/// Entiteit voor het opslaan van refresh tokens.
/// </summary>
[ExcludeFromCodeCoverage]
public class RefreshToken
{
public Guid Id { get; set; }
@@ -1,14 +1,17 @@
using System.Diagnostics.CodeAnalysis;
namespace SlpModularCms.Core.Identity.Models;
public record CreateOwnerRequest(string Name, string Email, string Password);
public record LoginRequest(string Email, string Password);
public record InviteUserRequest(string Email, string Role);
public record CompleteSetupRequest(string Token, string Password);
public record ChangeRoleRequest(string NewRole);
public record UpdateProfileRequest(string Name, string Email);
public record ChangePasswordRequest(string CurrentPassword, string NewPassword);
public record SetUserActiveRequest(bool IsActive);
public record PendingInvitationInfo(Guid Id, string Email, string Role, string Token, DateTimeOffset CreatedAt);
[ExcludeFromCodeCoverage] public record CreateOwnerRequest(string Name, string Email, string Password);
[ExcludeFromCodeCoverage] public record LoginRequest(string Email, string Password);
[ExcludeFromCodeCoverage] public record InviteUserRequest(string Email, string Role);
[ExcludeFromCodeCoverage] public record CompleteSetupRequest(string Token, string Password);
[ExcludeFromCodeCoverage] public record ChangeRoleRequest(string NewRole);
[ExcludeFromCodeCoverage] public record UpdateProfileRequest(string Name, string Email);
[ExcludeFromCodeCoverage] public record ChangePasswordRequest(string CurrentPassword, string NewPassword);
[ExcludeFromCodeCoverage] public record SetUserActiveRequest(bool IsActive);
[ExcludeFromCodeCoverage] public record PendingInvitationInfo(Guid Id, string Email, string Role, string Token, DateTimeOffset CreatedAt);
[ExcludeFromCodeCoverage]
public record UserDto(
Guid Id,
string Email,
@@ -1,8 +1,11 @@
using System.Diagnostics.CodeAnalysis;
namespace SlpModularCms.Core.Identity.Models;
/// <summary>
/// Configuratie-instellingen voor JWT.
/// </summary>
[ExcludeFromCodeCoverage]
public class JwtSettings
{
public string Secret { get; set; } = string.Empty;
@@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
namespace SlpModularCms.Core.Identity.Models;
@@ -5,6 +6,7 @@ namespace SlpModularCms.Core.Identity.Models;
/// <summary>
/// Model voor het retourneren van authenticatie tokens.
/// </summary>
[ExcludeFromCodeCoverage]
public record TokenResponse(
string AccessToken,
[property: JsonIgnore] string RefreshToken,
@@ -15,6 +17,7 @@ public record TokenResponse(
/// <summary>
/// Model voor gebruikersinformatie in de auth response.
/// </summary>
[ExcludeFromCodeCoverage]
public record UserResponse(
Guid Id,
string Email,
@@ -1,4 +1,6 @@
using System;
// <auto-generated />
using System;
using System.Diagnostics.CodeAnalysis;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
@@ -6,6 +8,7 @@ using Microsoft.EntityFrameworkCore.Migrations;
namespace SlpModularCms.Core.Migrations
{
/// <inheritdoc />
[ExcludeFromCodeCoverage]
public partial class InitialCreate : Migration
{
/// <inheritdoc />
@@ -1,10 +1,13 @@
using Microsoft.EntityFrameworkCore.Migrations;
// <auto-generated />
using System.Diagnostics.CodeAnalysis;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SlpModularCms.Core.Migrations
{
/// <inheritdoc />
[ExcludeFromCodeCoverage]
public partial class AddsDisplayName : Migration
{
/// <inheritdoc />
@@ -1,5 +1,6 @@
// <auto-generated />
using System;
using System.Diagnostics.CodeAnalysis;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
@@ -11,6 +12,7 @@ using SlpModularCms.Core.Data;
namespace SlpModularCms.Core.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[ExcludeFromCodeCoverage]
partial class ApplicationDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
@@ -1,8 +1,11 @@
using System.Diagnostics.CodeAnalysis;
namespace SlpModularCms.Core.Modules;
/// <summary>
/// Metadata over een geladen module.
/// </summary>
[ExcludeFromCodeCoverage]
public record ModuleInfo(
string Name,
string Version,
@@ -1,10 +1,14 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.IdentityModel.Tokens;
using NSubstitute;
using SlpModularCms.Core.Availability;
using SlpModularCms.Modules.Availability.Middleware;
using Xunit;
using FluentAssertions;
namespace SlpModularCms.Modules.Availability.Tests;
@@ -24,29 +28,23 @@ public class AvailabilityMiddlewareTests
[Fact]
public async Task InvokeAsync_ShouldAllowRequest_WhenSystemAvailable()
{
// Arrange
var context = new DefaultHttpContext();
_service.IsAvailableAsync().Returns(AvailabilityStatus.Available);
// Act
await _middleware.InvokeAsync(context, _service);
// Assert
await _next.Received(1).Invoke(context);
}
[Fact]
public async Task InvokeAsync_ShouldBlockRequest_WhenSystemUnavailable()
{
// Arrange
var context = new DefaultHttpContext();
context.Response.Body = new MemoryStream();
_service.IsAvailableAsync().Returns(AvailabilityStatus.NotAvailable);
// Act
await _middleware.InvokeAsync(context, _service);
// Assert
await _next.DidNotReceive().Invoke(Arg.Any<HttpContext>());
context.Response.StatusCode.Should().Be(StatusCodes.Status503ServiceUnavailable);
}
@@ -54,15 +52,124 @@ public class AvailabilityMiddlewareTests
[Fact]
public async Task InvokeAsync_ShouldAllowBypass_ForStatusEndpoint()
{
// Arrange
var context = new DefaultHttpContext();
context.Request.Path = "/api/availability/status";
context.Request.Path = "/api/v1/Availability/status";
_service.IsAvailableAsync().Returns(AvailabilityStatus.NotAvailable);
// Act
await _middleware.InvokeAsync(context, _service);
// Assert
await _next.Received(1).Invoke(context);
}
[Fact]
public async Task InvokeAsync_ShouldAllowBypass_ForAuthEndpoints()
{
var context = new DefaultHttpContext();
context.Request.Path = "/api/v1/Auth/login";
_service.IsAvailableAsync().Returns(AvailabilityStatus.NotAvailable);
await _middleware.InvokeAsync(context, _service);
await _next.Received(1).Invoke(context);
}
[Fact]
public async Task InvokeAsync_ShouldAllowBypass_ForSetupStatusEndpoint()
{
var context = new DefaultHttpContext();
context.Request.Path = "/api/v1/Setup/status";
_service.IsAvailableAsync().Returns(AvailabilityStatus.NotAvailable);
await _middleware.InvokeAsync(context, _service);
await _next.Received(1).Invoke(context);
}
[Fact]
public async Task InvokeAsync_ShouldBlockRequest_WhenSystemInMaintenance()
{
var context = new DefaultHttpContext();
context.Response.Body = new MemoryStream();
_service.IsAvailableAsync().Returns(AvailabilityStatus.Maintenance);
await _middleware.InvokeAsync(context, _service);
await _next.DidNotReceive().Invoke(Arg.Any<HttpContext>());
context.Response.StatusCode.Should().Be(StatusCodes.Status503ServiceUnavailable);
}
[Fact]
public async Task InvokeAsync_ShouldAllowAdminBypass_WhenOwnerToken()
{
var context = new DefaultHttpContext();
context.Request.Headers.Authorization = $"Bearer {CreateJwtWithRole("Owner")}";
_service.IsAvailableAsync().Returns(AvailabilityStatus.Maintenance);
await _middleware.InvokeAsync(context, _service);
await _next.Received(1).Invoke(context);
}
[Fact]
public async Task InvokeAsync_ShouldAllowAdminBypass_WhenAdministratorToken()
{
var context = new DefaultHttpContext();
context.Request.Headers.Authorization = $"Bearer {CreateJwtWithRole("Administrator")}";
_service.IsAvailableAsync().Returns(AvailabilityStatus.Maintenance);
await _middleware.InvokeAsync(context, _service);
await _next.Received(1).Invoke(context);
}
[Fact]
public async Task InvokeAsync_ShouldNotBypass_WhenUserRoleToken()
{
var context = new DefaultHttpContext();
context.Response.Body = new MemoryStream();
context.Request.Headers.Authorization = $"Bearer {CreateJwtWithRole("User")}";
_service.IsAvailableAsync().Returns(AvailabilityStatus.Maintenance);
await _middleware.InvokeAsync(context, _service);
await _next.DidNotReceive().Invoke(Arg.Any<HttpContext>());
context.Response.StatusCode.Should().Be(StatusCodes.Status503ServiceUnavailable);
}
[Fact]
public async Task InvokeAsync_ShouldNotBypass_WhenNoAuthorizationHeader()
{
var context = new DefaultHttpContext();
context.Response.Body = new MemoryStream();
_service.IsAvailableAsync().Returns(AvailabilityStatus.NotAvailable);
await _middleware.InvokeAsync(context, _service);
await _next.DidNotReceive().Invoke(Arg.Any<HttpContext>());
}
[Fact]
public async Task InvokeAsync_ShouldNotBypass_WhenInvalidJwtToken()
{
var context = new DefaultHttpContext();
context.Response.Body = new MemoryStream();
context.Request.Headers.Authorization = "Bearer not-a-valid-jwt";
_service.IsAvailableAsync().Returns(AvailabilityStatus.NotAvailable);
await _middleware.InvokeAsync(context, _service);
await _next.DidNotReceive().Invoke(Arg.Any<HttpContext>());
}
private static string CreateJwtWithRole(string role)
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("test-signing-key-at-least-32-chars-long!"));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
claims: [new Claim(ClaimTypes.Role, role)],
expires: DateTime.UtcNow.AddHours(1),
signingCredentials: creds
);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}
@@ -0,0 +1,69 @@
using System.Security.Claims;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using NSubstitute;
using SlpModularCms.Core.Availability;
using SlpModularCms.Modules.Availability.Controllers;
using SlpModularCms.Modules.Availability.Services;
namespace SlpModularCms.Modules.Availability.Tests.Controllers;
public class AvailabilityControllerTests
{
private readonly IAvailabilityService _availabilityService = Substitute.For<IAvailabilityService>();
private AvailabilityController CreateController(string? callerName = null)
{
var controller = new AvailabilityController(_availabilityService);
var httpContext = new DefaultHttpContext();
if (callerName is not null)
{
httpContext.User = new ClaimsPrincipal(
new ClaimsIdentity([new Claim(ClaimTypes.Name, callerName)], "TestAuth"));
}
controller.ControllerContext = new ControllerContext { HttpContext = httpContext };
return controller;
}
[Fact]
public async Task GetStatus_ReturnsOk_WithStatusDetails()
{
_availabilityService.GetStatusDetailsAsync()
.Returns(new AvailabilityStatusDetails(AvailabilityStatus.Available, null));
var result = await CreateController().GetStatus();
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
ok.Value.Should().BeEquivalentTo(new
{
Status = "Available",
Message = string.Empty,
}, options => options.ExcludingMissingMembers());
}
[Fact]
public async Task GetStatus_ReturnsOk_WithMaintenanceMessageIncluded()
{
_availabilityService.GetStatusDetailsAsync()
.Returns(new AvailabilityStatusDetails(AvailabilityStatus.Maintenance, "Gepland onderhoud"));
var result = await CreateController().GetStatus();
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
ok.Value.Should().BeEquivalentTo(new
{
Status = "Maintenance",
Message = "Gepland onderhoud",
}, options => options.ExcludingMissingMembers());
}
[Fact]
public async Task UpdateStatus_ReturnsBadRequest_WhenServiceIsNotPersistentAvailabilityService()
{
var result = await CreateController("admin@test.com")
.UpdateStatus(new UpdateStatusRequest(AvailabilityStatus.Maintenance, "Onderhoud"));
result.Should().BeOfType<BadRequestObjectResult>();
}
}
@@ -57,21 +57,45 @@ public class PersistentAvailabilityServiceTests
[Fact]
public async Task CircuitBreaker_ShouldReturnCachedStatus_AfterDatabaseError()
{
// Deze test is lastig met in-memory DB omdat deze zelden 'failt'.
// Maar we kunnen de logica testen door de context te disposen (simulatie van DB error).
// Arrange
await _service.UpdateStatusAsync(AvailabilityStatus.Available, "Reset", "System");
_context.Dispose(); // Forceer error op volgende DB call
_context.Dispose(); // Force DB error on next call
// Act
var status1 = await _service.IsAvailableAsync(); // Deze zou moeten failen en circuit breaker activeren
var status1 = await _service.IsAvailableAsync();
status1.Should().Be(AvailabilityStatus.Available); // Fallback
// Assert
status1.Should().Be(AvailabilityStatus.Available); // Fallback status
// Act 2: Circuit breaker is nu actief
var status2 = await _service.IsAvailableAsync();
status2.Should().Be(AvailabilityStatus.Available); // Direct uit cache
status2.Should().Be(AvailabilityStatus.Available); // From cache (circuit breaker active)
}
[Fact]
public async Task GetStatusDetailsAsync_ShouldReturnAvailable_WhenNoStateInDb()
{
var details = await _service.GetStatusDetailsAsync();
details.Status.Should().Be(AvailabilityStatus.Available);
details.Message.Should().BeNull();
}
[Fact]
public async Task GetStatusDetailsAsync_ShouldReturnCorrectStatusAndMessage()
{
await _service.UpdateStatusAsync(AvailabilityStatus.Maintenance, "Scheduled maintenance", "Admin");
var details = await _service.GetStatusDetailsAsync();
details.Status.Should().Be(AvailabilityStatus.Maintenance);
details.Message.Should().Be("Scheduled maintenance");
}
[Fact]
public async Task GetStatusDetailsAsync_ShouldReturnFallback_WhenDatabaseError()
{
await _service.UpdateStatusAsync(AvailabilityStatus.Available, null, "System");
_context.Dispose(); // Force DB error
var details = await _service.GetStatusDetailsAsync();
details.Status.Should().Be(AvailabilityStatus.Available); // Fallback
}
}
@@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using SlpModularCms.Core.Availability;
@@ -7,6 +8,7 @@ using SlpModularCms.Modules.Availability.Services;
namespace SlpModularCms.Modules.Availability;
[ExcludeFromCodeCoverage]
public class AvailabilityModule : IModule
{
public string Name => "Availability";
@@ -47,6 +47,7 @@ public class AvailabilityController : ControllerBase
}
}
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
public record UpdateStatusRequest(
AvailabilityStatus NewStatus,
string? Reason
@@ -0,0 +1,212 @@
using System.Security.Claims;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using SlpModularCms.Core.Identity.Models;
using SlpModularCms.Core.Identity.Services;
using SlpModularCms.Modules.Identity.Controllers;
namespace SlpModularCms.Modules.Identity.Tests.Controllers;
public class AuthControllerTests
{
private readonly IAuthService _authService = Substitute.For<IAuthService>();
private readonly JwtSettings _jwtSettings = new()
{
Secret = "test-secret-key-at-least-32-chars-long!",
Issuer = "test",
Audience = "test",
ExpiryMinutes = 60,
RefreshTokenExpiryDays = 7,
CookieSameSite = "Strict"
};
private AuthController CreateController(string? refreshTokenCookie = null, bool isAuthenticated = false, string? userId = null)
{
var options = Options.Create(_jwtSettings);
var controller = new AuthController(_authService, options);
var httpContext = new DefaultHttpContext();
if (refreshTokenCookie is not null)
{
var cookiesMock = Substitute.For<IRequestCookieCollection>();
cookiesMock["refreshToken"].Returns(refreshTokenCookie);
cookiesMock.TryGetValue("refreshToken", out Arg.Any<string?>()).Returns(x =>
{
x[1] = refreshTokenCookie;
return true;
});
// Use a real DefaultHttpContext but override with a simple helper
httpContext.Request.Headers["Cookie"] = $"refreshToken={refreshTokenCookie}";
}
if (isAuthenticated && userId is not null)
{
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userId) };
httpContext.User = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
}
controller.ControllerContext = new ControllerContext { HttpContext = httpContext };
return controller;
}
private TokenResponse MakeTokenResponse(string refresh = "refresh-token") =>
new(AccessToken: "access", RefreshToken: refresh, ExpiresAt: DateTimeOffset.UtcNow.AddHours(1),
User: new UserResponse(Guid.NewGuid(), "test@test.com", "Test", "User", true));
[Fact]
public async Task Login_ReturnsOk_WithTokenResponse()
{
var response = MakeTokenResponse();
_authService.AuthenticateAsync("test@test.com", "Password1!").Returns(response);
var controller = CreateController();
var result = await controller.Login(new LoginRequest("test@test.com", "Password1!"));
result.Should().BeOfType<OkObjectResult>()
.Which.Value.Should().Be(response);
}
[Fact]
public async Task Refresh_ReturnsUnauthorized_WhenNoCookie()
{
var controller = CreateController(refreshTokenCookie: null);
var result = await controller.Refresh();
result.Should().BeOfType<UnauthorizedResult>();
}
[Fact]
public async Task Refresh_ReturnsUnauthorized_WhenCookieIsEmpty()
{
var controller = CreateController(refreshTokenCookie: "");
var result = await controller.Refresh();
result.Should().BeOfType<UnauthorizedResult>();
}
[Fact]
public async Task Refresh_ReturnsOk_WhenCookiePresent()
{
var response = MakeTokenResponse("new-refresh-token");
_authService.RefreshTokenAsync(Arg.Any<string>()).Returns(response);
// Manually set cookie via IHeaderDictionary since DefaultHttpContext reads cookies from header
var options = Options.Create(_jwtSettings);
var controller = new AuthController(_authService, options);
var httpContext = new DefaultHttpContext();
httpContext.Request.Headers.Append("Cookie", "refreshToken=some-token");
controller.ControllerContext = new ControllerContext { HttpContext = httpContext };
var result = await controller.Refresh();
result.Should().BeOfType<OkObjectResult>()
.Which.Value.Should().Be(response);
}
[Fact]
public async Task Revoke_ReturnsNoContent_WhenCookiePresent()
{
var options = Options.Create(_jwtSettings);
var controller = new AuthController(_authService, options);
var httpContext = new DefaultHttpContext();
httpContext.Request.Headers.Append("Cookie", "refreshToken=some-token");
controller.ControllerContext = new ControllerContext { HttpContext = httpContext };
var result = await controller.Revoke();
await _authService.Received(1).RevokeTokenAsync(Arg.Any<string>());
result.Should().BeOfType<NoContentResult>();
}
[Fact]
public async Task Revoke_ReturnsNoContent_WhenNoCookie()
{
var controller = CreateController(refreshTokenCookie: null);
var result = await controller.Revoke();
await _authService.DidNotReceive().RevokeTokenAsync(Arg.Any<string>());
result.Should().BeOfType<NoContentResult>();
}
[Fact]
public async Task ChangePassword_ReturnsUnauthorized_WhenNoUserIdClaim()
{
var controller = CreateController(isAuthenticated: false);
var result = await controller.ChangePassword(new ChangePasswordRequest("Old1!", "New1!"));
result.Should().BeOfType<UnauthorizedResult>();
}
[Fact]
public async Task ChangePassword_ReturnsUnauthorized_WhenUserIdClaimIsNotAGuid()
{
var options = Options.Create(_jwtSettings);
var controller = new AuthController(_authService, options);
var httpContext = new DefaultHttpContext();
httpContext.User = new ClaimsPrincipal(
new ClaimsIdentity([new Claim(ClaimTypes.NameIdentifier, "not-a-guid")], "TestAuth"));
controller.ControllerContext = new ControllerContext { HttpContext = httpContext };
var result = await controller.ChangePassword(new ChangePasswordRequest("Old1!", "New1!"));
result.Should().BeOfType<UnauthorizedResult>();
}
[Fact]
public async Task Login_SetsSecureCookie_WhenSameSiteIsNone()
{
// Exercises the SameSiteMode.None branch in GetCookieOptions → Secure = true
var response = MakeTokenResponse();
_authService.AuthenticateAsync(Arg.Any<string>(), Arg.Any<string>()).Returns(response);
var settings = new JwtSettings { Secret = _jwtSettings.Secret, Issuer = _jwtSettings.Issuer, Audience = _jwtSettings.Audience, CookieSameSite = "None" };
var controller = new AuthController(_authService, Options.Create(settings));
controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() };
var result = await controller.Login(new LoginRequest("test@test.com", "Password1!"));
result.Should().BeOfType<OkObjectResult>();
}
[Fact]
public async Task Login_FallsBackToStrictSameSite_WhenCookieSameSiteIsInvalid()
{
// Exercises the Enum.TryParse failure branch → SameSiteMode.Strict
var response = MakeTokenResponse();
_authService.AuthenticateAsync(Arg.Any<string>(), Arg.Any<string>()).Returns(response);
var settings = new JwtSettings { Secret = _jwtSettings.Secret, Issuer = _jwtSettings.Issuer, Audience = _jwtSettings.Audience, CookieSameSite = "Invalid" };
var controller = new AuthController(_authService, Options.Create(settings));
controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() };
var result = await controller.Login(new LoginRequest("test@test.com", "Password1!"));
result.Should().BeOfType<OkObjectResult>();
}
[Fact]
public async Task ChangePassword_ReturnsOk_WhenUserIdClaimIsValid()
{
var userId = Guid.NewGuid();
var options = Options.Create(_jwtSettings);
var controller = new AuthController(_authService, options);
var httpContext = new DefaultHttpContext();
httpContext.User = new ClaimsPrincipal(
new ClaimsIdentity([new Claim(ClaimTypes.NameIdentifier, userId.ToString())], "TestAuth"));
controller.ControllerContext = new ControllerContext { HttpContext = httpContext };
var result = await controller.ChangePassword(new ChangePasswordRequest("Old1!", "New1!"));
await _authService.Received(1).ChangePasswordAsync(userId, "Old1!", "New1!");
result.Should().BeOfType<OkResult>();
}
}
@@ -0,0 +1,128 @@
using FluentAssertions;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using NSubstitute;
using SlpModularCms.Core.Data;
using SlpModularCms.Core.Identity.Entities;
using SlpModularCms.Core.Identity.Models;
using SlpModularCms.Core.Identity.Services;
using SlpModularCms.Modules.Identity.Controllers;
namespace SlpModularCms.Modules.Identity.Tests.Controllers;
public class InvitationControllerTests : IDisposable
{
private readonly ApplicationDbContext _context;
private readonly IInvitationService _invitationService = Substitute.For<IInvitationService>();
public InvitationControllerTests()
{
var dbOptions = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
_context = new ApplicationDbContext(dbOptions);
}
public void Dispose() => _context.Dispose();
private InvitationController CreateController() =>
new(_invitationService, _context);
[Fact]
public async Task Validate_ReturnsFalse_WhenTokenIsEmpty()
{
var controller = CreateController();
var result = await controller.Validate(string.Empty);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
ok.Value.Should().BeEquivalentTo(new { isValid = false, email = (string?)null, name = (string?)null, errorCode = "NOT_FOUND" });
}
[Fact]
public async Task Validate_ReturnsFalse_WhenTokenNotFound()
{
var controller = CreateController();
var result = await controller.Validate("unknown-token");
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
ok.Value.Should().BeEquivalentTo(new { isValid = false, email = (string?)null, name = (string?)null, errorCode = "NOT_FOUND" });
}
[Fact]
public async Task Validate_ReturnsFalse_WhenInvitationIsAlreadyAccepted()
{
var invitation = new Invitation
{
Id = Guid.NewGuid(),
Token = "used-token",
Email = "user@test.com",
Role = "User",
IsAccepted = true,
ExpiryDate = DateTimeOffset.UtcNow.AddDays(1)
};
_context.Invitations.Add(invitation);
await _context.SaveChangesAsync();
var result = await CreateController().Validate("used-token");
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
ok.Value.Should().BeEquivalentTo(new { isValid = false, email = "user@test.com", name = (string?)null, errorCode = "USED" });
}
[Fact]
public async Task Validate_ReturnsFalse_WhenInvitationIsExpired()
{
var invitation = new Invitation
{
Id = Guid.NewGuid(),
Token = "expired-token",
Email = "user@test.com",
Role = "User",
IsAccepted = false,
ExpiryDate = DateTimeOffset.UtcNow.AddDays(-1) // in the past
};
_context.Invitations.Add(invitation);
await _context.SaveChangesAsync();
var result = await CreateController().Validate("expired-token");
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
ok.Value.Should().BeEquivalentTo(new { isValid = false, email = "user@test.com", name = (string?)null, errorCode = "EXPIRED" });
}
[Fact]
public async Task Validate_ReturnsTrue_WhenInvitationIsValid()
{
var invitation = new Invitation
{
Id = Guid.NewGuid(),
Token = "valid-token",
Email = "user@test.com",
Role = "User",
IsAccepted = false,
ExpiryDate = DateTimeOffset.UtcNow.AddDays(1)
};
_context.Invitations.Add(invitation);
await _context.SaveChangesAsync();
var result = await CreateController().Validate("valid-token");
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
ok.Value.Should().BeEquivalentTo(new { isValid = true, email = "user@test.com", name = (string?)null, errorCode = (string?)null });
}
[Fact]
public async Task Complete_ReturnsOk_WithSuccessMessage()
{
var controller = CreateController();
var result = await controller.Complete(new CompleteSetupRequest("some-token", "Password1!"));
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
ok.Value.Should().BeEquivalentTo(new { message = "Account succesvol ingesteld. Je kunt nu inloggen." });
await _invitationService.Received(1).CompleteInvitationAsync("some-token", "Password1!");
}
}
@@ -0,0 +1,49 @@
using FluentAssertions;
using Microsoft.AspNetCore.Mvc;
using NSubstitute;
using SlpModularCms.Core.Identity.Models;
using SlpModularCms.Core.Identity.Services;
using SlpModularCms.Modules.Identity.Controllers;
namespace SlpModularCms.Modules.Identity.Tests.Controllers;
public class SetupControllerTests
{
private readonly ISetupService _setupService = Substitute.For<ISetupService>();
[Fact]
public async Task GetStatus_ReturnsOk_WithInitializedTrue_WhenSystemIsSetUp()
{
_setupService.IsSystemInitializedAsync().Returns(true);
var controller = new SetupController(_setupService);
var result = await controller.GetStatus();
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
ok.Value.Should().BeEquivalentTo(new { Initialized = true });
}
[Fact]
public async Task GetStatus_ReturnsOk_WithInitializedFalse_WhenSystemIsNotSetUp()
{
_setupService.IsSystemInitializedAsync().Returns(false);
var controller = new SetupController(_setupService);
var result = await controller.GetStatus();
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
ok.Value.Should().BeEquivalentTo(new { Initialized = false });
}
[Fact]
public async Task CreateOwner_ReturnsOk_WithSuccessMessage()
{
var controller = new SetupController(_setupService);
var result = await controller.CreateOwner(new CreateOwnerRequest("Owner", "owner@test.com", "Password1!"));
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
ok.Value.Should().BeEquivalentTo(new { Message = "Systeem succesvol geïnitialiseerd. De eerste eigenaar is aangemaakt." });
await _setupService.Received(1).CreateInitialOwnerAsync("Owner", "owner@test.com", "Password1!");
}
}
@@ -0,0 +1,311 @@
using System.Security.Claims;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using SlpModularCms.Core.Data;
using SlpModularCms.Core.Identity.Entities;
using SlpModularCms.Core.Identity.Models;
using SlpModularCms.Core.Identity.Services;
using SlpModularCms.Modules.Identity.Controllers;
namespace SlpModularCms.Modules.Identity.Tests.Controllers;
public class UsersControllerTests : IDisposable
{
private readonly ApplicationDbContext _context;
private readonly UserManager<ApplicationUser> _userManager;
private readonly RoleManager<ApplicationRole> _roleManager;
private readonly IInvitationService _invitationService = Substitute.For<IInvitationService>();
public UsersControllerTests()
{
var dbOptions = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
_context = new ApplicationDbContext(dbOptions);
var userStore = new UserStore<ApplicationUser, ApplicationRole, ApplicationDbContext, Guid>(_context);
_userManager = new UserManager<ApplicationUser>(
userStore,
Options.Create(new IdentityOptions()),
new PasswordHasher<ApplicationUser>(),
Array.Empty<IUserValidator<ApplicationUser>>(),
Array.Empty<IPasswordValidator<ApplicationUser>>(),
new UpperInvariantLookupNormalizer(),
new IdentityErrorDescriber(),
null!,
NullLogger<UserManager<ApplicationUser>>.Instance);
var roleStore = new RoleStore<ApplicationRole, ApplicationDbContext, Guid>(_context);
_roleManager = new RoleManager<ApplicationRole>(
roleStore,
Array.Empty<IRoleValidator<ApplicationRole>>(),
new UpperInvariantLookupNormalizer(),
new IdentityErrorDescriber(),
NullLogger<RoleManager<ApplicationRole>>.Instance);
}
public void Dispose()
{
_userManager.Dispose();
_roleManager.Dispose();
_context.Dispose();
}
private UsersController CreateController(string? callerEmail = null)
{
var controller = new UsersController(_invitationService, _userManager);
var httpContext = new DefaultHttpContext();
if (callerEmail is not null)
{
httpContext.User = new ClaimsPrincipal(
new ClaimsIdentity([new Claim(ClaimTypes.Name, callerEmail)], "TestAuth"));
}
controller.ControllerContext = new ControllerContext { HttpContext = httpContext };
return controller;
}
private async Task<ApplicationUser> CreateUser(string email, string role = "User", bool isActive = true)
{
if (!await _roleManager.RoleExistsAsync(role))
await _roleManager.CreateAsync(new ApplicationRole { Name = role });
var user = new ApplicationUser
{
Id = Guid.NewGuid(),
Email = email,
UserName = email,
NormalizedEmail = email.ToUpperInvariant(),
NormalizedUserName = email.ToUpperInvariant(),
DisplayName = email,
IsActive = isActive,
CreatedAt = DateTimeOffset.UtcNow
};
await _userManager.CreateAsync(user, "Password1!");
await _userManager.AddToRoleAsync(user, role);
return user;
}
// --- GetUsers ---
[Fact]
public async Task GetUsers_ReturnsOk_WithUsersList()
{
_invitationService.GetPendingInvitationByEmailAsync(Arg.Any<string>()).Returns((false, null));
_invitationService.GetAllPendingInvitationsAsync().Returns([]);
await CreateUser("user@test.com", "User");
var result = await CreateController().GetUsers();
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var list = ok.Value.Should().BeAssignableTo<IEnumerable<UserDto>>().Subject;
list.Should().HaveCount(1);
list.First().Email.Should().Be("user@test.com");
}
[Fact]
public async Task GetUsers_IncludesPendingInvitationsWithoutAccount()
{
_invitationService.GetPendingInvitationByEmailAsync(Arg.Any<string>()).Returns((false, null));
_invitationService.GetAllPendingInvitationsAsync().Returns([
new PendingInvitationInfo(Guid.NewGuid(), "pending@test.com", "User", "tok", DateTimeOffset.UtcNow)
]);
var result = await CreateController().GetUsers();
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var list = ok.Value.Should().BeAssignableTo<IEnumerable<UserDto>>().Subject;
list.Should().Contain(u => u.Email == "pending@test.com" && u.InvitationPending);
}
// --- UpdateProfile ---
[Fact]
public async Task UpdateProfile_ReturnsUnauthorized_WhenNoNameIdentifierClaim()
{
var controller = new UsersController(_invitationService, _userManager);
controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() };
var result = await controller.UpdateProfile(new UpdateProfileRequest("Name", "email@test.com"));
result.Should().BeOfType<UnauthorizedResult>();
}
[Fact]
public async Task UpdateProfile_ReturnsNotFound_WhenUserDoesNotExist()
{
var options = Options.Create(new IdentityOptions());
var controller = new UsersController(_invitationService, _userManager);
var httpContext = new DefaultHttpContext();
httpContext.User = new ClaimsPrincipal(
new ClaimsIdentity([new Claim(ClaimTypes.NameIdentifier, Guid.NewGuid().ToString())], "Test"));
controller.ControllerContext = new ControllerContext { HttpContext = httpContext };
var result = await controller.UpdateProfile(new UpdateProfileRequest("Name", "newemail@test.com"));
result.Should().BeOfType<NotFoundResult>();
}
[Fact]
public async Task UpdateProfile_ReturnsBadRequest_WhenEmailAlreadyInUse()
{
var existingUser = await CreateUser("existing@test.com");
var targetUser = await CreateUser("target@test.com");
var controller = new UsersController(_invitationService, _userManager);
var httpContext = new DefaultHttpContext();
httpContext.User = new ClaimsPrincipal(
new ClaimsIdentity([new Claim(ClaimTypes.NameIdentifier, targetUser.Id.ToString())], "Test"));
controller.ControllerContext = new ControllerContext { HttpContext = httpContext };
var result = await controller.UpdateProfile(new UpdateProfileRequest("Name", "existing@test.com"));
result.Should().BeOfType<BadRequestObjectResult>();
}
[Fact]
public async Task UpdateProfile_ReturnsOk_WhenUpdateSucceeds()
{
var user = await CreateUser("user@test.com");
var controller = new UsersController(_invitationService, _userManager);
var httpContext = new DefaultHttpContext();
httpContext.User = new ClaimsPrincipal(
new ClaimsIdentity([new Claim(ClaimTypes.NameIdentifier, user.Id.ToString())], "Test"));
controller.ControllerContext = new ControllerContext { HttpContext = httpContext };
var result = await controller.UpdateProfile(new UpdateProfileRequest("New Name", "user@test.com"));
result.Should().BeOfType<OkObjectResult>()
.Which.Value.Should().BeOfType<UserDto>()
.Which.Name.Should().Be("New Name");
}
// --- Invite ---
[Fact]
public async Task Invite_ReturnsOk_WithInviteLink()
{
_invitationService.CreateInvitationAsync("new@test.com", "User").Returns("test-token");
var result = await CreateController().Invite(new InviteUserRequest("new@test.com", "User"));
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
ok.Value.Should().BeEquivalentTo(new { inviteLink = "/invite/complete?token=test-token" });
}
// --- SetUserActive ---
[Fact]
public async Task SetUserActive_ReturnsNotFound_WhenUserDoesNotExist()
{
var result = await CreateController().SetUserActive(Guid.NewGuid(), new SetUserActiveRequest(false));
result.Should().BeOfType<NotFoundResult>();
}
[Fact]
public async Task SetUserActive_ReturnsBadRequest_WhenDeactivatingSelf()
{
var user = await CreateUser("self@test.com");
var result = await CreateController("self@test.com").SetUserActive(user.Id, new SetUserActiveRequest(false));
result.Should().BeOfType<BadRequestObjectResult>();
}
[Fact]
public async Task SetUserActive_ReturnsOk_WhenActivatingOtherUser()
{
var user = await CreateUser("other@test.com", "User", false);
var result = await CreateController("admin@test.com").SetUserActive(user.Id, new SetUserActiveRequest(true));
result.Should().BeOfType<OkResult>();
}
// --- DeleteUser ---
[Fact]
public async Task DeleteUser_ReturnsNotFound_WhenNoUserAndNoPendingInvitation()
{
_invitationService.DeleteInvitationByIdAsync(Arg.Any<Guid>()).Returns(false);
var result = await CreateController().DeleteUser(Guid.NewGuid());
result.Should().BeOfType<NotFoundResult>();
}
[Fact]
public async Task DeleteUser_ReturnsNoContent_WhenPendingInvitationIsDeleted()
{
_invitationService.DeleteInvitationByIdAsync(Arg.Any<Guid>()).Returns(true);
var result = await CreateController().DeleteUser(Guid.NewGuid());
result.Should().BeOfType<NoContentResult>();
}
[Fact]
public async Task DeleteUser_ReturnsBadRequest_WhenDeletingSelf()
{
var user = await CreateUser("self@test.com");
var result = await CreateController("self@test.com").DeleteUser(user.Id);
result.Should().BeOfType<BadRequestObjectResult>();
}
[Fact]
public async Task DeleteUser_ReturnsNoContent_WhenDeletingOtherUser()
{
var user = await CreateUser("other@test.com");
_invitationService.DeletePendingInvitationsByEmailAsync(Arg.Any<string>()).Returns(Task.CompletedTask);
var result = await CreateController("admin@test.com").DeleteUser(user.Id);
result.Should().BeOfType<NoContentResult>();
}
// --- ChangeRole ---
[Fact]
public async Task ChangeRole_ReturnsNotFound_WhenNoUserAndInvitationUpdateFails()
{
_invitationService.UpdateInvitationRoleAsync(Arg.Any<Guid>(), Arg.Any<string>()).Returns(false);
var result = await CreateController().ChangeRole(Guid.NewGuid(), new ChangeRoleRequest("User"));
result.Should().BeOfType<NotFoundResult>();
}
[Fact]
public async Task ChangeRole_ReturnsOk_WhenNoUserButInvitationUpdated()
{
_invitationService.UpdateInvitationRoleAsync(Arg.Any<Guid>(), Arg.Any<string>()).Returns(true);
// Need a caller identity with a name to check requester role
var result = await CreateController("admin@test.com").ChangeRole(Guid.NewGuid(), new ChangeRoleRequest("User"));
result.Should().BeOfType<OkResult>();
}
[Fact]
public async Task ChangeRole_ReturnsOk_WhenUserRoleUpdated()
{
await _roleManager.CreateAsync(new ApplicationRole { Name = "Owner" });
await _roleManager.CreateAsync(new ApplicationRole { Name = "Administrator" });
var user = await CreateUser("user@test.com", "User");
var result = await CreateController("owner@test.com").ChangeRole(user.Id, new ChangeRoleRequest("Administrator"));
result.Should().BeOfType<OkResult>();
}
}
@@ -0,0 +1,76 @@
using System.Reflection;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ApplicationModels;
using SlpModularCms.Api.Infrastructure;
namespace SlpModularCms.Modules.Identity.Tests.Infrastructure;
public class ApiPrefixConventionTests
{
[Fact]
public void Apply_PrependsPrefixToExistingAttributeRoute()
{
var convention = new ApiPrefixConvention("api/v1");
var application = BuildApplicationModel("[controller]");
convention.Apply(application);
var template = application.Controllers[0].Selectors[0].AttributeRouteModel!.Template;
template.Should().Be("api/v1/[controller]");
}
[Fact]
public void Apply_SetsPrefixAsRoute_WhenNoExistingAttributeRoute()
{
var convention = new ApiPrefixConvention("api/v1");
var application = BuildApplicationModelWithoutRoute();
convention.Apply(application);
var template = application.Controllers[0].Selectors[0].AttributeRouteModel!.Template;
template.Should().Be("api/v1");
}
[Fact]
public void Apply_HandlesMulitpleControllers()
{
var convention = new ApiPrefixConvention("api/v1");
var application = new ApplicationModel();
application.Controllers.Add(BuildController("[controller]"));
application.Controllers.Add(BuildController("other"));
convention.Apply(application);
application.Controllers[0].Selectors[0].AttributeRouteModel!.Template.Should().Be("api/v1/[controller]");
application.Controllers[1].Selectors[0].AttributeRouteModel!.Template.Should().Be("api/v1/other");
}
private static ApplicationModel BuildApplicationModel(string routeTemplate)
{
var app = new ApplicationModel();
app.Controllers.Add(BuildController(routeTemplate));
return app;
}
private static ApplicationModel BuildApplicationModelWithoutRoute()
{
var app = new ApplicationModel();
var controller = new ControllerModel(typeof(object).GetConstructors()[0].DeclaringType!.GetTypeInfo(), []);
var selector = new SelectorModel { AttributeRouteModel = null };
controller.Selectors.Add(selector);
app.Controllers.Add(controller);
return app;
}
private static ControllerModel BuildController(string routeTemplate)
{
var controller = new ControllerModel(typeof(object).GetConstructors()[0].DeclaringType!.GetTypeInfo(), []);
var selector = new SelectorModel
{
AttributeRouteModel = new AttributeRouteModel(new RouteAttribute(routeTemplate))
};
controller.Selectors.Add(selector);
return controller;
}
}
@@ -0,0 +1,92 @@
using FluentAssertions;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using SlpModularCms.Api.Infrastructure;
using SlpModularCms.Core.Modules;
namespace SlpModularCms.Modules.Identity.Tests.Infrastructure;
public class ModuleOrchestratorTests
{
private readonly ModuleOrchestrator _orchestrator =
new(NullLogger<ModuleOrchestrator>.Instance);
[Fact]
public void RegisterModuleServices_CallsRegisterServicesOnDiscoveredModules()
{
var module = Substitute.For<IModule>();
var services = new ServiceCollection();
InjectModules(_orchestrator, [module]);
_orchestrator.RegisterModuleServices(services);
module.Received(1).RegisterServices(services);
}
[Fact]
public void UseModules_CallsUseModuleOnDiscoveredModules()
{
var module = Substitute.For<IModule>();
var app = Substitute.For<IApplicationBuilder>();
app.ApplicationServices.Returns(new ServiceCollection().BuildServiceProvider());
InjectModules(_orchestrator, [module]);
_orchestrator.UseModules(app);
module.Received(1).UseModule(app);
}
[Fact]
public void RegisterModuleServices_DoesNotThrow_WhenModuleListIsEmpty()
{
var services = new ServiceCollection();
var act = () => _orchestrator.RegisterModuleServices(services);
act.Should().NotThrow();
}
[Fact]
public void UseModules_DoesNotThrow_WhenModuleListIsEmpty()
{
var app = Substitute.For<IApplicationBuilder>();
app.ApplicationServices.Returns(new ServiceCollection().BuildServiceProvider());
var act = () => _orchestrator.UseModules(app);
act.Should().NotThrow();
}
[Fact]
public void DiscoverModules_RunsWithoutThrowing_InTestEnvironment()
{
var act = () => _orchestrator.DiscoverModules();
act.Should().NotThrow();
}
[Fact]
public void RegisterModuleServices_ContinuesOnModuleThatThrows()
{
var badModule = Substitute.For<IModule>();
badModule.When(m => m.RegisterServices(Arg.Any<IServiceCollection>()))
.Do(_ => throw new InvalidOperationException("boom"));
var goodModule = Substitute.For<IModule>();
var services = new ServiceCollection();
InjectModules(_orchestrator, [badModule, goodModule]);
var act = () => _orchestrator.RegisterModuleServices(services);
act.Should().NotThrow();
goodModule.Received(1).RegisterServices(services);
}
// Inject modules via reflection — ModuleOrchestrator stores them in a private List<IModule>
private static void InjectModules(ModuleOrchestrator orchestrator, IModule[] modules)
{
var field = typeof(ModuleOrchestrator)
.GetField("_modules", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!;
var list = (List<IModule>)field.GetValue(orchestrator)!;
list.AddRange(modules);
}
}
@@ -0,0 +1,36 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="FluentAssertions" Version="8.10.0" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.9" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.9" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\SlpModularCms.Api\SlpModularCms.Api.csproj" />
<ProjectReference Include="..\SlpModularCms.Modules.Identity\SlpModularCms.Modules.Identity.csproj" />
</ItemGroup>
</Project>
@@ -1,9 +1,11 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using SlpModularCms.Core.Modules;
namespace SlpModularCms.Modules.Identity;
[ExcludeFromCodeCoverage]
public class IdentityModule : IModule
{
public string Name => "Identity";