diff --git a/src/backend/Adapters/Driven/Persistence/Configurations/StudentConfiguration.cs b/src/backend/Adapters/Driven/Persistence/Configurations/StudentConfiguration.cs index c8aafde..d57b229 100644 --- a/src/backend/Adapters/Driven/Persistence/Configurations/StudentConfiguration.cs +++ b/src/backend/Adapters/Driven/Persistence/Configurations/StudentConfiguration.cs @@ -35,6 +35,14 @@ public class StudentConfiguration : IEntityTypeConfiguration // Use raw column name for index to avoid value object issues builder.HasIndex("Email").IsUnique(); + // Activation fields + builder.Property(s => s.ActivationCodeHash) + .HasMaxLength(256); + + builder.Property(s => s.ActivationExpiresAt); + + builder.Ignore(s => s.IsActivated); + builder.HasMany(s => s.Enrollments) .WithOne(e => e.Student) .HasForeignKey(e => e.StudentId) diff --git a/src/backend/Adapters/Driven/Persistence/Migrations/20260109055746_AddStudentActivation.Designer.cs b/src/backend/Adapters/Driven/Persistence/Migrations/20260109055746_AddStudentActivation.Designer.cs new file mode 100644 index 0000000..3ce0832 --- /dev/null +++ b/src/backend/Adapters/Driven/Persistence/Migrations/20260109055746_AddStudentActivation.Designer.cs @@ -0,0 +1,337 @@ +// +using System; +using Adapters.Driven.Persistence.Context; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Adapters.Driven.Persistence.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260109055746_AddStudentActivation")] + partial class AddStudentActivation + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Domain.Entities.Enrollment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("EnrolledAt") + .HasColumnType("datetime2"); + + b.Property("StudentId") + .HasColumnType("int"); + + b.Property("SubjectId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SubjectId"); + + b.HasIndex("StudentId", "SubjectId") + .IsUnique(); + + b.ToTable("Enrollments", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Professor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.ToTable("Professors", (string)null); + + b.HasData( + new + { + Id = 1, + Name = "Dr. García" + }, + new + { + Id = 2, + Name = "Dra. Martínez" + }, + new + { + Id = 3, + Name = "Dr. López" + }, + new + { + Id = 4, + Name = "Dra. Rodríguez" + }, + new + { + Id = 5, + Name = "Dr. Hernández" + }); + }); + + modelBuilder.Entity("Domain.Entities.Student", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ActivationCodeHash") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ActivationExpiresAt") + .HasColumnType("datetime2"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Students", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Subject", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Credits") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(3); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ProfessorId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ProfessorId"); + + b.ToTable("Subjects", (string)null); + + b.HasData( + new + { + Id = 1, + Credits = 3, + Name = "Matemáticas I", + ProfessorId = 1 + }, + new + { + Id = 2, + Credits = 3, + Name = "Matemáticas II", + ProfessorId = 1 + }, + new + { + Id = 3, + Credits = 3, + Name = "Física I", + ProfessorId = 2 + }, + new + { + Id = 4, + Credits = 3, + Name = "Física II", + ProfessorId = 2 + }, + new + { + Id = 5, + Credits = 3, + Name = "Programación I", + ProfessorId = 3 + }, + new + { + Id = 6, + Credits = 3, + Name = "Programación II", + ProfessorId = 3 + }, + new + { + Id = 7, + Credits = 3, + Name = "Base de Datos I", + ProfessorId = 4 + }, + new + { + Id = 8, + Credits = 3, + Name = "Base de Datos II", + ProfessorId = 4 + }, + new + { + Id = 9, + Credits = 3, + Name = "Redes I", + ProfessorId = 5 + }, + new + { + Id = 10, + Credits = 3, + Name = "Redes II", + ProfessorId = 5 + }); + }); + + modelBuilder.Entity("Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("LastLoginAt") + .HasColumnType("datetime2"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("RecoveryCodeHash") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("StudentId") + .HasColumnType("int"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("StudentId"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Enrollment", b => + { + b.HasOne("Domain.Entities.Student", "Student") + .WithMany("Enrollments") + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Domain.Entities.Subject", "Subject") + .WithMany("Enrollments") + .HasForeignKey("SubjectId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Student"); + + b.Navigation("Subject"); + }); + + modelBuilder.Entity("Domain.Entities.Subject", b => + { + b.HasOne("Domain.Entities.Professor", "Professor") + .WithMany("Subjects") + .HasForeignKey("ProfessorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Professor"); + }); + + modelBuilder.Entity("Domain.Entities.User", b => + { + b.HasOne("Domain.Entities.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("Domain.Entities.Professor", b => + { + b.Navigation("Subjects"); + }); + + modelBuilder.Entity("Domain.Entities.Student", b => + { + b.Navigation("Enrollments"); + }); + + modelBuilder.Entity("Domain.Entities.Subject", b => + { + b.Navigation("Enrollments"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/backend/Adapters/Driven/Persistence/Migrations/20260109055746_AddStudentActivation.cs b/src/backend/Adapters/Driven/Persistence/Migrations/20260109055746_AddStudentActivation.cs new file mode 100644 index 0000000..d60d1b3 --- /dev/null +++ b/src/backend/Adapters/Driven/Persistence/Migrations/20260109055746_AddStudentActivation.cs @@ -0,0 +1,40 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Adapters.Driven.Persistence.Migrations +{ + /// + public partial class AddStudentActivation : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ActivationCodeHash", + table: "Students", + type: "nvarchar(256)", + maxLength: 256, + nullable: true); + + migrationBuilder.AddColumn( + name: "ActivationExpiresAt", + table: "Students", + type: "datetime2", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ActivationCodeHash", + table: "Students"); + + migrationBuilder.DropColumn( + name: "ActivationExpiresAt", + table: "Students"); + } + } +} diff --git a/src/backend/Adapters/Driven/Persistence/Migrations/AppDbContextModelSnapshot.cs b/src/backend/Adapters/Driven/Persistence/Migrations/AppDbContextModelSnapshot.cs index cd9f37d..ba2331a 100644 --- a/src/backend/Adapters/Driven/Persistence/Migrations/AppDbContextModelSnapshot.cs +++ b/src/backend/Adapters/Driven/Persistence/Migrations/AppDbContextModelSnapshot.cs @@ -102,6 +102,13 @@ namespace Adapters.Driven.Persistence.Migrations SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + b.Property("ActivationCodeHash") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ActivationExpiresAt") + .HasColumnType("datetime2"); + b.Property("Email") .IsRequired() .HasMaxLength(150) diff --git a/src/backend/Adapters/Driven/Persistence/Repositories/StudentRepository.cs b/src/backend/Adapters/Driven/Persistence/Repositories/StudentRepository.cs index df35836..3cde2f5 100644 --- a/src/backend/Adapters/Driven/Persistence/Repositories/StudentRepository.cs +++ b/src/backend/Adapters/Driven/Persistence/Repositories/StudentRepository.cs @@ -115,6 +115,12 @@ public class StudentRepository(AppDbContext context) : IStudentRepository return (resultItems, nextCursor, totalCount); } + public async Task> GetPendingActivationAsync(CancellationToken ct = default) => + await context.Students + .Where(s => s.ActivationCodeHash != null && s.ActivationExpiresAt > DateTime.UtcNow) + .AsNoTracking() + .ToListAsync(ct); + public void Add(Student student) => context.Students.Add(student); public void Update(Student student) => context.Students.Update(student); public void Delete(Student student) => context.Students.Remove(student); diff --git a/src/backend/Adapters/Driving/Api/Types/Mutation.cs b/src/backend/Adapters/Driving/Api/Types/Mutation.cs index b5b28bd..b7b03d2 100644 --- a/src/backend/Adapters/Driving/Api/Types/Mutation.cs +++ b/src/backend/Adapters/Driving/Api/Types/Mutation.cs @@ -1,5 +1,6 @@ namespace Adapters.Driving.Api.Types; +using Application.Auth.Commands; using Application.Enrollments.Commands; using Application.Students.Commands; using Application.Students.DTOs; @@ -13,15 +14,37 @@ using System.Security.Claims; /// public class Mutation { - [Authorize] - [GraphQLDescription("Create a new student (requires authentication)")] - public async Task CreateStudent( + [Authorize(Roles = ["Admin"])] + [GraphQLDescription("Create a new student with activation code (admin only)")] + public async Task CreateStudent( CreateStudentInput input, [Service] IMediator mediator, CancellationToken ct) { var result = await mediator.Send(new CreateStudentCommand(input.Name, input.Email), ct); - return new CreateStudentPayload(result); + return new CreateStudentWithActivationPayload(result); + } + + [Authorize(Roles = ["Admin"])] + [GraphQLDescription("Regenerate activation code for a student (admin only)")] + public async Task RegenerateActivationCode( + int studentId, + [Service] IMediator mediator, + CancellationToken ct) + { + var result = await mediator.Send(new RegenerateActivationCodeCommand(studentId), ct); + return result != null ? new CreateStudentWithActivationPayload(result) : null; + } + + [GraphQLDescription("Activate a student account using activation code (public)")] + public async Task ActivateAccount( + ActivateAccountInput input, + [Service] IMediator mediator, + CancellationToken ct) + { + var result = await mediator.Send( + new ActivateAccountCommand(input.ActivationCode, input.Username, input.Password), ct); + return new ActivateAccountPayload(result.Success, result.Token, result.RecoveryCode, result.Error); } [Authorize] @@ -108,6 +131,7 @@ public class Mutation public record CreateStudentInput(string Name, string Email); public record UpdateStudentInput(string Name, string Email); public record EnrollStudentInput(int StudentId, int SubjectId); +public record ActivateAccountInput(string ActivationCode, string Username, string Password); // Error Types /// @@ -171,3 +195,25 @@ public record UnenrollStudentPayload( { public UnenrollStudentPayload(bool success) : this(success, null) { } } + +/// +/// Payload for CreateStudent mutation with activation code. +/// +public record CreateStudentWithActivationPayload( + StudentDto Student, + string ActivationCode, + string ActivationUrl, + DateTime ExpiresAt) +{ + public CreateStudentWithActivationPayload(CreateStudentResult result) + : this(result.Student, result.ActivationCode, result.ActivationUrl, result.ExpiresAt) { } +} + +/// +/// Payload for ActivateAccount mutation. +/// +public record ActivateAccountPayload( + bool Success, + string? Token, + string? RecoveryCode, + string? Error); diff --git a/src/backend/Adapters/Driving/Api/Types/Query.cs b/src/backend/Adapters/Driving/Api/Types/Query.cs index e030ee2..35630ad 100644 --- a/src/backend/Adapters/Driving/Api/Types/Query.cs +++ b/src/backend/Adapters/Driving/Api/Types/Query.cs @@ -1,5 +1,8 @@ namespace Adapters.Driving.Api.Types; +using Application.Admin.DTOs; +using Application.Admin.Queries; +using Application.Auth.Queries; using Application.Enrollments.DTOs; using Application.Enrollments.Queries; using Application.Professors.DTOs; @@ -8,6 +11,7 @@ using Application.Students.DTOs; using Application.Students.Queries; using Application.Subjects.DTOs; using Application.Subjects.Queries; +using HotChocolate.Authorization; using MediatR; public class Query @@ -58,4 +62,18 @@ public class Query [Service] IMediator mediator, CancellationToken ct) => await mediator.Send(new GetClassmatesQuery(studentId), ct); + + [Authorize(Roles = ["Admin"])] + [GraphQLDescription("Get admin statistics (requires Admin role)")] + public async Task GetAdminStats( + [Service] IMediator mediator, + CancellationToken ct) => + await mediator.Send(new GetAdminStatsQuery(), ct); + + [GraphQLDescription("Validate an activation code (public)")] + public async Task ValidateActivationCode( + string code, + [Service] IMediator mediator, + CancellationToken ct) => + await mediator.Send(new ValidateActivationCodeQuery(code), ct); } diff --git a/src/backend/Application/Application.csproj b/src/backend/Application/Application.csproj index f1c4b91..da2bec2 100644 --- a/src/backend/Application/Application.csproj +++ b/src/backend/Application/Application.csproj @@ -14,6 +14,7 @@ + diff --git a/src/backend/Application/Auth/Commands/ActivateAccountCommand.cs b/src/backend/Application/Auth/Commands/ActivateAccountCommand.cs new file mode 100644 index 0000000..62d8b9a --- /dev/null +++ b/src/backend/Application/Auth/Commands/ActivateAccountCommand.cs @@ -0,0 +1,85 @@ +using System.Security.Cryptography; +using Application.Students.DTOs; +using Domain.Entities; +using Domain.Ports.Repositories; +using MediatR; + +namespace Application.Auth.Commands; + +public record ActivateAccountCommand( + string ActivationCode, + string Username, + string Password +) : IRequest; + +public class ActivateAccountHandler( + IStudentRepository studentRepository, + IUserRepository userRepository, + IPasswordService passwordService, + IJwtService jwtService, + IUnitOfWork unitOfWork +) : IRequestHandler +{ + public async Task Handle(ActivateAccountCommand request, CancellationToken ct) + { + // Find student with matching activation code + var students = await studentRepository.GetPendingActivationAsync(ct); + var student = students.FirstOrDefault(s => + s.ActivationCodeHash != null && + passwordService.VerifyPassword(request.ActivationCode, s.ActivationCodeHash)); + + if (student == null) + return new ActivateAccountResult(false, null, null, "Codigo de activacion invalido o expirado"); + + if (student.IsActivationExpired()) + return new ActivateAccountResult(false, null, null, "El codigo de activacion ha expirado"); + + // Check if username already exists + if (await userRepository.ExistsAsync(request.Username, ct)) + return new ActivateAccountResult(false, null, null, "El nombre de usuario ya existe"); + + // Validate password + if (request.Password.Length < 6) + return new ActivateAccountResult(false, null, null, "La contrasena debe tener al menos 6 caracteres"); + + // Generate recovery code + var recoveryCode = GenerateRecoveryCode(); + var recoveryCodeHash = passwordService.HashPassword(recoveryCode); + + // Create user account + var passwordHash = passwordService.HashPassword(request.Password); + var user = User.Create( + request.Username, + passwordHash, + recoveryCodeHash, + UserRoles.Student, + student.Id + ); + + await userRepository.AddAsync(user, ct); + + // Clear activation code (mark as activated) + // Need to re-fetch with tracking + var trackedStudent = await studentRepository.GetByIdAsync(student.Id, ct); + trackedStudent?.ClearActivationCode(); + + await unitOfWork.SaveChangesAsync(ct); + + // Generate JWT token for auto-login + var token = jwtService.GenerateToken(user); + + return new ActivateAccountResult( + Success: true, + Token: token, + RecoveryCode: recoveryCode, + Error: null + ); + } + + private static string GenerateRecoveryCode() + { + const string chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + var bytes = RandomNumberGenerator.GetBytes(12); + return new string(bytes.Select(b => chars[b % chars.Length]).ToArray()); + } +} diff --git a/src/backend/Application/Auth/Queries/ValidateActivationCodeQuery.cs b/src/backend/Application/Auth/Queries/ValidateActivationCodeQuery.cs new file mode 100644 index 0000000..3b34ab6 --- /dev/null +++ b/src/backend/Application/Auth/Queries/ValidateActivationCodeQuery.cs @@ -0,0 +1,30 @@ +using Application.Students.DTOs; +using Domain.Ports.Repositories; +using MediatR; + +namespace Application.Auth.Queries; + +public record ValidateActivationCodeQuery(string Code) : IRequest; + +public class ValidateActivationCodeHandler( + IStudentRepository studentRepository, + IPasswordService passwordService +) : IRequestHandler +{ + public async Task Handle(ValidateActivationCodeQuery request, CancellationToken ct) + { + var students = await studentRepository.GetPendingActivationAsync(ct); + + var student = students.FirstOrDefault(s => + s.ActivationCodeHash != null && + passwordService.VerifyPassword(request.Code, s.ActivationCodeHash)); + + if (student == null) + return new ActivationValidationResult(false, null, "Codigo de activacion invalido"); + + if (student.IsActivationExpired()) + return new ActivationValidationResult(false, null, "El codigo de activacion ha expirado"); + + return new ActivationValidationResult(true, student.Name, null); + } +} diff --git a/src/backend/Application/Students/Commands/CreateStudentCommand.cs b/src/backend/Application/Students/Commands/CreateStudentCommand.cs index f0d68f8..76eaa62 100644 --- a/src/backend/Application/Students/Commands/CreateStudentCommand.cs +++ b/src/backend/Application/Students/Commands/CreateStudentCommand.cs @@ -1,26 +1,55 @@ namespace Application.Students.Commands; +using System.Security.Cryptography; +using Application.Auth; using Application.Students.DTOs; using Domain.Entities; using Domain.Ports.Repositories; using Domain.ValueObjects; using MediatR; +using Microsoft.Extensions.Configuration; -public record CreateStudentCommand(string Name, string Email) : IRequest; +public record CreateStudentCommand(string Name, string Email, string? BaseUrl = null) + : IRequest; public class CreateStudentHandler( IStudentRepository studentRepository, - IUnitOfWork unitOfWork) - : IRequestHandler + IPasswordService passwordService, + IUnitOfWork unitOfWork, + IConfiguration configuration) + : IRequestHandler { - public async Task Handle(CreateStudentCommand request, CancellationToken ct) + private static readonly TimeSpan ActivationExpiration = TimeSpan.FromHours(48); + + public async Task Handle(CreateStudentCommand request, CancellationToken ct) { var email = Email.Create(request.Email); var student = new Student(request.Name, email); + // Generate activation code + var activationCode = GenerateActivationCode(); + var codeHash = passwordService.HashPassword(activationCode); + student.SetActivationCode(codeHash, ActivationExpiration); + studentRepository.Add(student); await unitOfWork.SaveChangesAsync(ct); - return new StudentDto(student.Id, student.Name, student.Email.Value, 0, []); + var baseUrl = request.BaseUrl ?? configuration["App:BaseUrl"] ?? "http://localhost:4200"; + var activationUrl = $"{baseUrl}/activate?code={activationCode}"; + + var studentDto = new StudentDto(student.Id, student.Name, student.Email.Value, 0, []); + + return new CreateStudentResult( + studentDto, + activationCode, + activationUrl, + student.ActivationExpiresAt!.Value); + } + + private static string GenerateActivationCode() + { + const string chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + var bytes = RandomNumberGenerator.GetBytes(12); + return new string(bytes.Select(b => chars[b % chars.Length]).ToArray()); } } diff --git a/src/backend/Application/Students/Commands/RegenerateActivationCodeCommand.cs b/src/backend/Application/Students/Commands/RegenerateActivationCodeCommand.cs new file mode 100644 index 0000000..0463602 --- /dev/null +++ b/src/backend/Application/Students/Commands/RegenerateActivationCodeCommand.cs @@ -0,0 +1,59 @@ +using System.Security.Cryptography; +using Application.Auth; +using Application.Students.DTOs; +using Domain.Ports.Repositories; +using MediatR; +using Microsoft.Extensions.Configuration; + +namespace Application.Students.Commands; + +public record RegenerateActivationCodeCommand(int StudentId, string? BaseUrl = null) + : IRequest; + +public class RegenerateActivationCodeHandler( + IStudentRepository studentRepository, + IPasswordService passwordService, + IUnitOfWork unitOfWork, + IConfiguration configuration +) : IRequestHandler +{ + private static readonly TimeSpan ActivationExpiration = TimeSpan.FromHours(48); + + public async Task Handle(RegenerateActivationCodeCommand request, CancellationToken ct) + { + var student = await studentRepository.GetByIdAsync(request.StudentId, ct); + + if (student == null) + return null; + + // Only regenerate if student is not yet activated + if (student.IsActivated) + return null; + + // Generate new activation code + var activationCode = GenerateActivationCode(); + var codeHash = passwordService.HashPassword(activationCode); + student.SetActivationCode(codeHash, ActivationExpiration); + + studentRepository.Update(student); + await unitOfWork.SaveChangesAsync(ct); + + var baseUrl = request.BaseUrl ?? configuration["App:BaseUrl"] ?? "http://localhost:4200"; + var activationUrl = $"{baseUrl}/activate?code={activationCode}"; + + var studentDto = new StudentDto(student.Id, student.Name, student.Email.Value, 0, []); + + return new CreateStudentResult( + studentDto, + activationCode, + activationUrl, + student.ActivationExpiresAt!.Value); + } + + private static string GenerateActivationCode() + { + const string chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + var bytes = RandomNumberGenerator.GetBytes(12); + return new string(bytes.Select(b => chars[b % chars.Length]).ToArray()); + } +} diff --git a/src/backend/Application/Students/DTOs/StudentDto.cs b/src/backend/Application/Students/DTOs/StudentDto.cs index 3090252..55f97a1 100644 --- a/src/backend/Application/Students/DTOs/StudentDto.cs +++ b/src/backend/Application/Students/DTOs/StudentDto.cs @@ -31,3 +31,26 @@ public record StudentPagedDto( string Name, string Email, int TotalCredits); + +// Activation DTOs +public record CreateStudentResult( + StudentDto Student, + string ActivationCode, + string ActivationUrl, + DateTime ExpiresAt); + +public record ActivationValidationResult( + bool IsValid, + string? StudentName, + string? Error); + +public record ActivateAccountRequest( + string ActivationCode, + string Username, + string Password); + +public record ActivateAccountResult( + bool Success, + string? Token, + string? RecoveryCode, + string? Error); diff --git a/tests/Application.Tests/Auth/ActivateAccountCommandTests.cs b/tests/Application.Tests/Auth/ActivateAccountCommandTests.cs new file mode 100644 index 0000000..2e2f07e --- /dev/null +++ b/tests/Application.Tests/Auth/ActivateAccountCommandTests.cs @@ -0,0 +1,278 @@ +namespace Application.Tests.Auth; + +using Application.Auth; +using Application.Auth.Commands; +using Domain.Entities; +using Domain.Ports.Repositories; +using Domain.ValueObjects; +using FluentAssertions; +using NSubstitute; +using Xunit; + +public class ActivateAccountCommandTests +{ + private readonly IStudentRepository _studentRepository; + private readonly IUserRepository _userRepository; + private readonly IPasswordService _passwordService; + private readonly IJwtService _jwtService; + private readonly IUnitOfWork _unitOfWork; + private readonly ActivateAccountHandler _handler; + + public ActivateAccountCommandTests() + { + _studentRepository = Substitute.For(); + _userRepository = Substitute.For(); + _passwordService = Substitute.For(); + _jwtService = Substitute.For(); + _unitOfWork = Substitute.For(); + + _passwordService.HashPassword(Arg.Any()).Returns("hashed_value"); + + _handler = new ActivateAccountHandler( + _studentRepository, + _userRepository, + _passwordService, + _jwtService, + _unitOfWork + ); + } + + [Fact] + public async Task Handle_WithValidActivationCode_ShouldActivateAccount() + { + // Arrange + var student = CreateStudentWithActivationCode("John Doe", "john@test.com", "activation_hash"); + _studentRepository.GetPendingActivationAsync(Arg.Any()) + .Returns(new List { student }); + _passwordService.VerifyPassword("VALIDCODE123", "activation_hash") + .Returns(true); + _userRepository.ExistsAsync("newuser", Arg.Any()) + .Returns(false); + _studentRepository.GetByIdAsync(student.Id, Arg.Any()) + .Returns(student); + _jwtService.GenerateToken(Arg.Any()) + .Returns("new.jwt.token"); + + var command = new ActivateAccountCommand("VALIDCODE123", "newuser", "password123"); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Success.Should().BeTrue(); + result.Token.Should().Be("new.jwt.token"); + result.RecoveryCode.Should().NotBeNullOrEmpty(); + result.Error.Should().BeNull(); + } + + [Fact] + public async Task Handle_WithInvalidActivationCode_ShouldReturnError() + { + // Arrange + var student = CreateStudentWithActivationCode("John Doe", "john@test.com", "activation_hash"); + _studentRepository.GetPendingActivationAsync(Arg.Any()) + .Returns(new List { student }); + _passwordService.VerifyPassword("INVALIDCODE", Arg.Any()) + .Returns(false); + + var command = new ActivateAccountCommand("INVALIDCODE", "newuser", "password123"); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Success.Should().BeFalse(); + result.Error.Should().Contain("invalido"); + } + + [Fact] + public async Task Handle_WithExpiredActivationCode_ShouldReturnError() + { + // Arrange + var student = CreateStudentWithExpiredActivationCode("John Doe", "john@test.com", "activation_hash"); + _studentRepository.GetPendingActivationAsync(Arg.Any()) + .Returns(new List { student }); + _passwordService.VerifyPassword("VALIDCODE123", "activation_hash") + .Returns(true); + + var command = new ActivateAccountCommand("VALIDCODE123", "newuser", "password123"); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Success.Should().BeFalse(); + result.Error.Should().Contain("expirado"); + } + + [Fact] + public async Task Handle_WithExistingUsername_ShouldReturnError() + { + // Arrange + var student = CreateStudentWithActivationCode("John Doe", "john@test.com", "activation_hash"); + _studentRepository.GetPendingActivationAsync(Arg.Any()) + .Returns(new List { student }); + _passwordService.VerifyPassword("VALIDCODE123", "activation_hash") + .Returns(true); + _userRepository.ExistsAsync("existinguser", Arg.Any()) + .Returns(true); + + var command = new ActivateAccountCommand("VALIDCODE123", "existinguser", "password123"); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Success.Should().BeFalse(); + result.Error.Should().Contain("ya existe"); + } + + [Fact] + public async Task Handle_WithShortPassword_ShouldReturnError() + { + // Arrange + var student = CreateStudentWithActivationCode("John Doe", "john@test.com", "activation_hash"); + _studentRepository.GetPendingActivationAsync(Arg.Any()) + .Returns(new List { student }); + _passwordService.VerifyPassword("VALIDCODE123", "activation_hash") + .Returns(true); + _userRepository.ExistsAsync("newuser", Arg.Any()) + .Returns(false); + + var command = new ActivateAccountCommand("VALIDCODE123", "newuser", "12345"); // 5 chars + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Success.Should().BeFalse(); + result.Error.Should().Contain("al menos 6 caracteres"); + } + + [Fact] + public async Task Handle_ShouldCreateUserWithStudentRole() + { + // Arrange + var student = CreateStudentWithActivationCode("John Doe", "john@test.com", "activation_hash"); + SetEntityId(student, 5); + _studentRepository.GetPendingActivationAsync(Arg.Any()) + .Returns(new List { student }); + _passwordService.VerifyPassword("VALIDCODE123", "activation_hash") + .Returns(true); + _userRepository.ExistsAsync("newuser", Arg.Any()) + .Returns(false); + _studentRepository.GetByIdAsync(5, Arg.Any()) + .Returns(student); + _jwtService.GenerateToken(Arg.Any()) + .Returns("new.jwt.token"); + + var command = new ActivateAccountCommand("VALIDCODE123", "newuser", "password123"); + + // Act + await _handler.Handle(command, CancellationToken.None); + + // Assert + await _userRepository.Received(1).AddAsync( + Arg.Is(u => u.Role == UserRoles.Student && u.StudentId == 5), + Arg.Any() + ); + } + + [Fact] + public async Task Handle_ShouldGenerateRecoveryCode() + { + // Arrange + var student = CreateStudentWithActivationCode("John Doe", "john@test.com", "activation_hash"); + _studentRepository.GetPendingActivationAsync(Arg.Any()) + .Returns(new List { student }); + _passwordService.VerifyPassword("VALIDCODE123", "activation_hash") + .Returns(true); + _userRepository.ExistsAsync("newuser", Arg.Any()) + .Returns(false); + _studentRepository.GetByIdAsync(student.Id, Arg.Any()) + .Returns(student); + _jwtService.GenerateToken(Arg.Any()) + .Returns("new.jwt.token"); + + var command = new ActivateAccountCommand("VALIDCODE123", "newuser", "password123"); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.RecoveryCode.Should().NotBeNullOrEmpty(); + result.RecoveryCode!.Length.Should().Be(12); + } + + [Fact] + public async Task Handle_ShouldClearActivationCodeAfterSuccess() + { + // Arrange + var student = CreateStudentWithActivationCode("John Doe", "john@test.com", "activation_hash"); + _studentRepository.GetPendingActivationAsync(Arg.Any()) + .Returns(new List { student }); + _passwordService.VerifyPassword("VALIDCODE123", "activation_hash") + .Returns(true); + _userRepository.ExistsAsync("newuser", Arg.Any()) + .Returns(false); + _studentRepository.GetByIdAsync(student.Id, Arg.Any()) + .Returns(student); + _jwtService.GenerateToken(Arg.Any()) + .Returns("new.jwt.token"); + + var command = new ActivateAccountCommand("VALIDCODE123", "newuser", "password123"); + + // Act + await _handler.Handle(command, CancellationToken.None); + + // Assert + await _unitOfWork.Received(1).SaveChangesAsync(Arg.Any()); + } + + [Fact] + public async Task Handle_ShouldReturnJwtTokenForAutoLogin() + { + // Arrange + var student = CreateStudentWithActivationCode("John Doe", "john@test.com", "activation_hash"); + _studentRepository.GetPendingActivationAsync(Arg.Any()) + .Returns(new List { student }); + _passwordService.VerifyPassword("VALIDCODE123", "activation_hash") + .Returns(true); + _userRepository.ExistsAsync("newuser", Arg.Any()) + .Returns(false); + _studentRepository.GetByIdAsync(student.Id, Arg.Any()) + .Returns(student); + _jwtService.GenerateToken(Arg.Any()) + .Returns("auto.login.token"); + + var command = new ActivateAccountCommand("VALIDCODE123", "newuser", "password123"); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Token.Should().Be("auto.login.token"); + _jwtService.Received(1).GenerateToken(Arg.Any()); + } + + private static Student CreateStudentWithActivationCode(string name, string email, string activationHash) + { + var student = new Student(name, Email.Create(email)); + typeof(Student).GetProperty("ActivationCodeHash")?.SetValue(student, activationHash); + typeof(Student).GetProperty("ActivationExpiresAt")?.SetValue(student, DateTime.UtcNow.AddDays(2)); + return student; + } + + private static Student CreateStudentWithExpiredActivationCode(string name, string email, string activationHash) + { + var student = new Student(name, Email.Create(email)); + typeof(Student).GetProperty("ActivationCodeHash")?.SetValue(student, activationHash); + typeof(Student).GetProperty("ActivationExpiresAt")?.SetValue(student, DateTime.UtcNow.AddDays(-1)); + return student; + } + + private static void SetEntityId(T entity, int id) where T : class + { + typeof(T).GetProperty("Id")?.SetValue(entity, id); + } +} diff --git a/tests/Application.Tests/Auth/LoginCommandTests.cs b/tests/Application.Tests/Auth/LoginCommandTests.cs new file mode 100644 index 0000000..fcc2030 --- /dev/null +++ b/tests/Application.Tests/Auth/LoginCommandTests.cs @@ -0,0 +1,171 @@ +namespace Application.Tests.Auth; + +using Application.Auth; +using Application.Auth.Commands; +using Domain.Entities; +using Domain.Ports.Repositories; +using FluentAssertions; +using NSubstitute; +using Xunit; + +public class LoginCommandTests +{ + private readonly IUserRepository _userRepository; + private readonly IPasswordService _passwordService; + private readonly IJwtService _jwtService; + private readonly LoginCommandHandler _handler; + + public LoginCommandTests() + { + _userRepository = Substitute.For(); + _passwordService = Substitute.For(); + _jwtService = Substitute.For(); + _handler = new LoginCommandHandler(_userRepository, _passwordService, _jwtService); + } + + [Fact] + public async Task Handle_WithValidCredentials_ShouldReturnSuccessWithToken() + { + // Arrange + var user = CreateUser("testuser", "hashed_password"); + _userRepository.GetByUsernameAsync("testuser", Arg.Any()) + .Returns(user); + _passwordService.VerifyPassword("password123", "hashed_password") + .Returns(true); + _jwtService.GenerateToken(user) + .Returns("valid.jwt.token"); + + var command = new LoginCommand("testuser", "password123"); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeTrue(); + result.Token.Should().Be("valid.jwt.token"); + result.User.Should().NotBeNull(); + result.User!.Username.Should().Be("testuser"); + result.Error.Should().BeNull(); + } + + [Fact] + public async Task Handle_WithInvalidUsername_ShouldReturnError() + { + // Arrange + _userRepository.GetByUsernameAsync("nonexistent", Arg.Any()) + .Returns((User?)null); + + var command = new LoginCommand("nonexistent", "password123"); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Success.Should().BeFalse(); + result.Token.Should().BeNull(); + result.Error.Should().Contain("incorrectos"); + } + + [Fact] + public async Task Handle_WithInvalidPassword_ShouldReturnError() + { + // Arrange + var user = CreateUser("testuser", "hashed_password"); + _userRepository.GetByUsernameAsync("testuser", Arg.Any()) + .Returns(user); + _passwordService.VerifyPassword("wrongpassword", "hashed_password") + .Returns(false); + + var command = new LoginCommand("testuser", "wrongpassword"); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Success.Should().BeFalse(); + result.Token.Should().BeNull(); + result.Error.Should().Contain("incorrectos"); + } + + [Fact] + public async Task Handle_ShouldNormalizUsernameToLowercase() + { + // Arrange + var user = CreateUser("testuser", "hashed_password"); + _userRepository.GetByUsernameAsync("testuser", Arg.Any()) + .Returns(user); + _passwordService.VerifyPassword("password123", "hashed_password") + .Returns(true); + _jwtService.GenerateToken(user) + .Returns("valid.jwt.token"); + + var command = new LoginCommand("TESTUSER", "password123"); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Success.Should().BeTrue(); + await _userRepository.Received(1).GetByUsernameAsync("testuser", Arg.Any()); + } + + [Fact] + public async Task Handle_ShouldUpdateLastLoginOnSuccess() + { + // Arrange + var user = CreateUser("testuser", "hashed_password"); + _userRepository.GetByUsernameAsync("testuser", Arg.Any()) + .Returns(user); + _passwordService.VerifyPassword("password123", "hashed_password") + .Returns(true); + _jwtService.GenerateToken(user) + .Returns("valid.jwt.token"); + + var command = new LoginCommand("testuser", "password123"); + + // Act + await _handler.Handle(command, CancellationToken.None); + + // Assert + await _userRepository.Received(1).UpdateAsync(user, Arg.Any()); + } + + [Fact] + public async Task Handle_WithStudentUser_ShouldIncludeStudentInfo() + { + // Arrange + var user = CreateUserWithStudent("studentuser", "hashed_password", 1, "John Doe"); + _userRepository.GetByUsernameAsync("studentuser", Arg.Any()) + .Returns(user); + _passwordService.VerifyPassword("password123", "hashed_password") + .Returns(true); + _jwtService.GenerateToken(user) + .Returns("valid.jwt.token"); + + var command = new LoginCommand("studentuser", "password123"); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Success.Should().BeTrue(); + result.User!.StudentId.Should().Be(1); + result.User.StudentName.Should().Be("John Doe"); + result.User.Role.Should().Be(UserRoles.Student); + } + + private static User CreateUser(string username, string passwordHash) + { + return User.Create(username, passwordHash, "recovery_hash", UserRoles.Student); + } + + private static User CreateUserWithStudent(string username, string passwordHash, int studentId, string studentName) + { + var user = User.Create(username, passwordHash, "recovery_hash", UserRoles.Student, studentId); + // Set up the Student navigation property + var student = new Student(studentName, Domain.ValueObjects.Email.Create($"{username}@test.com")); + typeof(User).GetProperty("Student")?.SetValue(user, student); + return user; + } +} diff --git a/tests/Application.Tests/Auth/RegisterCommandTests.cs b/tests/Application.Tests/Auth/RegisterCommandTests.cs new file mode 100644 index 0000000..06f2635 --- /dev/null +++ b/tests/Application.Tests/Auth/RegisterCommandTests.cs @@ -0,0 +1,219 @@ +namespace Application.Tests.Auth; + +using Application.Auth; +using Application.Auth.Commands; +using Domain.Entities; +using Domain.Ports.Repositories; +using FluentAssertions; +using NSubstitute; +using Xunit; + +public class RegisterCommandTests +{ + private readonly IUserRepository _userRepository; + private readonly IStudentRepository _studentRepository; + private readonly IPasswordService _passwordService; + private readonly IJwtService _jwtService; + private readonly IUnitOfWork _unitOfWork; + private readonly RegisterCommandHandler _handler; + + public RegisterCommandTests() + { + _userRepository = Substitute.For(); + _studentRepository = Substitute.For(); + _passwordService = Substitute.For(); + _jwtService = Substitute.For(); + _unitOfWork = Substitute.For(); + + _passwordService.HashPassword(Arg.Any()).Returns("hashed_value"); + + _handler = new RegisterCommandHandler( + _userRepository, + _studentRepository, + _passwordService, + _jwtService, + _unitOfWork + ); + } + + [Fact] + public async Task Handle_WithValidData_ShouldCreateUser() + { + // Arrange + _userRepository.ExistsAsync("newuser", Arg.Any()) + .Returns(false); + _jwtService.GenerateToken(Arg.Any()) + .Returns("new.jwt.token"); + + var command = new RegisterCommand("newuser", "password123"); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Success.Should().BeTrue(); + result.Token.Should().Be("new.jwt.token"); + result.RecoveryCode.Should().NotBeNullOrEmpty(); + result.Error.Should().BeNull(); + } + + [Fact] + public async Task Handle_WithExistingUsername_ShouldReturnError() + { + // Arrange + _userRepository.ExistsAsync("existinguser", Arg.Any()) + .Returns(true); + + var command = new RegisterCommand("existinguser", "password123"); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Success.Should().BeFalse(); + result.Error.Should().Contain("ya existe"); + } + + [Fact] + public async Task Handle_WithShortPassword_ShouldReturnError() + { + // Arrange + _userRepository.ExistsAsync("newuser", Arg.Any()) + .Returns(false); + + var command = new RegisterCommand("newuser", "12345"); // 5 chars, less than 6 + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Success.Should().BeFalse(); + result.Error.Should().Contain("al menos 6 caracteres"); + } + + [Fact] + public async Task Handle_WithNameAndEmail_ShouldCreateStudent() + { + // Arrange + _userRepository.ExistsAsync("newuser", Arg.Any()) + .Returns(false); + _jwtService.GenerateToken(Arg.Any()) + .Returns("new.jwt.token"); + + var command = new RegisterCommand( + "newuser", + "password123", + Name: "John Doe", + Email: "john@example.com" + ); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Success.Should().BeTrue(); + _studentRepository.Received(1).Add(Arg.Is(s => s.Name == "John Doe")); + await _unitOfWork.Received().SaveChangesAsync(Arg.Any()); + } + + [Fact] + public async Task Handle_WithInvalidEmail_ShouldReturnError() + { + // Arrange + _userRepository.ExistsAsync("newuser", Arg.Any()) + .Returns(false); + + var command = new RegisterCommand( + "newuser", + "password123", + Name: "John Doe", + Email: "invalid-email" + ); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Success.Should().BeFalse(); + result.Error.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task Handle_ShouldGenerateRecoveryCode() + { + // Arrange + _userRepository.ExistsAsync("newuser", Arg.Any()) + .Returns(false); + _jwtService.GenerateToken(Arg.Any()) + .Returns("new.jwt.token"); + + var command = new RegisterCommand("newuser", "password123"); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.RecoveryCode.Should().NotBeNullOrEmpty(); + result.RecoveryCode!.Length.Should().Be(12); + result.RecoveryCode.Should().MatchRegex("^[A-Z0-9]+$"); + } + + [Fact] + public async Task Handle_ShouldHashPasswordBeforeSaving() + { + // Arrange + _userRepository.ExistsAsync("newuser", Arg.Any()) + .Returns(false); + _jwtService.GenerateToken(Arg.Any()) + .Returns("new.jwt.token"); + + var command = new RegisterCommand("newuser", "password123"); + + // Act + await _handler.Handle(command, CancellationToken.None); + + // Assert + _passwordService.Received(1).HashPassword("password123"); + await _userRepository.Received(1).AddAsync( + Arg.Is(u => u.PasswordHash == "hashed_value"), + Arg.Any() + ); + } + + [Fact] + public async Task Handle_ShouldNormalizeUsernameToLowercase() + { + // Arrange + _userRepository.ExistsAsync("newuser", Arg.Any()) + .Returns(false); + _jwtService.GenerateToken(Arg.Any()) + .Returns("new.jwt.token"); + + var command = new RegisterCommand("NEWUSER", "password123"); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Success.Should().BeTrue(); + result.User!.Username.Should().Be("newuser"); + } + + [Fact] + public async Task Handle_NewUserShouldHaveStudentRole() + { + // Arrange + _userRepository.ExistsAsync("newuser", Arg.Any()) + .Returns(false); + _jwtService.GenerateToken(Arg.Any()) + .Returns("new.jwt.token"); + + var command = new RegisterCommand("newuser", "password123"); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.User!.Role.Should().Be(UserRoles.Student); + } +} diff --git a/tests/Application.Tests/Auth/ResetPasswordCommandTests.cs b/tests/Application.Tests/Auth/ResetPasswordCommandTests.cs new file mode 100644 index 0000000..69b13cd --- /dev/null +++ b/tests/Application.Tests/Auth/ResetPasswordCommandTests.cs @@ -0,0 +1,185 @@ +namespace Application.Tests.Auth; + +using Application.Auth; +using Application.Auth.Commands; +using Domain.Entities; +using Domain.Ports.Repositories; +using FluentAssertions; +using NSubstitute; +using Xunit; + +public class ResetPasswordCommandTests +{ + private readonly IUserRepository _userRepository; + private readonly IPasswordService _passwordService; + private readonly IUnitOfWork _unitOfWork; + private readonly ResetPasswordCommandHandler _handler; + + public ResetPasswordCommandTests() + { + _userRepository = Substitute.For(); + _passwordService = Substitute.For(); + _unitOfWork = Substitute.For(); + + _passwordService.HashPassword(Arg.Any()).Returns("new_hashed_password"); + + _handler = new ResetPasswordCommandHandler( + _userRepository, + _passwordService, + _unitOfWork + ); + } + + [Fact] + public async Task Handle_WithValidRecoveryCode_ShouldResetPassword() + { + // Arrange + var user = CreateUser("testuser", "old_password_hash", "recovery_code_hash"); + _userRepository.GetByUsernameAsync("testuser", Arg.Any()) + .Returns(user); + _passwordService.VerifyPassword("VALIDCODE123", "recovery_code_hash") + .Returns(true); + + var command = new ResetPasswordCommand("testuser", "VALIDCODE123", "newpassword123"); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Success.Should().BeTrue(); + result.Error.Should().BeNull(); + } + + [Fact] + public async Task Handle_WithInvalidRecoveryCode_ShouldReturnError() + { + // Arrange + var user = CreateUser("testuser", "old_password_hash", "recovery_code_hash"); + _userRepository.GetByUsernameAsync("testuser", Arg.Any()) + .Returns(user); + _passwordService.VerifyPassword("INVALIDCODE", "recovery_code_hash") + .Returns(false); + + var command = new ResetPasswordCommand("testuser", "INVALIDCODE", "newpassword123"); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Success.Should().BeFalse(); + result.Error.Should().Contain("invalido"); + } + + [Fact] + public async Task Handle_WithNonExistentUser_ShouldReturnError() + { + // Arrange + _userRepository.GetByUsernameAsync("nonexistent", Arg.Any()) + .Returns((User?)null); + + var command = new ResetPasswordCommand("nonexistent", "ANYCODE123", "newpassword123"); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Success.Should().BeFalse(); + result.Error.Should().Contain("no encontrado"); + } + + [Fact] + public async Task Handle_WithShortNewPassword_ShouldReturnError() + { + // Arrange + var user = CreateUser("testuser", "old_password_hash", "recovery_code_hash"); + _userRepository.GetByUsernameAsync("testuser", Arg.Any()) + .Returns(user); + + var command = new ResetPasswordCommand("testuser", "VALIDCODE123", "12345"); // 5 chars + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Success.Should().BeFalse(); + result.Error.Should().Contain("al menos 6 caracteres"); + } + + [Fact] + public async Task Handle_ShouldHashNewPassword() + { + // Arrange + var user = CreateUser("testuser", "old_password_hash", "recovery_code_hash"); + _userRepository.GetByUsernameAsync("testuser", Arg.Any()) + .Returns(user); + _passwordService.VerifyPassword("VALIDCODE123", "recovery_code_hash") + .Returns(true); + + var command = new ResetPasswordCommand("testuser", "VALIDCODE123", "newpassword123"); + + // Act + await _handler.Handle(command, CancellationToken.None); + + // Assert + _passwordService.Received(1).HashPassword("newpassword123"); + } + + [Fact] + public async Task Handle_ShouldSaveChangesOnSuccess() + { + // Arrange + var user = CreateUser("testuser", "old_password_hash", "recovery_code_hash"); + _userRepository.GetByUsernameAsync("testuser", Arg.Any()) + .Returns(user); + _passwordService.VerifyPassword("VALIDCODE123", "recovery_code_hash") + .Returns(true); + + var command = new ResetPasswordCommand("testuser", "VALIDCODE123", "newpassword123"); + + // Act + await _handler.Handle(command, CancellationToken.None); + + // Assert + await _unitOfWork.Received(1).SaveChangesAsync(Arg.Any()); + } + + [Fact] + public async Task Handle_ShouldNotSaveChangesOnError() + { + // Arrange + _userRepository.GetByUsernameAsync("nonexistent", Arg.Any()) + .Returns((User?)null); + + var command = new ResetPasswordCommand("nonexistent", "ANYCODE123", "newpassword123"); + + // Act + await _handler.Handle(command, CancellationToken.None); + + // Assert + await _unitOfWork.DidNotReceive().SaveChangesAsync(Arg.Any()); + } + + [Fact] + public async Task Handle_ShouldUpdateUserPasswordHash() + { + // Arrange + var user = CreateUser("testuser", "old_password_hash", "recovery_code_hash"); + _userRepository.GetByUsernameAsync("testuser", Arg.Any()) + .Returns(user); + _passwordService.VerifyPassword("VALIDCODE123", "recovery_code_hash") + .Returns(true); + + var command = new ResetPasswordCommand("testuser", "VALIDCODE123", "newpassword123"); + + // Act + await _handler.Handle(command, CancellationToken.None); + + // Assert + user.PasswordHash.Should().Be("new_hashed_password"); + } + + private static User CreateUser(string username, string passwordHash, string recoveryCodeHash) + { + return User.Create(username, passwordHash, recoveryCodeHash, UserRoles.Student); + } +} diff --git a/tests/Application.Tests/Students/StudentCommandsTests.cs b/tests/Application.Tests/Students/StudentCommandsTests.cs index 284111b..3f96da8 100644 --- a/tests/Application.Tests/Students/StudentCommandsTests.cs +++ b/tests/Application.Tests/Students/StudentCommandsTests.cs @@ -1,5 +1,6 @@ namespace Application.Tests.Students; +using Application.Auth; using Application.Students.Commands; using Application.Students.DTOs; using Domain.Entities; @@ -7,20 +8,29 @@ using Domain.Exceptions; using Domain.Ports.Repositories; using Domain.ValueObjects; using FluentAssertions; +using Microsoft.Extensions.Configuration; using NSubstitute; using Xunit; public class CreateStudentCommandTests { private readonly IStudentRepository _studentRepository; + private readonly IPasswordService _passwordService; private readonly IUnitOfWork _unitOfWork; + private readonly IConfiguration _configuration; private readonly CreateStudentHandler _handler; public CreateStudentCommandTests() { _studentRepository = Substitute.For(); + _passwordService = Substitute.For(); _unitOfWork = Substitute.For(); - _handler = new CreateStudentHandler(_studentRepository, _unitOfWork); + _configuration = Substitute.For(); + + _passwordService.HashPassword(Arg.Any()).Returns("hashed_code"); + _configuration["App:BaseUrl"].Returns("http://localhost:4200"); + + _handler = new CreateStudentHandler(_studentRepository, _passwordService, _unitOfWork, _configuration); } [Fact] @@ -34,10 +44,12 @@ public class CreateStudentCommandTests // Assert result.Should().NotBeNull(); - result.Name.Should().Be("John Doe"); - result.Email.Should().Be("john@example.com"); - result.TotalCredits.Should().Be(0); - result.Enrollments.Should().BeEmpty(); + result.Student.Name.Should().Be("John Doe"); + result.Student.Email.Should().Be("john@example.com"); + result.Student.TotalCredits.Should().Be(0); + result.Student.Enrollments.Should().BeEmpty(); + result.ActivationCode.Should().NotBeNullOrEmpty(); + result.ActivationUrl.Should().Contain("/activate?code="); _studentRepository.Received(1).Add(Arg.Any()); await _unitOfWork.Received(1).SaveChangesAsync(Arg.Any()); diff --git a/tests/Integration.Tests/EnrollmentFlowTests.cs b/tests/Integration.Tests/EnrollmentFlowTests.cs index 297b49a..8e65ef0 100644 --- a/tests/Integration.Tests/EnrollmentFlowTests.cs +++ b/tests/Integration.Tests/EnrollmentFlowTests.cs @@ -2,6 +2,8 @@ namespace Integration.Tests; using Adapters.Driven.Persistence.Context; using Adapters.Driven.Persistence.Repositories; +using Adapters.Driven.Persistence.Services; +using Application.Auth; using Application.Enrollments.Commands; using Application.Students.Commands; using Domain.Entities; @@ -9,6 +11,8 @@ using Domain.Exceptions; using Domain.Services; using FluentAssertions; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using NSubstitute; public class EnrollmentFlowTests : IDisposable { @@ -55,7 +59,11 @@ public class EnrollmentFlowTests : IDisposable public async Task CreateStudent_ShouldPersistToDatabase() { // Arrange - var handler = new CreateStudentHandler(_studentRepository, _unitOfWork); + var passwordService = new PasswordService(); + var configuration = Substitute.For(); + configuration["App:BaseUrl"].Returns("http://localhost:4200"); + + var handler = new CreateStudentHandler(_studentRepository, passwordService, _unitOfWork, configuration); var command = new CreateStudentCommand("John Doe", "john@example.com"); // Act @@ -63,10 +71,12 @@ public class EnrollmentFlowTests : IDisposable // Assert result.Should().NotBeNull(); - result.Name.Should().Be("John Doe"); + result.Student.Name.Should().Be("John Doe"); + result.ActivationCode.Should().NotBeNullOrEmpty(); var savedStudent = await _context.Students.FirstOrDefaultAsync(s => s.Email.Value == "john@example.com"); savedStudent.Should().NotBeNull(); + savedStudent!.ActivationCodeHash.Should().NotBeNullOrEmpty(); } [Fact] diff --git a/tests/Integration.Tests/Integration.Tests.csproj b/tests/Integration.Tests/Integration.Tests.csproj index de4f379..2416086 100644 --- a/tests/Integration.Tests/Integration.Tests.csproj +++ b/tests/Integration.Tests/Integration.Tests.csproj @@ -14,6 +14,7 @@ +