feat(auth): implement account activation backend
Add complete backend support for student account activation: Persistence layer: - StudentConfiguration: add IsActive, ActivationCode, ActivationCodeExpiry mappings - Migration: AddStudentActivation with new columns - StudentRepository: implement GetByActivationCodeAsync Application layer: - ActivateAccountCommand: validates code and activates student account - RegenerateActivationCodeCommand: generates new code with expiry - CreateStudentCommand: generates activation code on registration - StudentDto: expose activation status fields - Auth Queries: add activation status lookup API layer: - Mutation: add activateAccount and regenerateActivationCode endpoints - Query: add activation status queries Tests: - Unit tests for activation commands - Integration tests for enrollment flow with activation
This commit is contained in:
parent
4b9fe1c33b
commit
1b9918a90c
|
|
@ -35,6 +35,14 @@ public class StudentConfiguration : IEntityTypeConfiguration<Student>
|
||||||
// Use raw column name for index to avoid value object issues
|
// Use raw column name for index to avoid value object issues
|
||||||
builder.HasIndex("Email").IsUnique();
|
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)
|
builder.HasMany(s => s.Enrollments)
|
||||||
.WithOne(e => e.Student)
|
.WithOne(e => e.Student)
|
||||||
.HasForeignKey(e => e.StudentId)
|
.HasForeignKey(e => e.StudentId)
|
||||||
|
|
|
||||||
337
src/backend/Adapters/Driven/Persistence/Migrations/20260109055746_AddStudentActivation.Designer.cs
generated
Normal file
337
src/backend/Adapters/Driven/Persistence/Migrations/20260109055746_AddStudentActivation.Designer.cs
generated
Normal file
|
|
@ -0,0 +1,337 @@
|
||||||
|
// <auto-generated />
|
||||||
|
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
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
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<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTime>("EnrolledAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<int>("StudentId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("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<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("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<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("ActivationCodeHash")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("nvarchar(256)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("ActivationExpiresAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(150)
|
||||||
|
.HasColumnType("nvarchar(150)");
|
||||||
|
|
||||||
|
b.Property<string>("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<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<int>("Credits")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int")
|
||||||
|
.HasDefaultValue(3);
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("nvarchar(100)");
|
||||||
|
|
||||||
|
b.Property<int>("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<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("LastLoginAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("nvarchar(256)");
|
||||||
|
|
||||||
|
b.Property<string>("RecoveryCodeHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("nvarchar(256)");
|
||||||
|
|
||||||
|
b.Property<string>("Role")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("nvarchar(20)");
|
||||||
|
|
||||||
|
b.Property<int?>("StudentId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Adapters.Driven.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddStudentActivation : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "ActivationCodeHash",
|
||||||
|
table: "Students",
|
||||||
|
type: "nvarchar(256)",
|
||||||
|
maxLength: 256,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<DateTime>(
|
||||||
|
name: "ActivationExpiresAt",
|
||||||
|
table: "Students",
|
||||||
|
type: "datetime2",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ActivationCodeHash",
|
||||||
|
table: "Students");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ActivationExpiresAt",
|
||||||
|
table: "Students");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -102,6 +102,13 @@ namespace Adapters.Driven.Persistence.Migrations
|
||||||
|
|
||||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("ActivationCodeHash")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("nvarchar(256)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("ActivationExpiresAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
b.Property<string>("Email")
|
b.Property<string>("Email")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(150)
|
.HasMaxLength(150)
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,12 @@ public class StudentRepository(AppDbContext context) : IStudentRepository
|
||||||
return (resultItems, nextCursor, totalCount);
|
return (resultItems, nextCursor, totalCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Student>> 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 Add(Student student) => context.Students.Add(student);
|
||||||
public void Update(Student student) => context.Students.Update(student);
|
public void Update(Student student) => context.Students.Update(student);
|
||||||
public void Delete(Student student) => context.Students.Remove(student);
|
public void Delete(Student student) => context.Students.Remove(student);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
namespace Adapters.Driving.Api.Types;
|
namespace Adapters.Driving.Api.Types;
|
||||||
|
|
||||||
|
using Application.Auth.Commands;
|
||||||
using Application.Enrollments.Commands;
|
using Application.Enrollments.Commands;
|
||||||
using Application.Students.Commands;
|
using Application.Students.Commands;
|
||||||
using Application.Students.DTOs;
|
using Application.Students.DTOs;
|
||||||
|
|
@ -13,15 +14,37 @@ using System.Security.Claims;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class Mutation
|
public class Mutation
|
||||||
{
|
{
|
||||||
[Authorize]
|
[Authorize(Roles = ["Admin"])]
|
||||||
[GraphQLDescription("Create a new student (requires authentication)")]
|
[GraphQLDescription("Create a new student with activation code (admin only)")]
|
||||||
public async Task<CreateStudentPayload> CreateStudent(
|
public async Task<CreateStudentWithActivationPayload> CreateStudent(
|
||||||
CreateStudentInput input,
|
CreateStudentInput input,
|
||||||
[Service] IMediator mediator,
|
[Service] IMediator mediator,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await mediator.Send(new CreateStudentCommand(input.Name, input.Email), 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<CreateStudentWithActivationPayload?> 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<ActivateAccountPayload> 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]
|
[Authorize]
|
||||||
|
|
@ -108,6 +131,7 @@ public class Mutation
|
||||||
public record CreateStudentInput(string Name, string Email);
|
public record CreateStudentInput(string Name, string Email);
|
||||||
public record UpdateStudentInput(string Name, string Email);
|
public record UpdateStudentInput(string Name, string Email);
|
||||||
public record EnrollStudentInput(int StudentId, int SubjectId);
|
public record EnrollStudentInput(int StudentId, int SubjectId);
|
||||||
|
public record ActivateAccountInput(string ActivationCode, string Username, string Password);
|
||||||
|
|
||||||
// Error Types
|
// Error Types
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -171,3 +195,25 @@ public record UnenrollStudentPayload(
|
||||||
{
|
{
|
||||||
public UnenrollStudentPayload(bool success) : this(success, null) { }
|
public UnenrollStudentPayload(bool success) : this(success, null) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Payload for CreateStudent mutation with activation code.
|
||||||
|
/// </summary>
|
||||||
|
public record CreateStudentWithActivationPayload(
|
||||||
|
StudentDto Student,
|
||||||
|
string ActivationCode,
|
||||||
|
string ActivationUrl,
|
||||||
|
DateTime ExpiresAt)
|
||||||
|
{
|
||||||
|
public CreateStudentWithActivationPayload(CreateStudentResult result)
|
||||||
|
: this(result.Student, result.ActivationCode, result.ActivationUrl, result.ExpiresAt) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Payload for ActivateAccount mutation.
|
||||||
|
/// </summary>
|
||||||
|
public record ActivateAccountPayload(
|
||||||
|
bool Success,
|
||||||
|
string? Token,
|
||||||
|
string? RecoveryCode,
|
||||||
|
string? Error);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
namespace Adapters.Driving.Api.Types;
|
namespace Adapters.Driving.Api.Types;
|
||||||
|
|
||||||
|
using Application.Admin.DTOs;
|
||||||
|
using Application.Admin.Queries;
|
||||||
|
using Application.Auth.Queries;
|
||||||
using Application.Enrollments.DTOs;
|
using Application.Enrollments.DTOs;
|
||||||
using Application.Enrollments.Queries;
|
using Application.Enrollments.Queries;
|
||||||
using Application.Professors.DTOs;
|
using Application.Professors.DTOs;
|
||||||
|
|
@ -8,6 +11,7 @@ using Application.Students.DTOs;
|
||||||
using Application.Students.Queries;
|
using Application.Students.Queries;
|
||||||
using Application.Subjects.DTOs;
|
using Application.Subjects.DTOs;
|
||||||
using Application.Subjects.Queries;
|
using Application.Subjects.Queries;
|
||||||
|
using HotChocolate.Authorization;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
|
|
||||||
public class Query
|
public class Query
|
||||||
|
|
@ -58,4 +62,18 @@ public class Query
|
||||||
[Service] IMediator mediator,
|
[Service] IMediator mediator,
|
||||||
CancellationToken ct) =>
|
CancellationToken ct) =>
|
||||||
await mediator.Send(new GetClassmatesQuery(studentId), ct);
|
await mediator.Send(new GetClassmatesQuery(studentId), ct);
|
||||||
|
|
||||||
|
[Authorize(Roles = ["Admin"])]
|
||||||
|
[GraphQLDescription("Get admin statistics (requires Admin role)")]
|
||||||
|
public async Task<AdminStatsDto> GetAdminStats(
|
||||||
|
[Service] IMediator mediator,
|
||||||
|
CancellationToken ct) =>
|
||||||
|
await mediator.Send(new GetAdminStatsQuery(), ct);
|
||||||
|
|
||||||
|
[GraphQLDescription("Validate an activation code (public)")]
|
||||||
|
public async Task<ActivationValidationResult> ValidateActivationCode(
|
||||||
|
string code,
|
||||||
|
[Service] IMediator mediator,
|
||||||
|
CancellationToken ct) =>
|
||||||
|
await mediator.Send(new ValidateActivationCodeQuery(code), ct);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
<PackageReference Include="FluentValidation" Version="*" />
|
<PackageReference Include="FluentValidation" Version="*" />
|
||||||
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="*" />
|
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="*" />
|
||||||
<PackageReference Include="MediatR" Version="*" />
|
<PackageReference Include="MediatR" Version="*" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="*" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="*" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="*" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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<ActivateAccountResult>;
|
||||||
|
|
||||||
|
public class ActivateAccountHandler(
|
||||||
|
IStudentRepository studentRepository,
|
||||||
|
IUserRepository userRepository,
|
||||||
|
IPasswordService passwordService,
|
||||||
|
IJwtService jwtService,
|
||||||
|
IUnitOfWork unitOfWork
|
||||||
|
) : IRequestHandler<ActivateAccountCommand, ActivateAccountResult>
|
||||||
|
{
|
||||||
|
public async Task<ActivateAccountResult> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
using Application.Students.DTOs;
|
||||||
|
using Domain.Ports.Repositories;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Application.Auth.Queries;
|
||||||
|
|
||||||
|
public record ValidateActivationCodeQuery(string Code) : IRequest<ActivationValidationResult>;
|
||||||
|
|
||||||
|
public class ValidateActivationCodeHandler(
|
||||||
|
IStudentRepository studentRepository,
|
||||||
|
IPasswordService passwordService
|
||||||
|
) : IRequestHandler<ValidateActivationCodeQuery, ActivationValidationResult>
|
||||||
|
{
|
||||||
|
public async Task<ActivationValidationResult> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,26 +1,55 @@
|
||||||
namespace Application.Students.Commands;
|
namespace Application.Students.Commands;
|
||||||
|
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using Application.Auth;
|
||||||
using Application.Students.DTOs;
|
using Application.Students.DTOs;
|
||||||
using Domain.Entities;
|
using Domain.Entities;
|
||||||
using Domain.Ports.Repositories;
|
using Domain.Ports.Repositories;
|
||||||
using Domain.ValueObjects;
|
using Domain.ValueObjects;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
||||||
public record CreateStudentCommand(string Name, string Email) : IRequest<StudentDto>;
|
public record CreateStudentCommand(string Name, string Email, string? BaseUrl = null)
|
||||||
|
: IRequest<CreateStudentResult>;
|
||||||
|
|
||||||
public class CreateStudentHandler(
|
public class CreateStudentHandler(
|
||||||
IStudentRepository studentRepository,
|
IStudentRepository studentRepository,
|
||||||
IUnitOfWork unitOfWork)
|
IPasswordService passwordService,
|
||||||
: IRequestHandler<CreateStudentCommand, StudentDto>
|
IUnitOfWork unitOfWork,
|
||||||
|
IConfiguration configuration)
|
||||||
|
: IRequestHandler<CreateStudentCommand, CreateStudentResult>
|
||||||
{
|
{
|
||||||
public async Task<StudentDto> Handle(CreateStudentCommand request, CancellationToken ct)
|
private static readonly TimeSpan ActivationExpiration = TimeSpan.FromHours(48);
|
||||||
|
|
||||||
|
public async Task<CreateStudentResult> Handle(CreateStudentCommand request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var email = Email.Create(request.Email);
|
var email = Email.Create(request.Email);
|
||||||
var student = new Student(request.Name, 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);
|
studentRepository.Add(student);
|
||||||
await unitOfWork.SaveChangesAsync(ct);
|
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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<CreateStudentResult?>;
|
||||||
|
|
||||||
|
public class RegenerateActivationCodeHandler(
|
||||||
|
IStudentRepository studentRepository,
|
||||||
|
IPasswordService passwordService,
|
||||||
|
IUnitOfWork unitOfWork,
|
||||||
|
IConfiguration configuration
|
||||||
|
) : IRequestHandler<RegenerateActivationCodeCommand, CreateStudentResult?>
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan ActivationExpiration = TimeSpan.FromHours(48);
|
||||||
|
|
||||||
|
public async Task<CreateStudentResult?> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -31,3 +31,26 @@ public record StudentPagedDto(
|
||||||
string Name,
|
string Name,
|
||||||
string Email,
|
string Email,
|
||||||
int TotalCredits);
|
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);
|
||||||
|
|
|
||||||
|
|
@ -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<IStudentRepository>();
|
||||||
|
_userRepository = Substitute.For<IUserRepository>();
|
||||||
|
_passwordService = Substitute.For<IPasswordService>();
|
||||||
|
_jwtService = Substitute.For<IJwtService>();
|
||||||
|
_unitOfWork = Substitute.For<IUnitOfWork>();
|
||||||
|
|
||||||
|
_passwordService.HashPassword(Arg.Any<string>()).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<CancellationToken>())
|
||||||
|
.Returns(new List<Student> { student });
|
||||||
|
_passwordService.VerifyPassword("VALIDCODE123", "activation_hash")
|
||||||
|
.Returns(true);
|
||||||
|
_userRepository.ExistsAsync("newuser", Arg.Any<CancellationToken>())
|
||||||
|
.Returns(false);
|
||||||
|
_studentRepository.GetByIdAsync(student.Id, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(student);
|
||||||
|
_jwtService.GenerateToken(Arg.Any<User>())
|
||||||
|
.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<CancellationToken>())
|
||||||
|
.Returns(new List<Student> { student });
|
||||||
|
_passwordService.VerifyPassword("INVALIDCODE", Arg.Any<string>())
|
||||||
|
.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<CancellationToken>())
|
||||||
|
.Returns(new List<Student> { 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<CancellationToken>())
|
||||||
|
.Returns(new List<Student> { student });
|
||||||
|
_passwordService.VerifyPassword("VALIDCODE123", "activation_hash")
|
||||||
|
.Returns(true);
|
||||||
|
_userRepository.ExistsAsync("existinguser", Arg.Any<CancellationToken>())
|
||||||
|
.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<CancellationToken>())
|
||||||
|
.Returns(new List<Student> { student });
|
||||||
|
_passwordService.VerifyPassword("VALIDCODE123", "activation_hash")
|
||||||
|
.Returns(true);
|
||||||
|
_userRepository.ExistsAsync("newuser", Arg.Any<CancellationToken>())
|
||||||
|
.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<CancellationToken>())
|
||||||
|
.Returns(new List<Student> { student });
|
||||||
|
_passwordService.VerifyPassword("VALIDCODE123", "activation_hash")
|
||||||
|
.Returns(true);
|
||||||
|
_userRepository.ExistsAsync("newuser", Arg.Any<CancellationToken>())
|
||||||
|
.Returns(false);
|
||||||
|
_studentRepository.GetByIdAsync(5, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(student);
|
||||||
|
_jwtService.GenerateToken(Arg.Any<User>())
|
||||||
|
.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<User>(u => u.Role == UserRoles.Student && u.StudentId == 5),
|
||||||
|
Arg.Any<CancellationToken>()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_ShouldGenerateRecoveryCode()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var student = CreateStudentWithActivationCode("John Doe", "john@test.com", "activation_hash");
|
||||||
|
_studentRepository.GetPendingActivationAsync(Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new List<Student> { student });
|
||||||
|
_passwordService.VerifyPassword("VALIDCODE123", "activation_hash")
|
||||||
|
.Returns(true);
|
||||||
|
_userRepository.ExistsAsync("newuser", Arg.Any<CancellationToken>())
|
||||||
|
.Returns(false);
|
||||||
|
_studentRepository.GetByIdAsync(student.Id, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(student);
|
||||||
|
_jwtService.GenerateToken(Arg.Any<User>())
|
||||||
|
.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<CancellationToken>())
|
||||||
|
.Returns(new List<Student> { student });
|
||||||
|
_passwordService.VerifyPassword("VALIDCODE123", "activation_hash")
|
||||||
|
.Returns(true);
|
||||||
|
_userRepository.ExistsAsync("newuser", Arg.Any<CancellationToken>())
|
||||||
|
.Returns(false);
|
||||||
|
_studentRepository.GetByIdAsync(student.Id, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(student);
|
||||||
|
_jwtService.GenerateToken(Arg.Any<User>())
|
||||||
|
.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<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_ShouldReturnJwtTokenForAutoLogin()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var student = CreateStudentWithActivationCode("John Doe", "john@test.com", "activation_hash");
|
||||||
|
_studentRepository.GetPendingActivationAsync(Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new List<Student> { student });
|
||||||
|
_passwordService.VerifyPassword("VALIDCODE123", "activation_hash")
|
||||||
|
.Returns(true);
|
||||||
|
_userRepository.ExistsAsync("newuser", Arg.Any<CancellationToken>())
|
||||||
|
.Returns(false);
|
||||||
|
_studentRepository.GetByIdAsync(student.Id, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(student);
|
||||||
|
_jwtService.GenerateToken(Arg.Any<User>())
|
||||||
|
.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<User>());
|
||||||
|
}
|
||||||
|
|
||||||
|
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>(T entity, int id) where T : class
|
||||||
|
{
|
||||||
|
typeof(T).GetProperty("Id")?.SetValue(entity, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<IUserRepository>();
|
||||||
|
_passwordService = Substitute.For<IPasswordService>();
|
||||||
|
_jwtService = Substitute.For<IJwtService>();
|
||||||
|
_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<CancellationToken>())
|
||||||
|
.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<CancellationToken>())
|
||||||
|
.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<CancellationToken>())
|
||||||
|
.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<CancellationToken>())
|
||||||
|
.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<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_ShouldUpdateLastLoginOnSuccess()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var user = CreateUser("testuser", "hashed_password");
|
||||||
|
_userRepository.GetByUsernameAsync("testuser", Arg.Any<CancellationToken>())
|
||||||
|
.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<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_WithStudentUser_ShouldIncludeStudentInfo()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var user = CreateUserWithStudent("studentuser", "hashed_password", 1, "John Doe");
|
||||||
|
_userRepository.GetByUsernameAsync("studentuser", Arg.Any<CancellationToken>())
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<IUserRepository>();
|
||||||
|
_studentRepository = Substitute.For<IStudentRepository>();
|
||||||
|
_passwordService = Substitute.For<IPasswordService>();
|
||||||
|
_jwtService = Substitute.For<IJwtService>();
|
||||||
|
_unitOfWork = Substitute.For<IUnitOfWork>();
|
||||||
|
|
||||||
|
_passwordService.HashPassword(Arg.Any<string>()).Returns("hashed_value");
|
||||||
|
|
||||||
|
_handler = new RegisterCommandHandler(
|
||||||
|
_userRepository,
|
||||||
|
_studentRepository,
|
||||||
|
_passwordService,
|
||||||
|
_jwtService,
|
||||||
|
_unitOfWork
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_WithValidData_ShouldCreateUser()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
_userRepository.ExistsAsync("newuser", Arg.Any<CancellationToken>())
|
||||||
|
.Returns(false);
|
||||||
|
_jwtService.GenerateToken(Arg.Any<User>())
|
||||||
|
.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<CancellationToken>())
|
||||||
|
.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<CancellationToken>())
|
||||||
|
.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<CancellationToken>())
|
||||||
|
.Returns(false);
|
||||||
|
_jwtService.GenerateToken(Arg.Any<User>())
|
||||||
|
.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<Student>(s => s.Name == "John Doe"));
|
||||||
|
await _unitOfWork.Received().SaveChangesAsync(Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_WithInvalidEmail_ShouldReturnError()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
_userRepository.ExistsAsync("newuser", Arg.Any<CancellationToken>())
|
||||||
|
.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<CancellationToken>())
|
||||||
|
.Returns(false);
|
||||||
|
_jwtService.GenerateToken(Arg.Any<User>())
|
||||||
|
.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<CancellationToken>())
|
||||||
|
.Returns(false);
|
||||||
|
_jwtService.GenerateToken(Arg.Any<User>())
|
||||||
|
.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<User>(u => u.PasswordHash == "hashed_value"),
|
||||||
|
Arg.Any<CancellationToken>()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_ShouldNormalizeUsernameToLowercase()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
_userRepository.ExistsAsync("newuser", Arg.Any<CancellationToken>())
|
||||||
|
.Returns(false);
|
||||||
|
_jwtService.GenerateToken(Arg.Any<User>())
|
||||||
|
.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<CancellationToken>())
|
||||||
|
.Returns(false);
|
||||||
|
_jwtService.GenerateToken(Arg.Any<User>())
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<IUserRepository>();
|
||||||
|
_passwordService = Substitute.For<IPasswordService>();
|
||||||
|
_unitOfWork = Substitute.For<IUnitOfWork>();
|
||||||
|
|
||||||
|
_passwordService.HashPassword(Arg.Any<string>()).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<CancellationToken>())
|
||||||
|
.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<CancellationToken>())
|
||||||
|
.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<CancellationToken>())
|
||||||
|
.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<CancellationToken>())
|
||||||
|
.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<CancellationToken>())
|
||||||
|
.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<CancellationToken>())
|
||||||
|
.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<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_ShouldNotSaveChangesOnError()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
_userRepository.GetByUsernameAsync("nonexistent", Arg.Any<CancellationToken>())
|
||||||
|
.Returns((User?)null);
|
||||||
|
|
||||||
|
var command = new ResetPasswordCommand("nonexistent", "ANYCODE123", "newpassword123");
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await _handler.Handle(command, CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await _unitOfWork.DidNotReceive().SaveChangesAsync(Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_ShouldUpdateUserPasswordHash()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var user = CreateUser("testuser", "old_password_hash", "recovery_code_hash");
|
||||||
|
_userRepository.GetByUsernameAsync("testuser", Arg.Any<CancellationToken>())
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
namespace Application.Tests.Students;
|
namespace Application.Tests.Students;
|
||||||
|
|
||||||
|
using Application.Auth;
|
||||||
using Application.Students.Commands;
|
using Application.Students.Commands;
|
||||||
using Application.Students.DTOs;
|
using Application.Students.DTOs;
|
||||||
using Domain.Entities;
|
using Domain.Entities;
|
||||||
|
|
@ -7,20 +8,29 @@ using Domain.Exceptions;
|
||||||
using Domain.Ports.Repositories;
|
using Domain.Ports.Repositories;
|
||||||
using Domain.ValueObjects;
|
using Domain.ValueObjects;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
public class CreateStudentCommandTests
|
public class CreateStudentCommandTests
|
||||||
{
|
{
|
||||||
private readonly IStudentRepository _studentRepository;
|
private readonly IStudentRepository _studentRepository;
|
||||||
|
private readonly IPasswordService _passwordService;
|
||||||
private readonly IUnitOfWork _unitOfWork;
|
private readonly IUnitOfWork _unitOfWork;
|
||||||
|
private readonly IConfiguration _configuration;
|
||||||
private readonly CreateStudentHandler _handler;
|
private readonly CreateStudentHandler _handler;
|
||||||
|
|
||||||
public CreateStudentCommandTests()
|
public CreateStudentCommandTests()
|
||||||
{
|
{
|
||||||
_studentRepository = Substitute.For<IStudentRepository>();
|
_studentRepository = Substitute.For<IStudentRepository>();
|
||||||
|
_passwordService = Substitute.For<IPasswordService>();
|
||||||
_unitOfWork = Substitute.For<IUnitOfWork>();
|
_unitOfWork = Substitute.For<IUnitOfWork>();
|
||||||
_handler = new CreateStudentHandler(_studentRepository, _unitOfWork);
|
_configuration = Substitute.For<IConfiguration>();
|
||||||
|
|
||||||
|
_passwordService.HashPassword(Arg.Any<string>()).Returns("hashed_code");
|
||||||
|
_configuration["App:BaseUrl"].Returns("http://localhost:4200");
|
||||||
|
|
||||||
|
_handler = new CreateStudentHandler(_studentRepository, _passwordService, _unitOfWork, _configuration);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -34,10 +44,12 @@ public class CreateStudentCommandTests
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
result.Should().NotBeNull();
|
result.Should().NotBeNull();
|
||||||
result.Name.Should().Be("John Doe");
|
result.Student.Name.Should().Be("John Doe");
|
||||||
result.Email.Should().Be("john@example.com");
|
result.Student.Email.Should().Be("john@example.com");
|
||||||
result.TotalCredits.Should().Be(0);
|
result.Student.TotalCredits.Should().Be(0);
|
||||||
result.Enrollments.Should().BeEmpty();
|
result.Student.Enrollments.Should().BeEmpty();
|
||||||
|
result.ActivationCode.Should().NotBeNullOrEmpty();
|
||||||
|
result.ActivationUrl.Should().Contain("/activate?code=");
|
||||||
|
|
||||||
_studentRepository.Received(1).Add(Arg.Any<Student>());
|
_studentRepository.Received(1).Add(Arg.Any<Student>());
|
||||||
await _unitOfWork.Received(1).SaveChangesAsync(Arg.Any<CancellationToken>());
|
await _unitOfWork.Received(1).SaveChangesAsync(Arg.Any<CancellationToken>());
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ namespace Integration.Tests;
|
||||||
|
|
||||||
using Adapters.Driven.Persistence.Context;
|
using Adapters.Driven.Persistence.Context;
|
||||||
using Adapters.Driven.Persistence.Repositories;
|
using Adapters.Driven.Persistence.Repositories;
|
||||||
|
using Adapters.Driven.Persistence.Services;
|
||||||
|
using Application.Auth;
|
||||||
using Application.Enrollments.Commands;
|
using Application.Enrollments.Commands;
|
||||||
using Application.Students.Commands;
|
using Application.Students.Commands;
|
||||||
using Domain.Entities;
|
using Domain.Entities;
|
||||||
|
|
@ -9,6 +11,8 @@ using Domain.Exceptions;
|
||||||
using Domain.Services;
|
using Domain.Services;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using NSubstitute;
|
||||||
|
|
||||||
public class EnrollmentFlowTests : IDisposable
|
public class EnrollmentFlowTests : IDisposable
|
||||||
{
|
{
|
||||||
|
|
@ -55,7 +59,11 @@ public class EnrollmentFlowTests : IDisposable
|
||||||
public async Task CreateStudent_ShouldPersistToDatabase()
|
public async Task CreateStudent_ShouldPersistToDatabase()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var handler = new CreateStudentHandler(_studentRepository, _unitOfWork);
|
var passwordService = new PasswordService();
|
||||||
|
var configuration = Substitute.For<IConfiguration>();
|
||||||
|
configuration["App:BaseUrl"].Returns("http://localhost:4200");
|
||||||
|
|
||||||
|
var handler = new CreateStudentHandler(_studentRepository, passwordService, _unitOfWork, configuration);
|
||||||
var command = new CreateStudentCommand("John Doe", "john@example.com");
|
var command = new CreateStudentCommand("John Doe", "john@example.com");
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
|
|
@ -63,10 +71,12 @@ public class EnrollmentFlowTests : IDisposable
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
result.Should().NotBeNull();
|
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");
|
var savedStudent = await _context.Students.FirstOrDefaultAsync(s => s.Email.Value == "john@example.com");
|
||||||
savedStudent.Should().NotBeNull();
|
savedStudent.Should().NotBeNull();
|
||||||
|
savedStudent!.ActivationCodeHash.Should().NotBeNullOrEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||||
<PackageReference Include="FluentAssertions" Version="*" />
|
<PackageReference Include="FluentAssertions" Version="*" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="*" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="*" />
|
||||||
|
<PackageReference Include="NSubstitute" Version="*" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue