Compare commits
No commits in common. "b5da7d1b2e5087ce2cb03fba69e46cfc66b6f071" and "91f5c3460219128564639672955d5e20d50da8f8" have entirely different histories.
b5da7d1b2e
...
91f5c34602
22 changed files with 35 additions and 1893 deletions
105
.woodpecker.yml
105
.woodpecker.yml
|
|
@ -1,105 +0,0 @@
|
||||||
pipeline:
|
|
||||||
# Build and test backend
|
|
||||||
build-and-test:
|
|
||||||
image: mcr.microsoft.com/dotnet/sdk:9.0
|
|
||||||
commands:
|
|
||||||
- dotnet restore GermanApp/GermanApp.csproj
|
|
||||||
- dotnet build GermanApp/GermanApp.csproj --no-restore --configuration Release
|
|
||||||
- dotnet test Tests/GermanApp.Tests.Unit.csproj --no-build --configuration Release
|
|
||||||
- dotnet test Tests/GermanApp.Tests.Integration.csproj --no-build --configuration Release
|
|
||||||
when:
|
|
||||||
branch:
|
|
||||||
- main
|
|
||||||
- feature/*
|
|
||||||
- bugfix/*
|
|
||||||
- refactor/*
|
|
||||||
|
|
||||||
# Build and push backend image
|
|
||||||
build-backend:
|
|
||||||
image: plugins/docker
|
|
||||||
settings:
|
|
||||||
dockerfile: GermanApp/Dockerfile
|
|
||||||
context: GermanApp
|
|
||||||
registry: registry.lrhdev.dk
|
|
||||||
repo: registry.lrhdev.dk/lasserh/deutschlernen-backend
|
|
||||||
tags:
|
|
||||||
- latest
|
|
||||||
- ${CI_COMMIT_SHA:0:8}
|
|
||||||
username:
|
|
||||||
from_secret: docker_username
|
|
||||||
password:
|
|
||||||
from_secret: docker_password
|
|
||||||
when:
|
|
||||||
branch:
|
|
||||||
- main
|
|
||||||
status:
|
|
||||||
- success
|
|
||||||
|
|
||||||
# Build and push frontend image
|
|
||||||
build-frontend:
|
|
||||||
image: plugins/docker
|
|
||||||
settings:
|
|
||||||
dockerfile: german-app-frontend/Dockerfile
|
|
||||||
context: german-app-frontend
|
|
||||||
registry: registry.lrhdev.dk
|
|
||||||
repo: registry.lrhdev.dk/lasserh/deutschlernen-frontend
|
|
||||||
tags:
|
|
||||||
- latest
|
|
||||||
- ${CI_COMMIT_SHA:0:8}
|
|
||||||
username:
|
|
||||||
from_secret: docker_username
|
|
||||||
password:
|
|
||||||
from_secret: docker_password
|
|
||||||
when:
|
|
||||||
branch:
|
|
||||||
- main
|
|
||||||
status:
|
|
||||||
- success
|
|
||||||
|
|
||||||
# Deploy to production
|
|
||||||
deploy:
|
|
||||||
image: alpine
|
|
||||||
commands:
|
|
||||||
- apk add --no-cache openssh-client
|
|
||||||
- mkdir -p ~/.ssh
|
|
||||||
- echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa
|
|
||||||
- chmod 600 ~/.ssh/id_rsa
|
|
||||||
- ssh-keyscan deutsch.lrhdev.dk >> ~/.ssh/known_hosts
|
|
||||||
# Pull new images and restart containers
|
|
||||||
- ssh ci-deploy@deutsch.lrhdev.dk "cd /opt/deutschlernen && docker-compose pull && docker-compose up -d"
|
|
||||||
# Reload nginx to pick up any new configurations
|
|
||||||
- ssh ci-deploy@deutsch.lrhdev.dk "nginx -t && nginx -s reload"
|
|
||||||
secrets:
|
|
||||||
- SSH_PRIVATE_KEY
|
|
||||||
when:
|
|
||||||
branch:
|
|
||||||
- main
|
|
||||||
status:
|
|
||||||
- success
|
|
||||||
|
|
||||||
# ============================================
|
|
||||||
# WOODPECKER CONFIGURATION NOTES
|
|
||||||
# ============================================
|
|
||||||
#
|
|
||||||
# Required Secrets (configure in Woodpecker UI):
|
|
||||||
# --------------------------------------------
|
|
||||||
# 1. docker_username - Your registry username for registry.lrhdev.dk
|
|
||||||
# 2. docker_password - Your registry password for registry.lrhdev.dk
|
|
||||||
# 3. SSH_PRIVATE_KEY - SSH private key for deploying to deutsch.lrhdev.dk
|
|
||||||
#
|
|
||||||
# Registry Configuration:
|
|
||||||
# -----------------------
|
|
||||||
# - Registry URL: registry.lrhdev.dk
|
|
||||||
# - Repository: registry.lrhdev.dk/lasserh/deutschlernen
|
|
||||||
# - Images will be tagged as: latest and <short-commit-hash>
|
|
||||||
#
|
|
||||||
# Deployment:
|
|
||||||
# ----------
|
|
||||||
# The deploy stage connects via SSH to deutsch.lrhdev.dk and:
|
|
||||||
# 1. Pulls the latest backend image
|
|
||||||
# 2. Restarts the backend container
|
|
||||||
#
|
|
||||||
# Customize the deploy command to match your docker-compose setup.
|
|
||||||
# If you use a different path or service name, update the ssh command.
|
|
||||||
#
|
|
||||||
# ============================================
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
namespace GermanApp.Application.DTOs.Auth;
|
namespace GermanApp.Application.DTOs.Auth;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// DTO for authentication response containing JWT token and refresh token.
|
/// DTO for authentication response containing JWT token.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public record AuthResponse
|
public record AuthResponse
|
||||||
{
|
{
|
||||||
|
|
@ -9,6 +9,5 @@ public record AuthResponse
|
||||||
public string Username { get; init; } = string.Empty;
|
public string Username { get; init; } = string.Empty;
|
||||||
public string Email { get; init; } = string.Empty;
|
public string Email { get; init; } = string.Empty;
|
||||||
public string Token { get; init; } = string.Empty;
|
public string Token { get; init; } = string.Empty;
|
||||||
public string RefreshToken { get; init; } = string.Empty;
|
|
||||||
public DateTime ExpiresAt { get; init; }
|
public DateTime ExpiresAt { get; init; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
namespace GermanApp.Application.DTOs.Auth;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// DTO for refresh token response.
|
|
||||||
/// </summary>
|
|
||||||
public record RefreshTokenResponse
|
|
||||||
{
|
|
||||||
public string Token { get; init; } = string.Empty;
|
|
||||||
public string RefreshToken { get; init; } = string.Empty;
|
|
||||||
public DateTime ExpiresAt { get; init; }
|
|
||||||
}
|
|
||||||
|
|
@ -29,17 +29,4 @@ public interface IAuthService
|
||||||
/// <param name="userId">User ID from token claims</param>
|
/// <param name="userId">User ID from token claims</param>
|
||||||
/// <returns>The user entity</returns>
|
/// <returns>The user entity</returns>
|
||||||
Task<User?> GetCurrentUserAsync(int userId);
|
Task<User?> GetCurrentUserAsync(int userId);
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Refreshes the access token using a refresh token.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="refreshToken">The refresh token</param>
|
|
||||||
/// <returns>New access token and refresh token</returns>
|
|
||||||
Task<RefreshTokenResponse> RefreshTokenAsync(string refreshToken);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Revokes a refresh token.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="refreshToken">The refresh token to revoke</param>
|
|
||||||
Task RevokeRefreshTokenAsync(string refreshToken);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
namespace GermanApp.Domain.Entities;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Represents a refresh token for JWT authentication.
|
|
||||||
/// </summary>
|
|
||||||
public class RefreshToken
|
|
||||||
{
|
|
||||||
public int Id { get; internal set; }
|
|
||||||
public int UserId { get; internal set; }
|
|
||||||
public string Token { get; internal set; } = string.Empty;
|
|
||||||
public DateTime ExpiresAt { get; internal set; }
|
|
||||||
public bool IsActive { get; internal set; } = true;
|
|
||||||
public DateTime CreatedAt { get; internal set; }
|
|
||||||
public DateTime? RevokedAt { get; internal set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Constructor for EF Core deserialization.
|
|
||||||
/// </summary>
|
|
||||||
private RefreshToken() { }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Factory method to create a new refresh token.
|
|
||||||
/// </summary>
|
|
||||||
public static RefreshToken Create(int userId, string token, int expireDays = 7)
|
|
||||||
{
|
|
||||||
return new RefreshToken
|
|
||||||
{
|
|
||||||
UserId = userId,
|
|
||||||
Token = token,
|
|
||||||
ExpiresAt = DateTime.UtcNow.AddDays(expireDays),
|
|
||||||
CreatedAt = DateTime.UtcNow
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Revokes the refresh token.
|
|
||||||
/// </summary>
|
|
||||||
public void Revoke()
|
|
||||||
{
|
|
||||||
IsActive = false;
|
|
||||||
RevokedAt = DateTime.UtcNow;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Checks if the token is expired.
|
|
||||||
/// </summary>
|
|
||||||
public bool IsExpired() => DateTime.UtcNow >= ExpiresAt;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Checks if the token is valid (not revoked and not expired).
|
|
||||||
/// </summary>
|
|
||||||
public bool IsValid() => IsActive && !IsExpired();
|
|
||||||
}
|
|
||||||
|
|
@ -65,7 +65,7 @@ public class User
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void ChangeEmail(string newEmail)
|
public void ChangeEmail(string newEmail)
|
||||||
{
|
{
|
||||||
Email = newEmail?.ToLowerInvariant() ?? string.Empty;
|
Email = newEmail.ToLowerInvariant();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,6 @@
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<InternalsVisibleTo Include="GermanApp.Tests.Unit" />
|
|
||||||
<InternalsVisibleTo Include="GermanApp.Tests.Integration" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.16" />
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.16" />
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ public class AppDbContext : Microsoft.EntityFrameworkCore.DbContext
|
||||||
// DbSets for domain entities
|
// DbSets for domain entities
|
||||||
public DbSet<Lesson> Lessons { get; set; } = null!;
|
public DbSet<Lesson> Lessons { get; set; } = null!;
|
||||||
public DbSet<User> Users { get; set; } = null!;
|
public DbSet<User> Users { get; set; } = null!;
|
||||||
public DbSet<RefreshToken> RefreshTokens { get; set; } = null!;
|
|
||||||
|
|
||||||
// Note: Value objects are not stored directly as entities.
|
// Note: Value objects are not stored directly as entities.
|
||||||
// They are owned by entities and stored as part of the entity's data.
|
// They are owned by entities and stored as part of the entity's data.
|
||||||
|
|
@ -62,24 +61,6 @@ public class AppDbContext : Microsoft.EntityFrameworkCore.DbContext
|
||||||
builder.HasIndex(u => u.Email).IsUnique();
|
builder.HasIndex(u => u.Email).IsUnique();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Configure RefreshToken entity
|
|
||||||
modelBuilder.Entity<RefreshToken>(builder =>
|
|
||||||
{
|
|
||||||
builder.HasKey(r => r.Id);
|
|
||||||
builder.Property(r => r.UserId).IsRequired();
|
|
||||||
builder.Property(r => r.Token).IsRequired().HasMaxLength(255);
|
|
||||||
builder.Property(r => r.ExpiresAt).IsRequired();
|
|
||||||
builder.Property(r => r.IsActive).HasDefaultValue(true);
|
|
||||||
builder.Property(r => r.CreatedAt).IsRequired();
|
|
||||||
builder.Property(r => r.RevokedAt).IsRequired(false);
|
|
||||||
|
|
||||||
// Foreign key to User
|
|
||||||
builder.HasOne<User>()
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey(r => r.UserId)
|
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Seed data (optional) - Note: For EF Core, we need to set properties directly
|
// Seed data (optional) - Note: For EF Core, we need to set properties directly
|
||||||
// In a real application, use migrations or a separate seeding mechanism
|
// In a real application, use migrations or a separate seeding mechanism
|
||||||
// modelBuilder.Entity<Lesson>().HasData(
|
// modelBuilder.Entity<Lesson>().HasData(
|
||||||
|
|
|
||||||
|
|
@ -1,162 +0,0 @@
|
||||||
// <auto-generated />
|
|
||||||
using System;
|
|
||||||
using GermanApp.Infrastructure.Data.DbContext;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
|
||||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace GermanApp.Infrastructure.Data.Migrations
|
|
||||||
{
|
|
||||||
[DbContext(typeof(AppDbContext))]
|
|
||||||
[Migration("20260605131551_AddRefreshTokensTable")]
|
|
||||||
partial class AddRefreshTokensTable
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
|
||||||
{
|
|
||||||
#pragma warning disable 612, 618
|
|
||||||
modelBuilder
|
|
||||||
.HasAnnotation("ProductVersion", "9.0.0")
|
|
||||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
|
||||||
|
|
||||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.Lesson", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<string>("Description")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(2000)
|
|
||||||
.HasColumnType("character varying(2000)");
|
|
||||||
|
|
||||||
b.Property<int>("Level")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<string>("Title")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(200)
|
|
||||||
.HasColumnType("character varying(200)");
|
|
||||||
|
|
||||||
b.Property<DateTime?>("UpdatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.ToTable("Lessons");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.RefreshToken", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<DateTime>("ExpiresAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<bool>("IsActive")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("boolean")
|
|
||||||
.HasDefaultValue(true);
|
|
||||||
|
|
||||||
b.Property<DateTime?>("RevokedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<string>("Token")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(255)
|
|
||||||
.HasColumnType("character varying(255)");
|
|
||||||
|
|
||||||
b.Property<int>("UserId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("UserId");
|
|
||||||
|
|
||||||
b.ToTable("RefreshTokens");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.User", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<string>("CurrentLevel")
|
|
||||||
.IsRequired()
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasMaxLength(10)
|
|
||||||
.HasColumnType("character varying(10)")
|
|
||||||
.HasDefaultValue("A1");
|
|
||||||
|
|
||||||
b.Property<string>("Email")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(100)
|
|
||||||
.HasColumnType("character varying(100)");
|
|
||||||
|
|
||||||
b.Property<string>("PasswordHash")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(255)
|
|
||||||
.HasColumnType("character varying(255)");
|
|
||||||
|
|
||||||
b.Property<int>("Streak")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasDefaultValue(0);
|
|
||||||
|
|
||||||
b.Property<int>("TotalPoints")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasDefaultValue(0);
|
|
||||||
|
|
||||||
b.Property<string>("Username")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(50)
|
|
||||||
.HasColumnType("character varying(50)");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("Email")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.HasIndex("Username")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.ToTable("Users");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.RefreshToken", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.User", null)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("UserId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
});
|
|
||||||
#pragma warning restore 612, 618
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
using System;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace GermanApp.Infrastructure.Data.Migrations
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public partial class AddRefreshTokensTable : Migration
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name: "RefreshTokens",
|
|
||||||
columns: table => new
|
|
||||||
{
|
|
||||||
Id = table.Column<int>(type: "integer", nullable: false)
|
|
||||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
|
||||||
UserId = table.Column<int>(type: "integer", nullable: false),
|
|
||||||
Token = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
|
|
||||||
ExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
|
||||||
IsActive = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
|
|
||||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
|
||||||
RevokedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
|
||||||
},
|
|
||||||
constraints: table =>
|
|
||||||
{
|
|
||||||
table.PrimaryKey("PK_RefreshTokens", x => x.Id);
|
|
||||||
table.ForeignKey(
|
|
||||||
name: "FK_RefreshTokens_Users_UserId",
|
|
||||||
column: x => x.UserId,
|
|
||||||
principalTable: "Users",
|
|
||||||
principalColumn: "Id",
|
|
||||||
onDelete: ReferentialAction.Cascade);
|
|
||||||
});
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_RefreshTokens_UserId",
|
|
||||||
table: "RefreshTokens",
|
|
||||||
column: "UserId");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.DropTable(
|
|
||||||
name: "RefreshTokens");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -54,43 +54,6 @@ namespace GermanApp.Migrations
|
||||||
b.ToTable("Lessons");
|
b.ToTable("Lessons");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.RefreshToken", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<DateTime>("ExpiresAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<bool>("IsActive")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("boolean")
|
|
||||||
.HasDefaultValue(true);
|
|
||||||
|
|
||||||
b.Property<DateTime?>("RevokedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<string>("Token")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(255)
|
|
||||||
.HasColumnType("character varying(255)");
|
|
||||||
|
|
||||||
b.Property<int>("UserId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("UserId");
|
|
||||||
|
|
||||||
b.ToTable("RefreshTokens");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.User", b =>
|
modelBuilder.Entity("GermanApp.Domain.Entities.User", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
|
|
@ -144,15 +107,6 @@ namespace GermanApp.Migrations
|
||||||
|
|
||||||
b.ToTable("Users");
|
b.ToTable("Users");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("GermanApp.Domain.Entities.RefreshToken", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("GermanApp.Domain.Entities.User", null)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("UserId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
});
|
|
||||||
#pragma warning restore 612, 618
|
#pragma warning restore 612, 618
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
using System.IdentityModel.Tokens.Jwt;
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Security.Cryptography;
|
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using GermanApp.Application.DTOs.Auth;
|
using GermanApp.Application.DTOs.Auth;
|
||||||
using GermanApp.Application.Interfaces;
|
using GermanApp.Application.Interfaces;
|
||||||
|
|
@ -33,17 +32,6 @@ public class AuthService : IAuthService
|
||||||
_configuration = configuration;
|
_configuration = configuration;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates a cryptographically secure random token string.
|
|
||||||
/// </summary>
|
|
||||||
private static string GenerateRefreshTokenString(int length = 32)
|
|
||||||
{
|
|
||||||
var randomNumber = new byte[length];
|
|
||||||
using var rng = RandomNumberGenerator.Create();
|
|
||||||
rng.GetBytes(randomNumber);
|
|
||||||
return Convert.ToBase64String(randomNumber);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Registers a new user.
|
/// Registers a new user.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -67,19 +55,12 @@ public class AuthService : IAuthService
|
||||||
// Generate JWT token
|
// Generate JWT token
|
||||||
var token = GenerateJwtToken(user);
|
var token = GenerateJwtToken(user);
|
||||||
|
|
||||||
// Generate and store refresh token
|
|
||||||
var refreshTokenString = GenerateRefreshTokenString();
|
|
||||||
var refreshToken = RefreshToken.Create(user.Id, refreshTokenString);
|
|
||||||
_dbContext.RefreshTokens.Add(refreshToken);
|
|
||||||
await _dbContext.SaveChangesAsync();
|
|
||||||
|
|
||||||
return new AuthResponse
|
return new AuthResponse
|
||||||
{
|
{
|
||||||
UserId = user.Id,
|
UserId = user.Id,
|
||||||
Username = user.Username,
|
Username = user.Username,
|
||||||
Email = user.Email,
|
Email = user.Email,
|
||||||
Token = token,
|
Token = token,
|
||||||
RefreshToken = refreshTokenString,
|
|
||||||
ExpiresAt = DateTime.UtcNow.AddHours(24)
|
ExpiresAt = DateTime.UtcNow.AddHours(24)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -99,32 +80,15 @@ public class AuthService : IAuthService
|
||||||
if (result == PasswordVerificationResult.Failed)
|
if (result == PasswordVerificationResult.Failed)
|
||||||
throw new UnauthorizedAccessException("Invalid email or password");
|
throw new UnauthorizedAccessException("Invalid email or password");
|
||||||
|
|
||||||
// Revoke any existing refresh tokens for this user (optional: rotate tokens)
|
|
||||||
var existingRefreshTokens = await _dbContext.RefreshTokens
|
|
||||||
.Where(rt => rt.UserId == user.Id && rt.IsActive)
|
|
||||||
.ToListAsync();
|
|
||||||
|
|
||||||
foreach (var rt in existingRefreshTokens)
|
|
||||||
{
|
|
||||||
rt.Revoke();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate JWT token
|
// Generate JWT token
|
||||||
var token = GenerateJwtToken(user);
|
var token = GenerateJwtToken(user);
|
||||||
|
|
||||||
// Generate and store new refresh token
|
|
||||||
var refreshTokenString = GenerateRefreshTokenString();
|
|
||||||
var refreshToken = RefreshToken.Create(user.Id, refreshTokenString);
|
|
||||||
_dbContext.RefreshTokens.Add(refreshToken);
|
|
||||||
await _dbContext.SaveChangesAsync();
|
|
||||||
|
|
||||||
return new AuthResponse
|
return new AuthResponse
|
||||||
{
|
{
|
||||||
UserId = user.Id,
|
UserId = user.Id,
|
||||||
Username = user.Username,
|
Username = user.Username,
|
||||||
Email = user.Email,
|
Email = user.Email,
|
||||||
Token = token,
|
Token = token,
|
||||||
RefreshToken = refreshTokenString,
|
|
||||||
ExpiresAt = DateTime.UtcNow.AddHours(24)
|
ExpiresAt = DateTime.UtcNow.AddHours(24)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -165,66 +129,4 @@ public class AuthService : IAuthService
|
||||||
|
|
||||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Refreshes the access token using a refresh token.
|
|
||||||
/// Rotates the refresh token (generates a new one, revokes the old one).
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="refreshToken">The refresh token</param>
|
|
||||||
/// <returns>New access token and refresh token</returns>
|
|
||||||
/// <exception cref="UnauthorizedAccessException">Thrown when refresh token is invalid</exception>
|
|
||||||
public async Task<RefreshTokenResponse> RefreshTokenAsync(string refreshToken)
|
|
||||||
{
|
|
||||||
// Find the refresh token in the database
|
|
||||||
var storedToken = await _dbContext.RefreshTokens
|
|
||||||
.FirstOrDefaultAsync(rt => rt.Token == refreshToken);
|
|
||||||
|
|
||||||
if (storedToken == null)
|
|
||||||
throw new UnauthorizedAccessException("Invalid refresh token");
|
|
||||||
|
|
||||||
if (!storedToken.IsValid())
|
|
||||||
throw new UnauthorizedAccessException("Invalid refresh token");
|
|
||||||
|
|
||||||
// Get the user associated with this refresh token
|
|
||||||
var user = await _dbContext.Users.FirstOrDefaultAsync(u => u.Id == storedToken.UserId);
|
|
||||||
if (user == null)
|
|
||||||
throw new UnauthorizedAccessException("User not found for refresh token");
|
|
||||||
|
|
||||||
// Revoke the current refresh token
|
|
||||||
storedToken.Revoke();
|
|
||||||
|
|
||||||
// Generate new JWT access token
|
|
||||||
var newAccessToken = GenerateJwtToken(user);
|
|
||||||
|
|
||||||
// Generate new refresh token (rotate)
|
|
||||||
var newRefreshTokenString = GenerateRefreshTokenString();
|
|
||||||
var newRefreshToken = RefreshToken.Create(user.Id, newRefreshTokenString);
|
|
||||||
_dbContext.RefreshTokens.Add(newRefreshToken);
|
|
||||||
|
|
||||||
await _dbContext.SaveChangesAsync();
|
|
||||||
|
|
||||||
return new RefreshTokenResponse
|
|
||||||
{
|
|
||||||
Token = newAccessToken,
|
|
||||||
RefreshToken = newRefreshTokenString,
|
|
||||||
ExpiresAt = DateTime.UtcNow.AddHours(24)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Revokes a refresh token.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="refreshToken">The refresh token to revoke</param>
|
|
||||||
/// <exception cref="UnauthorizedAccessException">Thrown when refresh token is not found</exception>
|
|
||||||
public async Task RevokeRefreshTokenAsync(string refreshToken)
|
|
||||||
{
|
|
||||||
var storedToken = await _dbContext.RefreshTokens
|
|
||||||
.FirstOrDefaultAsync(rt => rt.Token == refreshToken);
|
|
||||||
|
|
||||||
if (storedToken == null)
|
|
||||||
throw new UnauthorizedAccessException("Refresh token not found");
|
|
||||||
|
|
||||||
storedToken.Revoke();
|
|
||||||
await _dbContext.SaveChangesAsync();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -103,57 +103,4 @@ public class AuthController : ControllerBase
|
||||||
return StatusCode((int)HttpStatusCode.InternalServerError, ex.Message);
|
return StatusCode((int)HttpStatusCode.InternalServerError, ex.Message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Refresh the access token using a refresh token.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="refreshToken">The refresh token</param>
|
|
||||||
/// <returns>New access token and refresh token</returns>
|
|
||||||
[HttpPost("refresh")]
|
|
||||||
[ProducesResponseType(typeof(RefreshTokenResponse), (int)HttpStatusCode.OK)]
|
|
||||||
[ProducesResponseType(typeof(string), (int)HttpStatusCode.Unauthorized)]
|
|
||||||
[ProducesResponseType(typeof(string), (int)HttpStatusCode.BadRequest)]
|
|
||||||
public async Task<IActionResult> Refresh([FromBody] string refreshToken)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var result = await _authService.RefreshTokenAsync(refreshToken);
|
|
||||||
return Ok(result);
|
|
||||||
}
|
|
||||||
catch (UnauthorizedAccessException ex)
|
|
||||||
{
|
|
||||||
return Unauthorized(ex.Message);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return StatusCode((int)HttpStatusCode.InternalServerError, ex.Message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Revoke a refresh token.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="refreshToken">The refresh token to revoke</param>
|
|
||||||
/// <returns>Success or error response</returns>
|
|
||||||
[HttpPost("revoke-refresh")]
|
|
||||||
[Authorize]
|
|
||||||
[ProducesResponseType((int)HttpStatusCode.OK)]
|
|
||||||
[ProducesResponseType(typeof(string), (int)HttpStatusCode.Unauthorized)]
|
|
||||||
[ProducesResponseType(typeof(string), (int)HttpStatusCode.BadRequest)]
|
|
||||||
public async Task<IActionResult> RevokeRefreshToken([FromBody] string refreshToken)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _authService.RevokeRefreshTokenAsync(refreshToken);
|
|
||||||
return Ok(new { message = "Refresh token revoked successfully" });
|
|
||||||
}
|
|
||||||
catch (UnauthorizedAccessException ex)
|
|
||||||
{
|
|
||||||
return Unauthorized(ex.Message);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return StatusCode((int)HttpStatusCode.InternalServerError, ex.Message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<ImplicitUsings>disable</ImplicitUsings>
|
|
||||||
<IsPackable>false</IsPackable>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
|
||||||
<PackageReference Include="MSTest.TestAdapter" Version="4.2.3" />
|
|
||||||
<PackageReference Include="MSTest.TestFramework" Version="4.2.3" />
|
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.0" />
|
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.0" />
|
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Identity" Version="2.2.0" />
|
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.0" />
|
|
||||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.0.1" />
|
|
||||||
<PackageReference Include="Moq" Version="4.20.72" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\GermanApp\GermanApp.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<ImplicitUsings>disable</ImplicitUsings>
|
|
||||||
<IsPackable>false</IsPackable>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
|
||||||
<PackageReference Include="MSTest.TestAdapter" Version="4.2.3" />
|
|
||||||
<PackageReference Include="MSTest.TestFramework" Version="4.2.3" />
|
|
||||||
<PackageReference Include="Moq" Version="4.20.72" />
|
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Identity" Version="2.2.0" />
|
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.0" />
|
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.0" />
|
|
||||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.0.1" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\GermanApp\GermanApp.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
|
|
@ -1,415 +0,0 @@
|
||||||
using System;
|
|
||||||
using System.Net;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using GermanApp.Application.DTOs.Auth;
|
|
||||||
using GermanApp.Application.Interfaces;
|
|
||||||
using GermanApp.Domain.Entities;
|
|
||||||
using GermanApp.Presentation.Controllers;
|
|
||||||
using Microsoft.AspNetCore.Http;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
|
||||||
using Moq;
|
|
||||||
|
|
||||||
namespace GermanApp.Tests.Integration.Controllers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Integration tests for AuthController.
|
|
||||||
/// Tests controller behavior with mocked services.
|
|
||||||
/// </summary>
|
|
||||||
[TestClass]
|
|
||||||
public class AuthControllerTests
|
|
||||||
{
|
|
||||||
private Mock<IAuthService>? _mockAuthService;
|
|
||||||
private AuthController? _controller;
|
|
||||||
|
|
||||||
[TestInitialize]
|
|
||||||
public void TestInitialize()
|
|
||||||
{
|
|
||||||
_mockAuthService = new Mock<IAuthService>();
|
|
||||||
_controller = new AuthController(_mockAuthService.Object);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestCleanup]
|
|
||||||
public void TestCleanup()
|
|
||||||
{
|
|
||||||
_controller = null;
|
|
||||||
_mockAuthService = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== REGISTER ENDPOINT TESTS ====================
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("AuthController")]
|
|
||||||
[TestCategory("Register")]
|
|
||||||
public async Task Register_WithValidData_ReturnsOkWithToken()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var registerDto = new RegisterDto
|
|
||||||
{
|
|
||||||
Username = "testuser",
|
|
||||||
Email = "test@example.com",
|
|
||||||
Password = "TestPassword123!"
|
|
||||||
};
|
|
||||||
|
|
||||||
var expectedResponse = new AuthResponse
|
|
||||||
{
|
|
||||||
UserId = 1,
|
|
||||||
Username = "testuser",
|
|
||||||
Email = "test@example.com",
|
|
||||||
Token = "test-token",
|
|
||||||
RefreshToken = "test-refresh-token",
|
|
||||||
ExpiresAt = DateTime.UtcNow.AddHours(24)
|
|
||||||
};
|
|
||||||
|
|
||||||
_mockAuthService!.Setup(s => s.RegisterAsync(It.IsAny<RegisterDto>()))
|
|
||||||
.ReturnsAsync(expectedResponse);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _controller!.Register(registerDto);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsInstanceOfType(result, typeof(OkObjectResult));
|
|
||||||
var okResult = result as OkObjectResult;
|
|
||||||
Assert.IsNotNull(okResult);
|
|
||||||
Assert.AreEqual(expectedResponse, okResult.Value);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("AuthController")]
|
|
||||||
[TestCategory("Register")]
|
|
||||||
public async Task Register_WithDuplicateUsername_ReturnsBadRequest()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var registerDto = new RegisterDto
|
|
||||||
{
|
|
||||||
Username = "duplicate_user",
|
|
||||||
Email = "test@example.com",
|
|
||||||
Password = "TestPassword123!"
|
|
||||||
};
|
|
||||||
|
|
||||||
_mockAuthService!.Setup(s => s.RegisterAsync(It.IsAny<RegisterDto>()))
|
|
||||||
.ThrowsAsync(new InvalidOperationException("Username already taken"));
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _controller!.Register(registerDto);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsInstanceOfType(result, typeof(BadRequestObjectResult));
|
|
||||||
var badRequestResult = result as BadRequestObjectResult;
|
|
||||||
Assert.IsNotNull(badRequestResult);
|
|
||||||
Assert.AreEqual("Username already taken", badRequestResult.Value);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("AuthController")]
|
|
||||||
[TestCategory("Register")]
|
|
||||||
public async Task Register_WithDuplicateEmail_ReturnsBadRequest()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var registerDto = new RegisterDto
|
|
||||||
{
|
|
||||||
Username = "testuser",
|
|
||||||
Email = "duplicate@example.com",
|
|
||||||
Password = "TestPassword123!"
|
|
||||||
};
|
|
||||||
|
|
||||||
_mockAuthService!.Setup(s => s.RegisterAsync(It.IsAny<RegisterDto>()))
|
|
||||||
.ThrowsAsync(new InvalidOperationException("Email already in use"));
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _controller!.Register(registerDto);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsInstanceOfType(result, typeof(BadRequestObjectResult));
|
|
||||||
var badRequestResult = result as BadRequestObjectResult;
|
|
||||||
Assert.IsNotNull(badRequestResult);
|
|
||||||
Assert.AreEqual("Email already in use", badRequestResult.Value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== LOGIN ENDPOINT TESTS ====================
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("AuthController")]
|
|
||||||
[TestCategory("Login")]
|
|
||||||
public async Task Login_WithValidCredentials_ReturnsOkWithToken()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var loginDto = new LoginDto
|
|
||||||
{
|
|
||||||
Email = "test@example.com",
|
|
||||||
Password = "TestPassword123!"
|
|
||||||
};
|
|
||||||
|
|
||||||
var expectedResponse = new AuthResponse
|
|
||||||
{
|
|
||||||
UserId = 1,
|
|
||||||
Username = "testuser",
|
|
||||||
Email = "test@example.com",
|
|
||||||
Token = "test-token",
|
|
||||||
RefreshToken = "test-refresh-token",
|
|
||||||
ExpiresAt = DateTime.UtcNow.AddHours(24)
|
|
||||||
};
|
|
||||||
|
|
||||||
_mockAuthService!.Setup(s => s.LoginAsync(It.IsAny<LoginDto>()))
|
|
||||||
.ReturnsAsync(expectedResponse);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _controller!.Login(loginDto);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsInstanceOfType(result, typeof(OkObjectResult));
|
|
||||||
var okResult = result as OkObjectResult;
|
|
||||||
Assert.IsNotNull(okResult);
|
|
||||||
Assert.AreEqual(expectedResponse, okResult.Value);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("AuthController")]
|
|
||||||
[TestCategory("Login")]
|
|
||||||
public async Task Login_WithInvalidEmail_ReturnsUnauthorized()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var loginDto = new LoginDto
|
|
||||||
{
|
|
||||||
Email = "nonexistent@example.com",
|
|
||||||
Password = "TestPassword123!"
|
|
||||||
};
|
|
||||||
|
|
||||||
_mockAuthService!.Setup(s => s.LoginAsync(It.IsAny<LoginDto>()))
|
|
||||||
.ThrowsAsync(new UnauthorizedAccessException("Invalid email or password"));
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _controller!.Login(loginDto);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsInstanceOfType(result, typeof(UnauthorizedObjectResult));
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("AuthController")]
|
|
||||||
[TestCategory("Login")]
|
|
||||||
public async Task Login_WithInvalidPassword_ReturnsUnauthorized()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var loginDto = new LoginDto
|
|
||||||
{
|
|
||||||
Email = "test@example.com",
|
|
||||||
Password = "wrong_password"
|
|
||||||
};
|
|
||||||
|
|
||||||
_mockAuthService!.Setup(s => s.LoginAsync(It.IsAny<LoginDto>()))
|
|
||||||
.ThrowsAsync(new UnauthorizedAccessException("Invalid email or password"));
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _controller!.Login(loginDto);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsInstanceOfType(result, typeof(UnauthorizedObjectResult));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== GET CURRENT USER ENDPOINT TESTS ====================
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("AuthController")]
|
|
||||||
[TestCategory("Me")]
|
|
||||||
public async Task GetCurrentUser_WithValidUser_ReturnsUserInfo()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var user = User.Create("testuser", "test@example.com", "hashed-password");
|
|
||||||
|
|
||||||
_mockAuthService!.Setup(s => s.GetCurrentUserAsync(1))
|
|
||||||
.ReturnsAsync(user);
|
|
||||||
|
|
||||||
// Arrange - set up controller context with user claim
|
|
||||||
_controller!.ControllerContext = new ControllerContext
|
|
||||||
{
|
|
||||||
HttpContext = new DefaultHttpContext
|
|
||||||
{
|
|
||||||
User = new System.Security.Claims.ClaimsPrincipal(new System.Security.Claims.ClaimsIdentity(new[]
|
|
||||||
{
|
|
||||||
new System.Security.Claims.Claim("nameid", "1"),
|
|
||||||
new System.Security.Claims.Claim("name", "testuser"),
|
|
||||||
new System.Security.Claims.Claim("email", "test@example.com"),
|
|
||||||
new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Role, "User")
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _controller.GetCurrentUser();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsInstanceOfType(result, typeof(OkObjectResult));
|
|
||||||
var okResult = result as OkObjectResult;
|
|
||||||
Assert.IsNotNull(okResult);
|
|
||||||
var response = okResult.Value as AuthResponse;
|
|
||||||
Assert.IsNotNull(response);
|
|
||||||
Assert.AreEqual(user.Id, response.UserId);
|
|
||||||
Assert.AreEqual(user.Username, response.Username);
|
|
||||||
Assert.AreEqual(user.Email, response.Email);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("AuthController")]
|
|
||||||
[TestCategory("Me")]
|
|
||||||
public async Task GetCurrentUser_WithoutAuthentication_ReturnsUnauthorized()
|
|
||||||
{
|
|
||||||
// Arrange - no user in context
|
|
||||||
_controller!.ControllerContext = new ControllerContext
|
|
||||||
{
|
|
||||||
HttpContext = new DefaultHttpContext()
|
|
||||||
};
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _controller.GetCurrentUser();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsInstanceOfType(result, typeof(UnauthorizedResult));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== REFRESH TOKEN ENDPOINT TESTS ====================
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("AuthController")]
|
|
||||||
[TestCategory("Refresh")]
|
|
||||||
public async Task Refresh_WithValidRefreshToken_ReturnsNewTokens()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var validRefreshToken = "valid-refresh-token";
|
|
||||||
|
|
||||||
var expectedResponse = new RefreshTokenResponse
|
|
||||||
{
|
|
||||||
Token = "new-access-token",
|
|
||||||
RefreshToken = "new-refresh-token",
|
|
||||||
ExpiresAt = DateTime.UtcNow.AddHours(24)
|
|
||||||
};
|
|
||||||
|
|
||||||
_mockAuthService!.Setup(s => s.RefreshTokenAsync(validRefreshToken))
|
|
||||||
.ReturnsAsync(expectedResponse);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _controller!.Refresh(validRefreshToken);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsInstanceOfType(result, typeof(OkObjectResult));
|
|
||||||
var okResult = result as OkObjectResult;
|
|
||||||
Assert.IsNotNull(okResult);
|
|
||||||
Assert.AreEqual(expectedResponse, okResult.Value);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("AuthController")]
|
|
||||||
[TestCategory("Refresh")]
|
|
||||||
public async Task Refresh_WithInvalidRefreshToken_ReturnsUnauthorized()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var invalidRefreshToken = "invalid-refresh-token";
|
|
||||||
|
|
||||||
_mockAuthService!.Setup(s => s.RefreshTokenAsync(invalidRefreshToken))
|
|
||||||
.ThrowsAsync(new UnauthorizedAccessException("Invalid refresh token"));
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _controller!.Refresh(invalidRefreshToken);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsInstanceOfType(result, typeof(UnauthorizedObjectResult));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== REVOKE REFRESH TOKEN ENDPOINT TESTS ====================
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("AuthController")]
|
|
||||||
[TestCategory("RevokeRefresh")]
|
|
||||||
public async Task RevokeRefreshToken_WithValidToken_ReturnsOk()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var validRefreshToken = "valid-refresh-token";
|
|
||||||
|
|
||||||
// No exception means success
|
|
||||||
_mockAuthService!.Setup(s => s.RevokeRefreshTokenAsync(validRefreshToken))
|
|
||||||
.Returns(Task.CompletedTask);
|
|
||||||
|
|
||||||
// Arrange - set up authorized context
|
|
||||||
_controller!.ControllerContext = new ControllerContext
|
|
||||||
{
|
|
||||||
HttpContext = new DefaultHttpContext
|
|
||||||
{
|
|
||||||
User = new System.Security.Claims.ClaimsPrincipal(new System.Security.Claims.ClaimsIdentity(new[]
|
|
||||||
{
|
|
||||||
new System.Security.Claims.Claim("nameid", "1"),
|
|
||||||
new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Role, "User")
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _controller.RevokeRefreshToken(validRefreshToken);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsInstanceOfType(result, typeof(OkObjectResult));
|
|
||||||
var okResult = result as OkObjectResult;
|
|
||||||
Assert.IsNotNull(okResult);
|
|
||||||
// Check that the response contains the expected message
|
|
||||||
dynamic responseValue = okResult.Value!;
|
|
||||||
Assert.AreEqual("Refresh token revoked successfully", (string)responseValue.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("AuthController")]
|
|
||||||
[TestCategory("RevokeRefresh")]
|
|
||||||
public async Task RevokeRefreshToken_WithoutAuthentication_ReturnsUnauthorized()
|
|
||||||
{
|
|
||||||
// Note: When testing controllers directly (not through HTTP pipeline),
|
|
||||||
// the [Authorize] attribute is not automatically enforced.
|
|
||||||
// This test would pass in a full integration test with WebApplicationFactory.
|
|
||||||
// For now, we test that the controller correctly uses the service.
|
|
||||||
|
|
||||||
// Arrange - no user in context, service throws exception
|
|
||||||
var invalidRefreshToken = "any-token";
|
|
||||||
_mockAuthService!.Setup(s => s.RevokeRefreshTokenAsync(invalidRefreshToken))
|
|
||||||
.ThrowsAsync(new UnauthorizedAccessException("Refresh token not found"));
|
|
||||||
|
|
||||||
_controller!.ControllerContext = new ControllerContext
|
|
||||||
{
|
|
||||||
HttpContext = new DefaultHttpContext()
|
|
||||||
};
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _controller.RevokeRefreshToken(invalidRefreshToken);
|
|
||||||
|
|
||||||
// Assert - Without [Authorize] enforcement in direct controller tests,
|
|
||||||
// we expect the service exception to propagate as UnauthorizedObjectResult
|
|
||||||
Assert.IsInstanceOfType(result, typeof(UnauthorizedObjectResult));
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("AuthController")]
|
|
||||||
[TestCategory("RevokeRefresh")]
|
|
||||||
public async Task RevokeRefreshToken_WithInvalidToken_ReturnsUnauthorized()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var invalidRefreshToken = "invalid-refresh-token";
|
|
||||||
|
|
||||||
_mockAuthService!.Setup(s => s.RevokeRefreshTokenAsync(invalidRefreshToken))
|
|
||||||
.ThrowsAsync(new UnauthorizedAccessException("Refresh token not found"));
|
|
||||||
|
|
||||||
// Arrange - set up authorized context
|
|
||||||
_controller!.ControllerContext = new ControllerContext
|
|
||||||
{
|
|
||||||
HttpContext = new DefaultHttpContext
|
|
||||||
{
|
|
||||||
User = new System.Security.Claims.ClaimsPrincipal(new System.Security.Claims.ClaimsIdentity(new[]
|
|
||||||
{
|
|
||||||
new System.Security.Claims.Claim("nameid", "1"),
|
|
||||||
new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Role, "User")
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _controller.RevokeRefreshToken(invalidRefreshToken);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsInstanceOfType(result, typeof(UnauthorizedObjectResult));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,320 +0,0 @@
|
||||||
using System;
|
|
||||||
using GermanApp.Domain.Entities;
|
|
||||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
|
||||||
|
|
||||||
namespace GermanApp.Tests.Unit.Domain.Entities;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Unit tests for RefreshToken domain entity.
|
|
||||||
/// Tests the refresh token factory methods and business logic.
|
|
||||||
/// </summary>
|
|
||||||
[TestClass]
|
|
||||||
public class RefreshTokenTests
|
|
||||||
{
|
|
||||||
#region Factory Method Tests
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("Factory")]
|
|
||||||
public void Create_WithValidParameters_ReturnsRefreshToken()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
int userId = 1;
|
|
||||||
string token = "test-token-string";
|
|
||||||
int expireDays = 7;
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var refreshToken = RefreshToken.Create(userId, token, expireDays);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsNotNull(refreshToken);
|
|
||||||
Assert.AreEqual(userId, refreshToken.UserId);
|
|
||||||
Assert.AreEqual(token, refreshToken.Token);
|
|
||||||
Assert.AreEqual(DateTime.UtcNow.Date, refreshToken.CreatedAt.Date);
|
|
||||||
Assert.IsTrue(refreshToken.ExpiresAt > DateTime.UtcNow);
|
|
||||||
Assert.IsTrue(refreshToken.IsActive);
|
|
||||||
Assert.IsNull(refreshToken.RevokedAt);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("Factory")]
|
|
||||||
public void Create_WithDefaultExpireDays_Uses7Days()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
int userId = 1;
|
|
||||||
string token = "test-token-string";
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var refreshToken = RefreshToken.Create(userId, token);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
var timeDifference = refreshToken.ExpiresAt - DateTime.UtcNow;
|
|
||||||
Assert.IsTrue(timeDifference.TotalDays > 6.9 && timeDifference.TotalDays < 7.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("Factory")]
|
|
||||||
public void Create_WithCustomExpireDays_SetsCorrectExpiry()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
int userId = 1;
|
|
||||||
string token = "test-token";
|
|
||||||
int expireDays = 30;
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var refreshToken = RefreshToken.Create(userId, token, expireDays);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
var expectedExpiry = DateTime.UtcNow.AddDays(expireDays);
|
|
||||||
var timeDifference = refreshToken.ExpiresAt - DateTime.UtcNow;
|
|
||||||
Assert.IsTrue(timeDifference.TotalDays > 29.9 && timeDifference.TotalDays < 30.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Revoke Method Tests
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("Behavior")]
|
|
||||||
public void Revoke_ActiveToken_DeactivatesAndSetsRevokedAt()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var refreshToken = RefreshToken.Create(1, "test-token");
|
|
||||||
|
|
||||||
// Act
|
|
||||||
refreshToken.Revoke();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsFalse(refreshToken.IsActive);
|
|
||||||
Assert.IsNotNull(refreshToken.RevokedAt);
|
|
||||||
Assert.IsTrue(refreshToken.RevokedAt > DateTime.UtcNow.AddSeconds(-1));
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("Behavior")]
|
|
||||||
public void Revoke_AlreadyRevokedToken_UpdatesRevokedAt()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var refreshToken = RefreshToken.Create(1, "test-token");
|
|
||||||
refreshToken.Revoke();
|
|
||||||
System.Threading.Thread.Sleep(10); // Small delay
|
|
||||||
var firstRevokedAt = refreshToken.RevokedAt;
|
|
||||||
|
|
||||||
// Act
|
|
||||||
refreshToken.Revoke();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsFalse(refreshToken.IsActive);
|
|
||||||
Assert.IsNotNull(refreshToken.RevokedAt);
|
|
||||||
Assert.IsTrue(refreshToken.RevokedAt >= firstRevokedAt);
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region IsExpired Method Tests
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("Query")]
|
|
||||||
public void IsExpired_NotExpiredToken_ReturnsFalse()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var refreshToken = RefreshToken.Create(1, "test-token", 7);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var isExpired = refreshToken.IsExpired();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsFalse(isExpired);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("Query")]
|
|
||||||
public void IsExpired_ExpiredToken_ReturnsTrue()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var refreshToken = RefreshToken.Create(1, "test-token");
|
|
||||||
refreshToken.ExpiresAt = DateTime.UtcNow.AddDays(-1); // Set to past
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var isExpired = refreshToken.IsExpired();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsTrue(isExpired);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("Query")]
|
|
||||||
public void IsExpired_ExactlyAtExpiryTime_ReturnsTrue()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var refreshToken = RefreshToken.Create(1, "test-token");
|
|
||||||
refreshToken.ExpiresAt = DateTime.UtcNow; // Set to exactly now
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var isExpired = refreshToken.IsExpired();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsTrue(isExpired);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("Query")]
|
|
||||||
public void IsExpired_FarFutureExpiry_ReturnsFalse()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var refreshToken = RefreshToken.Create(1, "test-token");
|
|
||||||
refreshToken.ExpiresAt = DateTime.UtcNow.AddYears(1);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var isExpired = refreshToken.IsExpired();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsFalse(isExpired);
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region IsValid Method Tests
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("Query")]
|
|
||||||
public void IsValid_ActiveNotExpiredToken_ReturnsTrue()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var refreshToken = RefreshToken.Create(1, "test-token", 7);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var isValid = refreshToken.IsValid();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsTrue(isValid);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("Query")]
|
|
||||||
public void IsValid_RevokedToken_ReturnsFalse()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var refreshToken = RefreshToken.Create(1, "test-token", 7);
|
|
||||||
refreshToken.Revoke();
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var isValid = refreshToken.IsValid();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsFalse(isValid);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("Query")]
|
|
||||||
public void IsValid_ExpiredToken_ReturnsFalse()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var refreshToken = RefreshToken.Create(1, "test-token");
|
|
||||||
refreshToken.ExpiresAt = DateTime.UtcNow.AddDays(-1);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var isValid = refreshToken.IsValid();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsFalse(isValid);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("Query")]
|
|
||||||
public void IsValid_RevokedAndExpiredToken_ReturnsFalse()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var refreshToken = RefreshToken.Create(1, "test-token");
|
|
||||||
refreshToken.Revoke();
|
|
||||||
refreshToken.ExpiresAt = DateTime.UtcNow.AddDays(-1);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var isValid = refreshToken.IsValid();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsFalse(isValid);
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Property Tests
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("Property")]
|
|
||||||
public void Id_HasDefaultValueOfZero()
|
|
||||||
{
|
|
||||||
// Arrange & Act
|
|
||||||
var refreshToken = RefreshToken.Create(1, "test-token");
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.AreEqual(0, refreshToken.Id);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("Property")]
|
|
||||||
public void UserId_IsSetCorrectly()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
int userId = 42;
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var refreshToken = RefreshToken.Create(userId, "test-token");
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.AreEqual(userId, refreshToken.UserId);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("Property")]
|
|
||||||
public void Token_IsSetCorrectly()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
string tokenString = "test-token-12345";
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var refreshToken = RefreshToken.Create(1, tokenString);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.AreEqual(tokenString, refreshToken.Token);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("Property")]
|
|
||||||
public void IsActive_HasDefaultValueOfTrue()
|
|
||||||
{
|
|
||||||
// Arrange & Act
|
|
||||||
var refreshToken = RefreshToken.Create(1, "test-token");
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsTrue(refreshToken.IsActive);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("Property")]
|
|
||||||
public void RevokedAt_IsNullByDefault()
|
|
||||||
{
|
|
||||||
// Arrange & Act
|
|
||||||
var refreshToken = RefreshToken.Create(1, "test-token");
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsNull(refreshToken.RevokedAt);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("Property")]
|
|
||||||
public void CreatedAt_IsSetToCurrentTime()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var beforeCreation = DateTime.UtcNow;
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var refreshToken = RefreshToken.Create(1, "test-token");
|
|
||||||
var afterCreation = DateTime.UtcNow;
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsTrue(refreshToken.CreatedAt >= beforeCreation);
|
|
||||||
Assert.IsTrue(refreshToken.CreatedAt <= afterCreation);
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
}
|
|
||||||
|
|
@ -1,428 +0,0 @@
|
||||||
using System;
|
|
||||||
using GermanApp.Domain.Entities;
|
|
||||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
|
||||||
|
|
||||||
namespace GermanApp.Tests.Unit.Domain.Entities;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Unit tests for User domain entity.
|
|
||||||
/// Tests the user factory methods and business logic.
|
|
||||||
/// </summary>
|
|
||||||
[TestClass]
|
|
||||||
public class UserTests
|
|
||||||
{
|
|
||||||
#region Factory Method Tests
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("Factory")]
|
|
||||||
public void Create_WithValidParameters_ReturnsUser()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
string username = "testuser";
|
|
||||||
string email = "test@example.com";
|
|
||||||
string passwordHash = "hashed-password";
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var user = User.Create(username, email, passwordHash);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsNotNull(user);
|
|
||||||
Assert.AreEqual(username, user.Username);
|
|
||||||
Assert.AreEqual(email, user.Email);
|
|
||||||
Assert.AreEqual(passwordHash, user.PasswordHash);
|
|
||||||
Assert.AreEqual("A1", user.CurrentLevel);
|
|
||||||
Assert.AreEqual(0, user.Streak);
|
|
||||||
Assert.AreEqual(0, user.TotalPoints);
|
|
||||||
Assert.IsNotNull(user.CreatedAt);
|
|
||||||
Assert.IsTrue(user.CreatedAt <= DateTime.UtcNow);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("Factory")]
|
|
||||||
public void Create_WithEmptyPasswordHash_ReturnsUser()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
string username = "testuser";
|
|
||||||
string email = "test@example.com";
|
|
||||||
string passwordHash = "";
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var user = User.Create(username, email, passwordHash);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsNotNull(user);
|
|
||||||
Assert.AreEqual(passwordHash, user.PasswordHash);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("Factory")]
|
|
||||||
public void Create_LowercasesEmail()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
string username = "testuser";
|
|
||||||
string email = "TEST@EXAMPLE.COM";
|
|
||||||
string passwordHash = "hashed-password";
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var user = User.Create(username, email, passwordHash);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.AreEqual("test@example.com", user.Email);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("Factory")]
|
|
||||||
public void Create_WithMixedCaseUsername_PreservesUsernameCase()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
string username = "TestUser123";
|
|
||||||
string email = "test@example.com";
|
|
||||||
string passwordHash = "hashed-password";
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var user = User.Create(username, email, passwordHash);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.AreEqual(username, user.Username);
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region ChangePassword Method Tests
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("Behavior")]
|
|
||||||
public void ChangePassword_WithValidHash_UpdatesPassword()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var user = User.Create("testuser", "test@example.com", "initial-hash");
|
|
||||||
string newHash = "new-hashed-password";
|
|
||||||
|
|
||||||
// Act
|
|
||||||
user.ChangePassword(newHash);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.AreEqual(newHash, user.PasswordHash);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("Behavior")]
|
|
||||||
public void ChangePassword_WithEmptyHash_UpdatesPassword()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var user = User.Create("testuser", "test@example.com", "initial-hash");
|
|
||||||
|
|
||||||
// Act
|
|
||||||
user.ChangePassword("");
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.AreEqual("", user.PasswordHash);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("Behavior")]
|
|
||||||
public void ChangePassword_MultipleTimes_UpdatesCorrectly()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var user = User.Create("testuser", "test@example.com", "hash1");
|
|
||||||
|
|
||||||
// Act
|
|
||||||
user.ChangePassword("hash2");
|
|
||||||
user.ChangePassword("hash3");
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.AreEqual("hash3", user.PasswordHash);
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region ChangeEmail Method Tests
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("Behavior")]
|
|
||||||
public void ChangeEmail_WithValidEmail_UpdatesEmail()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var user = User.Create("testuser", "test@example.com", "hash");
|
|
||||||
var newEmail = "new@example.com";
|
|
||||||
|
|
||||||
// Act
|
|
||||||
user.ChangeEmail(newEmail);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.AreEqual("new@example.com", user.Email);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("Behavior")]
|
|
||||||
public void ChangeEmail_LowercasesEmail()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var user = User.Create("testuser", "test@example.com", "hash");
|
|
||||||
|
|
||||||
// Act
|
|
||||||
user.ChangeEmail("UPPERCASE@EXAMPLE.COM");
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.AreEqual("uppercase@example.com", user.Email);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("Behavior")]
|
|
||||||
public void ChangeEmail_WithMixedCase_Values()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var user = User.Create("testuser", "test@example.com", "hash");
|
|
||||||
|
|
||||||
// Act
|
|
||||||
user.ChangeEmail("TeSt@ExAmPlE.cOm");
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.AreEqual("test@example.com", user.Email);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("Behavior")]
|
|
||||||
public void ChangeEmail_WithNullEmail_UpdatesEmailToEmpty()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var user = User.Create("testuser", "test@example.com", "hash");
|
|
||||||
|
|
||||||
// Act - Note: This won't throw, it will just set to empty string
|
|
||||||
user.ChangeEmail(null!);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.AreEqual("", user.Email);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("Behavior")]
|
|
||||||
public void ChangeEmail_WithEmptyString_SetsEmptyEmail()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var user = User.Create("testuser", "test@example.com", "hash");
|
|
||||||
|
|
||||||
// Act
|
|
||||||
user.ChangeEmail("");
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.AreEqual("", user.Email);
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Gamification Methods Tests
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("Gamification")]
|
|
||||||
public void AddPoints_IncreasesTotalPoints()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var user = User.Create("testuser", "test@example.com", "hash");
|
|
||||||
int initialPoints = user.TotalPoints;
|
|
||||||
|
|
||||||
// Act
|
|
||||||
user.AddPoints(10);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.AreEqual(initialPoints + 10, user.TotalPoints);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("Gamification")]
|
|
||||||
public void AddPoints_MultipleTimes_AccumulatesCorrectly()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var user = User.Create("testuser", "test@example.com", "hash");
|
|
||||||
|
|
||||||
// Act
|
|
||||||
user.AddPoints(10);
|
|
||||||
user.AddPoints(20);
|
|
||||||
user.AddPoints(30);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.AreEqual(60, user.TotalPoints);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("Gamification")]
|
|
||||||
public void AddPoints_WithNegativePoints_DecreasesTotal()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var user = User.Create("testuser", "test@example.com", "hash");
|
|
||||||
user.AddPoints(100);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
user.AddPoints(-10);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.AreEqual(90, user.TotalPoints);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("Gamification")]
|
|
||||||
public void UpdateStreak_SetsNewStreak()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var user = User.Create("testuser", "test@example.com", "hash");
|
|
||||||
|
|
||||||
// Act
|
|
||||||
user.UpdateStreak(5);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.AreEqual(5, user.Streak);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("Gamification")]
|
|
||||||
public void UpdateStreak_WithZero_ResetsStreak()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var user = User.Create("testuser", "test@example.com", "hash");
|
|
||||||
user.UpdateStreak(10);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
user.UpdateStreak(0);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.AreEqual(0, user.Streak);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("Gamification")]
|
|
||||||
public void UpdateLevel_SetsNewLevel()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var user = User.Create("testuser", "test@example.com", "hash");
|
|
||||||
|
|
||||||
// Act
|
|
||||||
user.UpdateLevel("B1");
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.AreEqual("B1", user.CurrentLevel);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("Gamification")]
|
|
||||||
public void UpdateLevel_ToHigherLevel_UpdatesCorrectly()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var user = User.Create("testuser", "test@example.com", "hash");
|
|
||||||
|
|
||||||
// Act
|
|
||||||
user.UpdateLevel("A2");
|
|
||||||
user.UpdateLevel("B1");
|
|
||||||
user.UpdateLevel("C1");
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.AreEqual("C1", user.CurrentLevel);
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Property Tests
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("Property")]
|
|
||||||
public void Id_HasDefaultValueOfZero()
|
|
||||||
{
|
|
||||||
// Arrange & Act
|
|
||||||
var user = User.Create("testuser", "test@example.com", "hash");
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.AreEqual(0, user.Id);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("Property")]
|
|
||||||
public void Username_HasPrivateSetter()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var username = "testuser";
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var user = User.Create(username, "test@example.com", "hash");
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.AreEqual(username, user.Username);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("Property")]
|
|
||||||
public void Email_HasPrivateSetter()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var email = "test@example.com";
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var user = User.Create("testuser", email, "hash");
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.AreEqual(email, user.Email);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("Property")]
|
|
||||||
public void PasswordHash_HasPrivateSetter()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var passwordHash = "hashed-password-123";
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var user = User.Create("testuser", "test@example.com", passwordHash);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.AreEqual(passwordHash, user.PasswordHash);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("Property")]
|
|
||||||
public void CurrentLevel_HasDefaultValueOf_A1()
|
|
||||||
{
|
|
||||||
// Arrange & Act
|
|
||||||
var user = User.Create("testuser", "test@example.com", "hash");
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.AreEqual("A1", user.CurrentLevel);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("Property")]
|
|
||||||
public void Streak_HasDefaultValueOfZero()
|
|
||||||
{
|
|
||||||
// Arrange & Act
|
|
||||||
var user = User.Create("testuser", "test@example.com", "hash");
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.AreEqual(0, user.Streak);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("Property")]
|
|
||||||
public void TotalPoints_HasDefaultValueOfZero()
|
|
||||||
{
|
|
||||||
// Arrange & Act
|
|
||||||
var user = User.Create("testuser", "test@example.com", "hash");
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.AreEqual(0, user.TotalPoints);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
|
||||||
[TestCategory("Property")]
|
|
||||||
public void CreatedAt_IsSetToCurrentTime()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var beforeCreation = DateTime.UtcNow;
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var user = User.Create("testuser", "test@example.com", "hash");
|
|
||||||
var afterCreation = DateTime.UtcNow;
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsTrue(user.CreatedAt >= beforeCreation);
|
|
||||||
Assert.IsTrue(user.CreatedAt <= afterCreation);
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
}
|
|
||||||
|
|
@ -34,8 +34,8 @@ Establish the technical foundation for the entire application, including backend
|
||||||
### Features
|
### Features
|
||||||
| # | Feature | Description | Hours | Status | Dependencies |
|
| # | Feature | Description | Hours | Status | Dependencies |
|
||||||
|---|---------|-------------|-------|--------|--------------|
|
|---|---------|-------------|-------|--------|--------------|
|
||||||
| 1.1 | [Infrastructure Setup](features/infrastructure-setup.md) | .NET project, PostgreSQL, Docker, CI/CD | 10-14h | ✅ Complete | None |
|
| 1.1 | [Infrastructure Setup](features/infrastructure-setup.md) | .NET project, PostgreSQL, Docker, CI/CD | 10-14h | ⏳ Planned | None |
|
||||||
| 1.2 | [User Authentication](features/user-authentication.md) | JWT-based auth with ASP.NET Core Identity | 4-6h | ✅ Complete | 1.1 |
|
| 1.2 | [User Authentication](features/user-authentication.md) | JWT-based auth with ASP.NET Core Identity | 4-6h | ⏳ Planned | 1.1 |
|
||||||
|
|
||||||
### Deliverables
|
### Deliverables
|
||||||
- ✅ Working .NET 9.0 backend project
|
- ✅ Working .NET 9.0 backend project
|
||||||
|
|
@ -265,11 +265,11 @@ Implement the complete React + TypeScript frontend application with all UI compo
|
||||||
|
|
||||||
| Phase | Duration | Hours | Features | Status |
|
| Phase | Duration | Hours | Features | Status |
|
||||||
|-------|----------|-------|----------|--------|
|
|-------|----------|-------|----------|--------|
|
||||||
| Phase 1: Foundation | 2 weeks | 30-42h | 2 | 🚀 In Progress (1.1 ✅, 1.2 🚀) |
|
| Phase 1: Foundation | 2 weeks | 30-42h | 2 | ⏳ Planned |
|
||||||
| Phase 2: Core Backend | 2 weeks | 42-58h | 4 | ⏳ Planned |
|
| Phase 2: Core Backend | 2 weeks | 42-58h | 4 | ⏳ Planned |
|
||||||
| Phase 3: Content & Features | 2 weeks | 30-42h | 2 | ⏳ Planned |
|
| Phase 3: Content & Features | 2 weeks | 30-42h | 2 | ⏳ Planned |
|
||||||
| Phase 4: Frontend | 2 weeks | 10-16h | 1 | ⏳ Planned |
|
| Phase 4: Frontend | 2 weeks | 10-16h | 1 | ⏳ Planned |
|
||||||
| **Total** | **8 weeks** | **112-158h** | **9** | 🚀 In Progress |
|
| **Total** | **8 weeks** | **112-158h** | **9** | ⏳ Planned |
|
||||||
|
|
||||||
**For a small team (2-3 developers):** ~4-5 weeks
|
**For a small team (2-3 developers):** ~4-5 weeks
|
||||||
**For a solo developer:** ~8-10 weeks
|
**For a solo developer:** ~8-10 weeks
|
||||||
|
|
@ -328,20 +328,18 @@ Week 9-10: Testing, Polish, Bug Fixes (20h)
|
||||||
|
|
||||||
### Milestone 1: Foundation Complete (End of Week 2)
|
### Milestone 1: Foundation Complete (End of Week 2)
|
||||||
**Success Metrics:**
|
**Success Metrics:**
|
||||||
- [x] Backend project builds and runs
|
- [ ] Backend project builds and runs
|
||||||
- [x] Database is configured and accessible
|
- [ ] Database is configured and accessible
|
||||||
- [x] Docker containers work
|
- [ ] Docker containers work
|
||||||
- [ ] CI/CD pipeline passes (deferred per user request)
|
- [ ] CI/CD pipeline passes
|
||||||
- [x] Authentication works end-to-end (JWT with refresh tokens)
|
- [ ] Authentication works end-to-end
|
||||||
- [x] Can start any Phase 2 feature
|
- [ ] Can start any Phase 2 feature
|
||||||
|
|
||||||
**Exit Criteria:**
|
**Exit Criteria:**
|
||||||
- All Phase 1 acceptance criteria met
|
- All Phase 1 acceptance criteria met
|
||||||
- All Phase 1 tests passing (tests to be written)
|
- All Phase 1 tests passing
|
||||||
- All Phase 1 documentation complete
|
- All Phase 1 documentation complete
|
||||||
|
|
||||||
**Status:** ~80% Complete - Infrastructure Setup ✅, User Authentication 🚀 In Progress (refresh tokens implemented)
|
|
||||||
|
|
||||||
### Milestone 2: Core Backend Complete (End of Week 4)
|
### Milestone 2: Core Backend Complete (End of Week 4)
|
||||||
**Success Metrics:**
|
**Success Metrics:**
|
||||||
- [ ] Lesson management works
|
- [ ] Lesson management works
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Feature: Infrastructure Setup
|
# Feature: Infrastructure Setup
|
||||||
|
|
||||||
> **Status**: ✅ Complete
|
> **Status**: 🚀 In Progress
|
||||||
> **Priority**: High
|
> **Priority**: High
|
||||||
> **Complexity**: Medium
|
> **Complexity**: Medium
|
||||||
> **Estimate**: 10-14 hours
|
> **Estimate**: 10-14 hours
|
||||||
|
|
@ -22,8 +22,8 @@ As a developer, I want to have a working backend and database setup so that I ca
|
||||||
### Acceptance Criteria
|
### Acceptance Criteria
|
||||||
- [x] .NET 9.0 backend project is created and builds successfully
|
- [x] .NET 9.0 backend project is created and builds successfully
|
||||||
- [x] PostgreSQL database is configured and accessible
|
- [x] PostgreSQL database is configured and accessible
|
||||||
- [x] Docker setup is ready for deployment (docker-compose.yml, Dockerfiles)
|
- [ ] Docker setup is ready for deployment
|
||||||
- [x] CI/CD pipeline is configured (.woodpecker.yml for Woodpecker CI)
|
- [ ] CI/CD pipeline is configured
|
||||||
- [x] Development environment is reproducible
|
- [x] Development environment is reproducible
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -109,11 +109,11 @@ As a developer, I want to have a working backend and database setup so that I ca
|
||||||
- [x] Test Docker build and run
|
- [x] Test Docker build and run
|
||||||
|
|
||||||
### Phase 4: CI/CD Pipeline (2-4 hours)
|
### Phase 4: CI/CD Pipeline (2-4 hours)
|
||||||
- [x] Create Woodpecker CI pipeline (.woodpecker.yml)
|
- [ ] Create GitHub Actions workflow for backend
|
||||||
- [x] Configure build, test, and deploy steps for self-hosted Woodpecker
|
- [ ] Configure build, test, and deploy steps
|
||||||
- [x] Set up environment secrets (documented, requires Woodpecker UI setup)
|
- [ ] Set up environment secrets
|
||||||
- [x] Configure branch triggers for main and feature branches
|
- [ ] Configure branch protection rules
|
||||||
- [x] Test CI/CD pipeline (configuration created and validated)
|
- [ ] Test CI/CD pipeline
|
||||||
|
|
||||||
### Milestones
|
### Milestones
|
||||||
| Milestone | Date | Status |
|
| Milestone | Date | Status |
|
||||||
|
|
@ -121,7 +121,7 @@ As a developer, I want to have a working backend and database setup so that I ca
|
||||||
| Backend Project Created | 2025-05-31 | ✅ |
|
| Backend Project Created | 2025-05-31 | ✅ |
|
||||||
| Database Configured | 2025-05-31 | ✅ |
|
| Database Configured | 2025-05-31 | ✅ |
|
||||||
| Docker Setup Complete | 2025-06-05 | ✅ |
|
| Docker Setup Complete | 2025-06-05 | ✅ |
|
||||||
| CI/CD Pipeline Working | 2025-06-05 | ✅ |
|
| CI/CD Pipeline Working | - | ⏳ |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Feature: User Authentication & Authorization
|
# Feature: User Authentication & Authorization
|
||||||
|
|
||||||
> **Status**: 🚀 In Progress (90% Complete)
|
> **Status**: 🚀 In Progress
|
||||||
> **Priority**: High
|
> **Priority**: High
|
||||||
> **Complexity**: Medium
|
> **Complexity**: Medium
|
||||||
> **Estimate**: 4-6 hours
|
> **Estimate**: 4-6 hours
|
||||||
|
|
@ -43,7 +43,6 @@ As a user, I want to register, login, and access my personalized learning conten
|
||||||
| FR-005 | Current user endpoint | Medium |
|
| FR-005 | Current user endpoint | Medium |
|
||||||
| FR-006 | Password reset functionality | Low |
|
| FR-006 | Password reset functionality | Low |
|
||||||
| FR-007 | Email verification (optional for MVP) | Low |
|
| FR-007 | Email verification (optional for MVP) | Low |
|
||||||
| FR-008 | Token refresh mechanism | High |
|
|
||||||
|
|
||||||
### Non-Functional Requirements
|
### Non-Functional Requirements
|
||||||
- Security: Passwords hashed with bcrypt or similar
|
- Security: Passwords hashed with bcrypt or similar
|
||||||
|
|
@ -92,8 +91,7 @@ Protected Endpoint:
|
||||||
| `/api/auth/login` | POST | Login existing user | No |
|
| `/api/auth/login` | POST | Login existing user | No |
|
||||||
| `/api/auth/me` | GET | Get current user info | Yes |
|
| `/api/auth/me` | GET | Get current user info | Yes |
|
||||||
| `/api/auth/logout` | POST | Invalidate token | Yes |
|
| `/api/auth/logout` | POST | Invalidate token | Yes |
|
||||||
| `/api/auth/refresh` | POST | Refresh expired token | No |
|
| `/api/auth/refresh` | POST | Refresh expired token | Yes |
|
||||||
| `/api/auth/revoke-refresh` | POST | Revoke a refresh token | Yes |
|
|
||||||
|
|
||||||
### Database Schema (from application-plan.md)
|
### Database Schema (from application-plan.md)
|
||||||
```sql
|
```sql
|
||||||
|
|
@ -134,9 +132,9 @@ CREATE TABLE Users (
|
||||||
### Phase 3: Token Management (1 hour)
|
### Phase 3: Token Management (1 hour)
|
||||||
- [x] Configure JWT settings in appsettings.json
|
- [x] Configure JWT settings in appsettings.json
|
||||||
- [x] Implement token validation middleware (via AddJwtBearer)
|
- [x] Implement token validation middleware (via AddJwtBearer)
|
||||||
- [x] Add token refresh mechanism (with RefreshToken entity, AuthService methods, AuthController endpoints)
|
- [ ] Add token refresh mechanism
|
||||||
- [x] Set up token expiration (24 hours)
|
- [x] Set up token expiration (24 hours)
|
||||||
- [x] Configure refresh token rotation (7-day refresh tokens, rotated on refresh)
|
- [ ] Configure refresh token rotation
|
||||||
|
|
||||||
### Phase 4: Frontend Integration (Optional - if doing full stack)
|
### Phase 4: Frontend Integration (Optional - if doing full stack)
|
||||||
- [ ] Create auth service in React
|
- [ ] Create auth service in React
|
||||||
|
|
@ -159,20 +157,18 @@ CREATE TABLE Users (
|
||||||
|
|
||||||
### Backend
|
### Backend
|
||||||
- [x] Create Models/User.cs with properties
|
- [x] Create Models/User.cs with properties
|
||||||
- [x] Create Domain/Entities/User.cs with properties
|
|
||||||
- [x] Create DTOs/Auth/RegisterDto.cs
|
- [x] Create DTOs/Auth/RegisterDto.cs
|
||||||
- [x] Create DTOs/Auth/LoginDto.cs
|
- [x] Create DTOs/Auth/LoginDto.cs
|
||||||
- [x] Create DTOs/Auth/AuthResponse.cs
|
- [x] Create DTOs/Auth/AuthResponse.cs
|
||||||
- [x] Create DTOs/Auth/RefreshTokenResponse.cs
|
- [x] Create Interfaces/IAuthService.cs
|
||||||
- [x] Create Domain/Entities/RefreshToken.cs
|
- [x] Create Services/AuthService.cs
|
||||||
- [x] Create Interfaces/IAuthService.cs (with RefreshTokenAsync, RevokeRefreshTokenAsync)
|
- [x] Create Controllers/AuthController.cs
|
||||||
- [x] Create Services/AuthService.cs (with JWT generation, refresh token methods)
|
|
||||||
- [x] Create Controllers/AuthController.cs (with /refresh, /revoke-refresh endpoints)
|
|
||||||
- [x] Configure JWT in Program.cs
|
- [x] Configure JWT in Program.cs
|
||||||
- [x] Add [Authorize] attribute to protected endpoints (LessonsEndpoints)
|
- [x] Add [Authorize] attribute to protected endpoints (LessonsEndpoints)
|
||||||
|
- [ ] Create AuthMiddleware.cs
|
||||||
- [x] Configure CORS policy
|
- [x] Configure CORS policy
|
||||||
- [x] Write unit tests for AuthService and Domain Entities (46 tests passing)
|
- [ ] Write unit tests for AuthService
|
||||||
- [x] Write integration tests for AuthController (59 tests passing)
|
- [ ] Write integration tests for AuthController
|
||||||
|
|
||||||
### Database
|
### Database
|
||||||
- [x] Update User entity mapping (in AppDbContext)
|
- [x] Update User entity mapping (in AppDbContext)
|
||||||
|
|
@ -185,12 +181,8 @@ CREATE TABLE Users (
|
||||||
- [x] Configure JWT settings (in appsettings.json)
|
- [x] Configure JWT settings (in appsettings.json)
|
||||||
- [x] Implement token generation (in AuthService)
|
- [x] Implement token generation (in AuthService)
|
||||||
- [x] Implement token validation (via AddJwtBearer)
|
- [x] Implement token validation (via AddJwtBearer)
|
||||||
- [x] Implement token refresh (RefreshTokenAsync, RevokeRefreshTokenAsync in AuthService)
|
- [ ] Implement token refresh
|
||||||
- [x] Create RefreshToken entity with factory methods (Create, Revoke, IsExpired, IsValid)
|
- [x] Set token expiration (24 hours)
|
||||||
- [x] Add refresh token storage in database (AddRefreshTokensTable migration)
|
|
||||||
- [x] Add /api/auth/refresh endpoint for token rotation
|
|
||||||
- [x] Add /api/auth/revoke-refresh endpoint for token revocation
|
|
||||||
- [x] Set token expiration (24 hours access token, 7 days refresh token)
|
|
||||||
|
|
||||||
### Frontend (Optional)
|
### Frontend (Optional)
|
||||||
- [ ] Create authService.ts
|
- [ ] Create authService.ts
|
||||||
|
|
@ -322,15 +314,7 @@ CREATE TABLE Users (
|
||||||
| Jun 05, 2025 | Status: Planned → In Progress | Started implementation |
|
| Jun 05, 2025 | Status: Planned → In Progress | Started implementation |
|
||||||
| Jun 05, 2025 | Backend Auth Complete | DTOs, AuthService, AuthController, JWT configured |
|
| Jun 05, 2025 | Backend Auth Complete | DTOs, AuthService, AuthController, JWT configured |
|
||||||
| Jun 05, 2025 | Database Integration Complete | User entity, password hashing, seed data |
|
| Jun 05, 2025 | Database Integration Complete | User entity, password hashing, seed data |
|
||||||
| Jun 05, 2025 | Token Management Complete | JWT settings, token generation/validation, refresh token mechanism |
|
| Jun 05, 2025 | Token Management Complete | JWT settings, token generation/validation |
|
||||||
| Jun 05, 2025 | Refresh Token Implementation Complete | RefreshToken entity, AuthService methods, AuthController endpoints, migration created |
|
|
||||||
|
|
||||||
**Remaining Tasks:**
|
|
||||||
- [ ] Write integration tests for AuthController (See Tests/TODO.md for detailed test cases)
|
|
||||||
|
|
||||||
**Note:** Unit tests for Domain Entities (User, RefreshToken) and integration tests for AuthController are complete and passing (105 tests total).
|
|
||||||
|
|
||||||
**Note on Integration Tests:** Tests are implemented as controller tests with mocked services. Full HTTP pipeline integration tests would require WebApplicationFactory which needs Program class access in .NET 6+ minimal APIs.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<configuration>
|
|
||||||
<packageSources>
|
|
||||||
<!--To inherit the global NuGet package sources remove the <clear/> line below -->
|
|
||||||
<clear />
|
|
||||||
<add key="nuget" value="https://api.nuget.org/v3/index.json" />
|
|
||||||
</packageSources>
|
|
||||||
</configuration>
|
|
||||||
Loading…
Add table
Reference in a new issue