commit 0543120f3ba76195804e5d897017accea0cc00db Author: ddodovic Date: Sun Mar 15 23:17:51 2026 +0100 Add initial implementation of API, database, and user management components. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..add57be --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +bin/ +obj/ +/packages/ +riderModule.iml +/_ReSharper.Caches/ \ No newline at end of file diff --git a/.idea/.idea.MikrocopTest/.idea/.gitignore b/.idea/.idea.MikrocopTest/.idea/.gitignore new file mode 100644 index 0000000..6d72feb --- /dev/null +++ b/.idea/.idea.MikrocopTest/.idea/.gitignore @@ -0,0 +1,15 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Rider ignored files +/contentModel.xml +/modules.xml +/projectSettingsUpdater.xml +/.idea.MikrocopTest.iml +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/.idea/.idea.MikrocopTest/.idea/encodings.xml b/.idea/.idea.MikrocopTest/.idea/encodings.xml new file mode 100644 index 0000000..df87cf9 --- /dev/null +++ b/.idea/.idea.MikrocopTest/.idea/encodings.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/.idea.MikrocopTest/.idea/indexLayout.xml b/.idea/.idea.MikrocopTest/.idea/indexLayout.xml new file mode 100644 index 0000000..7b08163 --- /dev/null +++ b/.idea/.idea.MikrocopTest/.idea/indexLayout.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/.idea.MikrocopTest/.idea/vcs.xml b/.idea/.idea.MikrocopTest/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/.idea.MikrocopTest/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/MikrocopApi/Configuration/JwtOptions.cs b/MikrocopApi/Configuration/JwtOptions.cs new file mode 100644 index 0000000..e9d8e32 --- /dev/null +++ b/MikrocopApi/Configuration/JwtOptions.cs @@ -0,0 +1,9 @@ +namespace MikrocopApi.Configuration; + +public sealed class JwtOptions +{ + public string Issuer { get; set; } = string.Empty; + public string Audience { get; set; } = string.Empty; + public string SigningKey { get; set; } = string.Empty; + public int ExpirationMinutes { get; set; } = 60; +} diff --git a/MikrocopApi/Controllers/AuthController.cs b/MikrocopApi/Controllers/AuthController.cs new file mode 100644 index 0000000..312368c --- /dev/null +++ b/MikrocopApi/Controllers/AuthController.cs @@ -0,0 +1,42 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using MikrocopApi.Dtos; +using MikrocopApi.Services; + +namespace MikrocopApi.Controllers; + +[ApiController] +[Route("api/auth")] +public sealed class AuthController : ControllerBase +{ + private readonly IAuthService _authService; + private readonly IUserService _userService; + + public AuthController(IAuthService authService, IUserService userService) + { + _authService = authService; + _userService = userService; + } + + [AllowAnonymous] + [HttpPost("register")] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task Register([FromBody] CreateUserDto request, CancellationToken cancellationToken) + { + var user = await _userService.CreateAsync(request, cancellationToken); + return Created($"/api/users/{user.Id}", user); + } + + [AllowAnonymous] + [HttpPost("login")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task Login([FromBody] LoginRequestDto request, CancellationToken cancellationToken) + { + var response = await _authService.LoginAsync(request, cancellationToken); + return Ok(response); + } +} diff --git a/MikrocopApi/Controllers/UsersController.cs b/MikrocopApi/Controllers/UsersController.cs new file mode 100644 index 0000000..5591888 --- /dev/null +++ b/MikrocopApi/Controllers/UsersController.cs @@ -0,0 +1,69 @@ +using MikrocopApi.Dtos; +using MikrocopApi.Mappers; +using MikrocopApi.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace MikrocopApi.Controllers; + +[ApiController] +[Authorize] +[Route("api/users")] +public sealed class UsersController : ControllerBase +{ + private readonly IUserService _userService; + + public UsersController(IUserService userService) + { + _userService = userService; + } + + [HttpPost] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task Create([FromBody] CreateUserDto request, CancellationToken cancellationToken) + { + var user = await _userService.CreateAsync(request, cancellationToken); + return CreatedAtAction(nameof(GetById), new { id = user.Id }, user); + } + + [HttpGet("{id:guid}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetById([FromRoute] Guid id, CancellationToken cancellationToken) + { + var user = await _userService.GetByIdAsync(id, cancellationToken); + return Ok(user); + } + + [HttpPut("{id:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task Update([FromRoute] Guid id, [FromBody] UpdateUserDto request, CancellationToken cancellationToken) + { + await _userService.UpdateAsync(id, request, cancellationToken); + return NoContent(); + } + + [HttpDelete("{id:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Delete([FromRoute] Guid id, CancellationToken cancellationToken) + { + await _userService.DeleteAsync(id, cancellationToken); + return NoContent(); + } + + [HttpPost("{id:guid}/validate-password")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task ValidatePassword([FromRoute] Guid id, [FromBody] ValidatePasswordRequestDto request, CancellationToken cancellationToken) + { + var isValid = await _userService.ValidatePasswordAsync(id, request.Password, cancellationToken); + return Ok(isValid.ToDto()); + } +} diff --git a/MikrocopApi/Dtos/CreateUserDto.cs b/MikrocopApi/Dtos/CreateUserDto.cs new file mode 100644 index 0000000..338afd5 --- /dev/null +++ b/MikrocopApi/Dtos/CreateUserDto.cs @@ -0,0 +1,38 @@ +using System.ComponentModel.DataAnnotations; + +namespace MikrocopApi.Dtos; + +public sealed class CreateUserDto +{ + [Required] + [StringLength(100, MinimumLength = 3)] + public string UserName { get; set; } = string.Empty; + + [Required] + [StringLength(200, MinimumLength = 2)] + public string FullName { get; set; } = string.Empty; + + [Required] + [EmailAddress] + [StringLength(200)] + public string Email { get; set; } = string.Empty; + + [Required] + [StringLength(30, MinimumLength = 7)] + [RegularExpression(@"^\+?[0-9\- ]+$", ErrorMessage = "Mobile number contains invalid characters.")] + public string MobileNumber { get; set; } = string.Empty; + + [Required] + [StringLength(10, MinimumLength = 2)] + [RegularExpression(@"^[a-zA-Z]{2,10}$", ErrorMessage = "Language must contain only letters.")] + public string Language { get; set; } = string.Empty; + + [Required] + [StringLength(20, MinimumLength = 2)] + [RegularExpression(@"^[a-zA-Z]{2,10}(-[a-zA-Z]{2,10})?$", ErrorMessage = "Culture format is invalid.")] + public string Culture { get; set; } = string.Empty; + + [Required] + [StringLength(128, MinimumLength = 8)] + public string Password { get; set; } = string.Empty; +} diff --git a/MikrocopApi/Dtos/LoginRequestDto.cs b/MikrocopApi/Dtos/LoginRequestDto.cs new file mode 100644 index 0000000..1d594eb --- /dev/null +++ b/MikrocopApi/Dtos/LoginRequestDto.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; + +namespace MikrocopApi.Dtos; + +public sealed class LoginRequestDto +{ + [Required] + [StringLength(100, MinimumLength = 3)] + public string UserName { get; set; } = string.Empty; + + [Required] + [StringLength(128, MinimumLength = 8)] + public string Password { get; set; } = string.Empty; +} diff --git a/MikrocopApi/Dtos/LoginResponseDto.cs b/MikrocopApi/Dtos/LoginResponseDto.cs new file mode 100644 index 0000000..8deba91 --- /dev/null +++ b/MikrocopApi/Dtos/LoginResponseDto.cs @@ -0,0 +1,8 @@ +namespace MikrocopApi.Dtos; + +public sealed class LoginResponseDto +{ + public required string AccessToken { get; init; } + public required DateTime ExpiresAtUtc { get; init; } + public required string TokenType { get; init; } +} diff --git a/MikrocopApi/Dtos/UpdateUserDto.cs b/MikrocopApi/Dtos/UpdateUserDto.cs new file mode 100644 index 0000000..93a219f --- /dev/null +++ b/MikrocopApi/Dtos/UpdateUserDto.cs @@ -0,0 +1,37 @@ +using System.ComponentModel.DataAnnotations; + +namespace MikrocopApi.Dtos; + +public sealed class UpdateUserDto +{ + [Required] + [StringLength(100, MinimumLength = 3)] + public string UserName { get; set; } = string.Empty; + + [Required] + [StringLength(200, MinimumLength = 2)] + public string FullName { get; set; } = string.Empty; + + [Required] + [EmailAddress] + [StringLength(200)] + public string Email { get; set; } = string.Empty; + + [Required] + [StringLength(30, MinimumLength = 7)] + [RegularExpression(@"^\+?[0-9\- ]+$", ErrorMessage = "Mobile number contains invalid characters.")] + public string MobileNumber { get; set; } = string.Empty; + + [Required] + [StringLength(10, MinimumLength = 2)] + [RegularExpression(@"^[a-zA-Z]{2,10}$", ErrorMessage = "Language must contain only letters.")] + public string Language { get; set; } = string.Empty; + + [Required] + [StringLength(20, MinimumLength = 2)] + [RegularExpression(@"^[a-zA-Z]{2,10}(-[a-zA-Z]{2,10})?$", ErrorMessage = "Culture format is invalid.")] + public string Culture { get; set; } = string.Empty; + + [StringLength(128, MinimumLength = 8)] + public string? Password { get; set; } +} diff --git a/MikrocopApi/Dtos/UserDto.cs b/MikrocopApi/Dtos/UserDto.cs new file mode 100644 index 0000000..4ba651c --- /dev/null +++ b/MikrocopApi/Dtos/UserDto.cs @@ -0,0 +1,12 @@ +namespace MikrocopApi.Dtos; + +public sealed class UserDto +{ + public Guid Id { get; init; } + public required string UserName { get; init; } + public required string FullName { get; init; } + public required string Email { get; init; } + public required string MobileNumber { get; init; } + public required string Language { get; init; } + public required string Culture { get; init; } +} diff --git a/MikrocopApi/Dtos/ValidatePasswordRequestDto.cs b/MikrocopApi/Dtos/ValidatePasswordRequestDto.cs new file mode 100644 index 0000000..b7c2cf0 --- /dev/null +++ b/MikrocopApi/Dtos/ValidatePasswordRequestDto.cs @@ -0,0 +1,10 @@ +using System.ComponentModel.DataAnnotations; + +namespace MikrocopApi.Dtos; + +public sealed class ValidatePasswordRequestDto +{ + [Required] + [StringLength(128, MinimumLength = 8)] + public string Password { get; set; } = string.Empty; +} diff --git a/MikrocopApi/Dtos/ValidatePasswordResponseDto.cs b/MikrocopApi/Dtos/ValidatePasswordResponseDto.cs new file mode 100644 index 0000000..f04436e --- /dev/null +++ b/MikrocopApi/Dtos/ValidatePasswordResponseDto.cs @@ -0,0 +1,6 @@ +namespace MikrocopApi.Dtos; + +public sealed class ValidatePasswordResponseDto +{ + public bool IsValid { get; init; } +} diff --git a/MikrocopApi/Exceptions/AppException.cs b/MikrocopApi/Exceptions/AppException.cs new file mode 100644 index 0000000..45d34de --- /dev/null +++ b/MikrocopApi/Exceptions/AppException.cs @@ -0,0 +1,11 @@ +namespace MikrocopApi.Exceptions; + +public abstract class AppException : Exception +{ + protected AppException(string message) : base(message) + { + } + + public abstract int StatusCode { get; } + public abstract string Title { get; } +} diff --git a/MikrocopApi/Exceptions/BadRequestException.cs b/MikrocopApi/Exceptions/BadRequestException.cs new file mode 100644 index 0000000..9c4a5e9 --- /dev/null +++ b/MikrocopApi/Exceptions/BadRequestException.cs @@ -0,0 +1,11 @@ +namespace MikrocopApi.Exceptions; + +public sealed class BadRequestException : AppException +{ + public BadRequestException(string message) : base(message) + { + } + + public override int StatusCode => StatusCodes.Status400BadRequest; + public override string Title => "Bad Request"; +} diff --git a/MikrocopApi/Exceptions/ConflictException.cs b/MikrocopApi/Exceptions/ConflictException.cs new file mode 100644 index 0000000..eface62 --- /dev/null +++ b/MikrocopApi/Exceptions/ConflictException.cs @@ -0,0 +1,11 @@ +namespace MikrocopApi.Exceptions; + +public sealed class ConflictException : AppException +{ + public ConflictException(string message) : base(message) + { + } + + public override int StatusCode => StatusCodes.Status409Conflict; + public override string Title => "Conflict"; +} diff --git a/MikrocopApi/Exceptions/NotFoundException.cs b/MikrocopApi/Exceptions/NotFoundException.cs new file mode 100644 index 0000000..a46070b --- /dev/null +++ b/MikrocopApi/Exceptions/NotFoundException.cs @@ -0,0 +1,11 @@ +namespace MikrocopApi.Exceptions; + +public sealed class NotFoundException : AppException +{ + public NotFoundException(string message) : base(message) + { + } + + public override int StatusCode => StatusCodes.Status404NotFound; + public override string Title => "Not Found"; +} diff --git a/MikrocopApi/Exceptions/UnauthorizedException.cs b/MikrocopApi/Exceptions/UnauthorizedException.cs new file mode 100644 index 0000000..48692bc --- /dev/null +++ b/MikrocopApi/Exceptions/UnauthorizedException.cs @@ -0,0 +1,11 @@ +namespace MikrocopApi.Exceptions; + +public sealed class UnauthorizedException : AppException +{ + public UnauthorizedException(string message) : base(message) + { + } + + public override int StatusCode => StatusCodes.Status401Unauthorized; + public override string Title => "Unauthorized"; +} diff --git a/MikrocopApi/Extensions/SwaggerExtensions.cs b/MikrocopApi/Extensions/SwaggerExtensions.cs new file mode 100644 index 0000000..08d7426 --- /dev/null +++ b/MikrocopApi/Extensions/SwaggerExtensions.cs @@ -0,0 +1,65 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.OpenApi; +using System.Collections.Generic; + +namespace MikrocopApi.Extensions; + +public static class SwaggerExtensions +{ + public static IServiceCollection AddSwaggerWithJwtAuth(this IServiceCollection services) + { + services.AddEndpointsApiExplorer(); + services.AddSwaggerGen(options => + { + const string schemeName = "Bearer"; + options.SwaggerDoc("v1", new OpenApiInfo + { + Title = "Mikrocop API", + Version = "v1" + }); + + options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme + { + Name = "Authorization", + Type = SecuritySchemeType.Http, + Scheme = "bearer", + BearerFormat = "JWT", + In = ParameterLocation.Header, + Description = "Enter 'Bearer' followed by a space and your token, e.g., 'Bearer abc123'" + }); + + + options.AddSecurityRequirement(document => new OpenApiSecurityRequirement + { + [new OpenApiSecuritySchemeReference("bearer", document)] = [] + }); + + }); + + return services; + } + + public static IApplicationBuilder UseSwaggerWithJwtAuth(this IApplicationBuilder app) + { + app.UseSwagger(options => + { + options.PreSerializeFilters.Add((swaggerDoc, _) => + { + swaggerDoc.Security = + [ + new OpenApiSecurityRequirement + { + [new OpenApiSecuritySchemeReference("Bearer", swaggerDoc, null)] = new List() + } + ]; + }); + }); + app.UseSwaggerUI(options => + { + options.SwaggerEndpoint("/swagger/v1/swagger.json", "Mikrocop API v1"); + options.EnablePersistAuthorization(); + }); + return app; + } +} diff --git a/MikrocopApi/Mappers/AuthMappingExtensions.cs b/MikrocopApi/Mappers/AuthMappingExtensions.cs new file mode 100644 index 0000000..78dbb06 --- /dev/null +++ b/MikrocopApi/Mappers/AuthMappingExtensions.cs @@ -0,0 +1,24 @@ +using MikrocopApi.Dtos; + +namespace MikrocopApi.Mappers; + +public static class AuthMappingExtensions +{ + public static LoginResponseDto ToDto(this (string AccessToken, DateTime ExpiresAtUtc) token) + { + return new LoginResponseDto + { + AccessToken = token.AccessToken, + ExpiresAtUtc = token.ExpiresAtUtc, + TokenType = "Bearer" + }; + } + + public static ValidatePasswordResponseDto ToDto(this bool isValid) + { + return new ValidatePasswordResponseDto + { + IsValid = isValid + }; + } +} diff --git a/MikrocopApi/Mappers/UserMappingExtensions.cs b/MikrocopApi/Mappers/UserMappingExtensions.cs new file mode 100644 index 0000000..3da7e21 --- /dev/null +++ b/MikrocopApi/Mappers/UserMappingExtensions.cs @@ -0,0 +1,47 @@ +using MikrocopApi.Dtos; +using MikrocopDb.Entities; + +namespace MikrocopApi.Mappers; + +public static class UserMappingExtensions +{ + public static UserEntity ToEntity(this CreateUserDto dto) + { + return new UserEntity + { + Id = Guid.NewGuid(), + UserName = dto.UserName.Trim(), + FullName = dto.FullName.Trim(), + Email = dto.Email.Trim(), + MobileNumber = dto.MobileNumber.Trim(), + Language = dto.Language.Trim(), + Culture = dto.Culture.Trim(), + PasswordHash = string.Empty, + PasswordSalt = string.Empty + }; + } + + public static void ApplyFromDto(this UserEntity entity, UpdateUserDto dto) + { + entity.UserName = dto.UserName.Trim(); + entity.FullName = dto.FullName.Trim(); + entity.Email = dto.Email.Trim(); + entity.MobileNumber = dto.MobileNumber.Trim(); + entity.Language = dto.Language.Trim(); + entity.Culture = dto.Culture.Trim(); + } + + public static UserDto ToDto(this UserEntity entity) + { + return new UserDto + { + Id = entity.Id, + UserName = entity.UserName, + FullName = entity.FullName, + Email = entity.Email, + MobileNumber = entity.MobileNumber, + Language = entity.Language, + Culture = entity.Culture + }; + } +} diff --git a/MikrocopApi/Middleware/ApiRequestLoggingMiddleware.cs b/MikrocopApi/Middleware/ApiRequestLoggingMiddleware.cs new file mode 100644 index 0000000..eb64e7a --- /dev/null +++ b/MikrocopApi/Middleware/ApiRequestLoggingMiddleware.cs @@ -0,0 +1,188 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using MikrocopApi.Exceptions; + +namespace MikrocopApi.Middleware; + +public sealed class ApiRequestLoggingMiddleware +{ + private static readonly HashSet SensitiveFields = new(StringComparer.OrdinalIgnoreCase) + { + "password", + "passwordhash", + "apikey", + "x-api-key" + }; + + private readonly RequestDelegate _next; + private readonly ILogger _logger; + + public ApiRequestLoggingMiddleware(RequestDelegate next, ILogger logger) + { + _next = next; + _logger = logger; + } + + public async Task InvokeAsync(HttpContext context) + { + var requestBody = await ExtractBodyAsync(context.Request); + + try + { + await _next(context); + var requestParameters = BuildRequestParameters(context, requestBody); + + _logger.LogInformation( + "Time={Time}; ClientIp={ClientIp}; ClientName={ClientName}; Host={Host}; ApiMethod={ApiMethod}; RequestParameters={RequestParameters}; Message={Message}", + DateTimeOffset.UtcNow, + GetClientIp(context), + GetClientName(context), + Environment.MachineName, + GetApiMethod(context), + requestParameters, + "Request completed."); + } + catch (AppException ex) + { + var requestParameters = BuildRequestParameters(context, requestBody); + _logger.LogInformation( + ex, + "Time={Time}; ClientIp={ClientIp}; ClientName={ClientName}; Host={Host}; ApiMethod={ApiMethod}; RequestParameters={RequestParameters}; Message={Message}", + DateTimeOffset.UtcNow, + GetClientIp(context), + GetClientName(context), + Environment.MachineName, + GetApiMethod(context), + requestParameters, + "Request handled with business exception."); + throw; + } + catch (Exception ex) + { + var requestParameters = BuildRequestParameters(context, requestBody); + _logger.LogError( + ex, + "Time={Time}; ClientIp={ClientIp}; ClientName={ClientName}; Host={Host}; ApiMethod={ApiMethod}; RequestParameters={RequestParameters}; Message={Message}", + DateTimeOffset.UtcNow, + GetClientIp(context), + GetClientName(context), + Environment.MachineName, + GetApiMethod(context), + requestParameters, + "Request failed."); + throw; + } + } + + private static string GetClientIp(HttpContext context) + { + return context.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + } + + private static string GetClientName(HttpContext context) + { + if (context.User.Identity?.IsAuthenticated == true) + { + return context.User.Identity.Name ?? "authenticated-user"; + } + + return "anonymous"; + } + + private static string GetApiMethod(HttpContext context) + { + return context.GetEndpoint()?.DisplayName ?? $"{context.Request.Method} {context.Request.Path}"; + } + + private static string BuildRequestParameters(HttpContext context, string? body) + { + var query = context.Request.Query.ToDictionary(kvp => kvp.Key, kvp => SanitizeValue(kvp.Key, kvp.Value.ToString())); + var route = context.Request.RouteValues.ToDictionary(kvp => kvp.Key, kvp => SanitizeValue(kvp.Key, kvp.Value?.ToString() ?? string.Empty)); + + var payload = new + { + query, + route, + body + }; + + return JsonSerializer.Serialize(payload); + } + + private static async Task ExtractBodyAsync(HttpRequest request) + { + if (request.ContentLength is not > 0) + { + return null; + } + + request.EnableBuffering(); + request.Body.Position = 0; + using var reader = new StreamReader(request.Body, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, leaveOpen: true); + var rawBody = await reader.ReadToEndAsync(); + request.Body.Position = 0; + + return SanitizeBody(rawBody); + } + + private static string SanitizeBody(string body) + { + if (string.IsNullOrWhiteSpace(body)) + { + return body; + } + + try + { + var node = JsonNode.Parse(body); + if (node is null) + { + return body; + } + + SanitizeNode(node); + return node.ToJsonString(); + } + catch + { + return body; + } + } + + private static void SanitizeNode(JsonNode node) + { + if (node is JsonObject jsonObject) + { + foreach (var kvp in jsonObject.ToList()) + { + if (SensitiveFields.Contains(kvp.Key)) + { + jsonObject[kvp.Key] = "***"; + continue; + } + + if (kvp.Value is not null) + { + SanitizeNode(kvp.Value); + } + } + } + + if (node is JsonArray jsonArray) + { + foreach (var item in jsonArray) + { + if (item is not null) + { + SanitizeNode(item); + } + } + } + } + + private static string SanitizeValue(string key, string value) + { + return SensitiveFields.Contains(key) ? "***" : value; + } +} diff --git a/MikrocopApi/Middleware/ExceptionHandlingMiddleware.cs b/MikrocopApi/Middleware/ExceptionHandlingMiddleware.cs new file mode 100644 index 0000000..a7fb962 --- /dev/null +++ b/MikrocopApi/Middleware/ExceptionHandlingMiddleware.cs @@ -0,0 +1,42 @@ +using Microsoft.AspNetCore.Mvc; +using MikrocopApi.Exceptions; + +namespace MikrocopApi.Middleware; + +public sealed class ExceptionHandlingMiddleware +{ + private readonly RequestDelegate _next; + + public ExceptionHandlingMiddleware(RequestDelegate next) + { + _next = next; + } + + public async Task InvokeAsync(HttpContext context) + { + try + { + await _next(context); + } + catch (AppException ex) + { + context.Response.StatusCode = ex.StatusCode; + await context.Response.WriteAsJsonAsync(new ProblemDetails + { + Title = ex.Title, + Detail = ex.Message, + Status = ex.StatusCode + }); + } + catch (Exception) + { + context.Response.StatusCode = StatusCodes.Status500InternalServerError; + await context.Response.WriteAsJsonAsync(new ProblemDetails + { + Title = "Internal Server Error", + Detail = "An unexpected error occurred.", + Status = StatusCodes.Status500InternalServerError + }); + } + } +} diff --git a/MikrocopApi/MikrocopApi.csproj b/MikrocopApi/MikrocopApi.csproj new file mode 100644 index 0000000..8145e5c --- /dev/null +++ b/MikrocopApi/MikrocopApi.csproj @@ -0,0 +1,23 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + + + + diff --git a/MikrocopApi/MikrocopApi.http b/MikrocopApi/MikrocopApi.http new file mode 100644 index 0000000..fc5e5f9 --- /dev/null +++ b/MikrocopApi/MikrocopApi.http @@ -0,0 +1,75 @@ +@MikrocopApi_HostAddress = http://localhost:5018 +@AccessToken = paste-jwt-here +@UserId = 00000000-0000-0000-0000-000000000000 + +POST {{MikrocopApi_HostAddress}}/api/auth/register +Content-Type: application/json + +{ + "userName": "john.doe", + "fullName": "John Doe", + "email": "john.doe@example.com", + "mobileNumber": "+15551234567", + "language": "en", + "culture": "en-US", + "password": "StrongPass123" +} + +### +POST {{MikrocopApi_HostAddress}}/api/auth/login +Content-Type: application/json + +{ + "userName": "john.doe", + "password": "StrongPass123" +} + +### +POST {{MikrocopApi_HostAddress}}/api/users +Authorization: Bearer {{AccessToken}} +Content-Type: application/json + +{ + "userName": "jane.doe", + "fullName": "Jane Doe", + "email": "jane.doe@example.com", + "mobileNumber": "+15550000001", + "language": "en", + "culture": "en-US", + "password": "StrongPass123" +} + +### +GET {{MikrocopApi_HostAddress}}/api/users/{{UserId}} +Authorization: Bearer {{AccessToken}} +Accept: application/json + +### +PUT {{MikrocopApi_HostAddress}}/api/users/{{UserId}} +Authorization: Bearer {{AccessToken}} +Content-Type: application/json + +{ + "userName": "john.doe", + "fullName": "Johnathan Doe", + "email": "john.doe@example.com", + "mobileNumber": "+15550000002", + "language": "en", + "culture": "en-US", + "password": "AnotherStrongPass123" +} + +### +POST {{MikrocopApi_HostAddress}}/api/users/{{UserId}}/validate-password +Authorization: Bearer {{AccessToken}} +Content-Type: application/json + +{ + "password": "AnotherStrongPass123" +} + +### +DELETE {{MikrocopApi_HostAddress}}/api/users/{{UserId}} +Authorization: Bearer {{AccessToken}} + +### diff --git a/MikrocopApi/Program.cs b/MikrocopApi/Program.cs new file mode 100644 index 0000000..7a97d52 --- /dev/null +++ b/MikrocopApi/Program.cs @@ -0,0 +1,108 @@ +using System.Text; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; +using MikrocopApi.Configuration; +using MikrocopApi.Extensions; +using MikrocopApi.Middleware; +using MikrocopApi.Services; +using MikrocopDb; +using MikrocopDb.Repositories; +using Serilog; +using Serilog.Formatting.Json; + +var builder = WebApplication.CreateBuilder(args); + +var loggerConfiguration = new LoggerConfiguration() + .ReadFrom.Configuration(builder.Configuration) + .WriteTo.File( + formatter: new JsonFormatter(renderMessage: true), + path: "Logs/log-.json", + rollingInterval: RollingInterval.Day, + shared: true); + +if (builder.Environment.IsDevelopment()) +{ + loggerConfiguration.WriteTo.Console(); +} +else +{ + loggerConfiguration.WriteTo.Console(new JsonFormatter(renderMessage: true)); +} + +Log.Logger = loggerConfiguration.CreateLogger(); + +builder.Host.UseSerilog(); + +builder.Services.Configure(builder.Configuration.GetSection("Jwt")); +builder.Services.AddControllers(); +builder.Services.AddSwaggerWithJwtAuth(); + +var jwtOptions = builder.Configuration.GetSection("Jwt").Get() ?? new JwtOptions(); +if (string.IsNullOrWhiteSpace(jwtOptions.SigningKey) || jwtOptions.SigningKey.Length < 32) +{ + throw new InvalidOperationException("Jwt:SigningKey must be configured and at least 32 characters long."); +} + +builder.Services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = jwtOptions.Issuer, + ValidAudience = jwtOptions.Audience, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOptions.SigningKey)), + ClockSkew = TimeSpan.Zero + }; + }); + +builder.Services.AddAuthorization(); + +builder.Services.AddDbContext(options => + options.UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection"))); + +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddSingleton(); + +var app = builder.Build(); + +using (var scope = app.Services.CreateScope()) +{ + var dbContext = scope.ServiceProvider.GetRequiredService(); + if (app.Environment.IsDevelopment()) + { + await dbContext.Database.MigrateAsync(); + } + else + { + await dbContext.Database.EnsureCreatedAsync(); + } +} + +if (app.Environment.IsDevelopment()) +{ + app.UseSwaggerWithJwtAuth(); +} + +app.UseMiddleware(); +app.UseMiddleware(); + +if (!app.Environment.IsDevelopment()) +{ + app.UseHttpsRedirection(); +} + +app.UseAuthentication(); +app.UseAuthorization(); + +app.MapControllers(); + +await app.RunAsync(); diff --git a/MikrocopApi/Properties/launchSettings.json b/MikrocopApi/Properties/launchSettings.json new file mode 100644 index 0000000..4e91269 --- /dev/null +++ b/MikrocopApi/Properties/launchSettings.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5018", + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7007;http://localhost:5018", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/MikrocopApi/Services/AuthService.cs b/MikrocopApi/Services/AuthService.cs new file mode 100644 index 0000000..6a90654 --- /dev/null +++ b/MikrocopApi/Services/AuthService.cs @@ -0,0 +1,40 @@ +using MikrocopApi.Dtos; +using MikrocopApi.Exceptions; +using MikrocopApi.Mappers; +using MikrocopDb.Repositories; + +namespace MikrocopApi.Services; + +public sealed class AuthService : IAuthService +{ + private readonly IUserRepository _userRepository; + private readonly IPasswordHashingService _passwordHashingService; + private readonly IJwtTokenService _jwtTokenService; + + public AuthService( + IUserRepository userRepository, + IPasswordHashingService passwordHashingService, + IJwtTokenService jwtTokenService) + { + _userRepository = userRepository; + _passwordHashingService = passwordHashingService; + _jwtTokenService = jwtTokenService; + } + + public async Task LoginAsync(LoginRequestDto request, CancellationToken cancellationToken = default) + { + var user = await _userRepository.GetByUserNameAsync(request.UserName, cancellationToken); + if (user is null) + { + throw new UnauthorizedException("Invalid username or password."); + } + + var isValid = _passwordHashingService.VerifyPassword(request.Password, user.PasswordHash, user.PasswordSalt); + if (!isValid) + { + throw new UnauthorizedException("Invalid username or password."); + } + + return _jwtTokenService.Generate(user).ToDto(); + } +} diff --git a/MikrocopApi/Services/Interfaces/IAuthService.cs b/MikrocopApi/Services/Interfaces/IAuthService.cs new file mode 100644 index 0000000..9b65cb1 --- /dev/null +++ b/MikrocopApi/Services/Interfaces/IAuthService.cs @@ -0,0 +1,8 @@ +using MikrocopApi.Dtos; + +namespace MikrocopApi.Services; + +public interface IAuthService +{ + Task LoginAsync(LoginRequestDto request, CancellationToken cancellationToken = default); +} diff --git a/MikrocopApi/Services/Interfaces/IJwtTokenService.cs b/MikrocopApi/Services/Interfaces/IJwtTokenService.cs new file mode 100644 index 0000000..4dde605 --- /dev/null +++ b/MikrocopApi/Services/Interfaces/IJwtTokenService.cs @@ -0,0 +1,8 @@ +using MikrocopDb.Entities; + +namespace MikrocopApi.Services; + +public interface IJwtTokenService +{ + (string AccessToken, DateTime ExpiresAtUtc) Generate(UserEntity user); +} diff --git a/MikrocopApi/Services/Interfaces/IPasswordHashingService.cs b/MikrocopApi/Services/Interfaces/IPasswordHashingService.cs new file mode 100644 index 0000000..1f69419 --- /dev/null +++ b/MikrocopApi/Services/Interfaces/IPasswordHashingService.cs @@ -0,0 +1,7 @@ +namespace MikrocopApi.Services; + +public interface IPasswordHashingService +{ + (string Hash, string Salt) HashPassword(string password); + bool VerifyPassword(string password, string hashBase64, string saltBase64); +} diff --git a/MikrocopApi/Services/Interfaces/IUserService.cs b/MikrocopApi/Services/Interfaces/IUserService.cs new file mode 100644 index 0000000..33cf4d9 --- /dev/null +++ b/MikrocopApi/Services/Interfaces/IUserService.cs @@ -0,0 +1,12 @@ +using MikrocopApi.Dtos; + +namespace MikrocopApi.Services; + +public interface IUserService +{ + Task CreateAsync(CreateUserDto request, CancellationToken cancellationToken = default); + Task UpdateAsync(Guid id, UpdateUserDto request, CancellationToken cancellationToken = default); + Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); + Task DeleteAsync(Guid id, CancellationToken cancellationToken = default); + Task ValidatePasswordAsync(Guid id, string password, CancellationToken cancellationToken = default); +} diff --git a/MikrocopApi/Services/JwtTokenService.cs b/MikrocopApi/Services/JwtTokenService.cs new file mode 100644 index 0000000..8a8a881 --- /dev/null +++ b/MikrocopApi/Services/JwtTokenService.cs @@ -0,0 +1,47 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using MikrocopApi.Configuration; +using MikrocopDb.Entities; + +namespace MikrocopApi.Services; + +public sealed class JwtTokenService : IJwtTokenService +{ + private readonly JwtOptions _options; + + public JwtTokenService(IOptions options) + { + _options = options.Value; + } + + public (string AccessToken, DateTime ExpiresAtUtc) Generate(UserEntity user) + { + var now = DateTime.UtcNow; + var expires = now.AddMinutes(_options.ExpirationMinutes); + + var claims = new List + { + new(JwtRegisteredClaimNames.Sub, user.Id.ToString()), + new(JwtRegisteredClaimNames.UniqueName, user.UserName), + new(ClaimTypes.Name, user.UserName), + new(ClaimTypes.NameIdentifier, user.Id.ToString()) + }; + + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.SigningKey)); + var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var token = new JwtSecurityToken( + issuer: _options.Issuer, + audience: _options.Audience, + claims: claims, + notBefore: now, + expires: expires, + signingCredentials: creds); + + var serialized = new JwtSecurityTokenHandler().WriteToken(token); + return (serialized, expires); + } +} diff --git a/MikrocopApi/Services/PasswordHashingService.cs b/MikrocopApi/Services/PasswordHashingService.cs new file mode 100644 index 0000000..bb83b3a --- /dev/null +++ b/MikrocopApi/Services/PasswordHashingService.cs @@ -0,0 +1,37 @@ +using System.Security.Cryptography; + +namespace MikrocopApi.Services; + +public sealed class PasswordHashingService : IPasswordHashingService +{ + private const int SaltSizeBytes = 16; + private const int HashSizeBytes = 32; + private const int Iterations = 100_000; + + public (string Hash, string Salt) HashPassword(string password) + { + var salt = RandomNumberGenerator.GetBytes(SaltSizeBytes); + var hash = Rfc2898DeriveBytes.Pbkdf2(password, salt, Iterations, HashAlgorithmName.SHA256, HashSizeBytes); + + return (Convert.ToBase64String(hash), Convert.ToBase64String(salt)); + } + + public bool VerifyPassword(string password, string hashBase64, string saltBase64) + { + byte[] expectedHash; + byte[] salt; + + try + { + expectedHash = Convert.FromBase64String(hashBase64); + salt = Convert.FromBase64String(saltBase64); + } + catch (FormatException) + { + return false; + } + + var actualHash = Rfc2898DeriveBytes.Pbkdf2(password, salt, Iterations, HashAlgorithmName.SHA256, expectedHash.Length); + return CryptographicOperations.FixedTimeEquals(actualHash, expectedHash); + } +} diff --git a/MikrocopApi/Services/UserService.cs b/MikrocopApi/Services/UserService.cs new file mode 100644 index 0000000..9b7ed56 --- /dev/null +++ b/MikrocopApi/Services/UserService.cs @@ -0,0 +1,133 @@ +using MikrocopApi.Dtos; +using MikrocopApi.Exceptions; +using MikrocopApi.Mappers; +using MikrocopDb.Repositories; + +namespace MikrocopApi.Services; + +public sealed class UserService : IUserService +{ + private readonly IUserRepository _userRepository; + private readonly IPasswordHashingService _passwordHashingService; + + public UserService(IUserRepository userRepository, IPasswordHashingService passwordHashingService) + { + _userRepository = userRepository; + _passwordHashingService = passwordHashingService; + } + + public async Task CreateAsync(CreateUserDto request, CancellationToken cancellationToken = default) + { + if (await _userRepository.GetByUserNameAsync(request.UserName, cancellationToken) is not null) + { + throw new ConflictException("UserName already exists."); + } + + if (await _userRepository.GetByEmailAsync(request.Email, cancellationToken) is not null) + { + throw new ConflictException("Email already exists."); + } + + ValidatePasswordComplexity(request.Password); + + var entity = request.ToEntity(); + var (hash, salt) = _passwordHashingService.HashPassword(request.Password); + entity.PasswordHash = hash; + entity.PasswordSalt = salt; + + await _userRepository.AddAsync(entity, cancellationToken); + return entity.ToDto(); + } + + public async Task UpdateAsync(Guid id, UpdateUserDto request, CancellationToken cancellationToken = default) + { + var existing = await _userRepository.GetByIdAsync(id, cancellationToken) + ?? throw new NotFoundException($"User with id '{id}' was not found."); + + var byUserName = await _userRepository.GetByUserNameAsync(request.UserName, cancellationToken); + if (byUserName is not null && byUserName.Id != id) + { + throw new ConflictException("UserName already exists."); + } + + var byEmail = await _userRepository.GetByEmailAsync(request.Email, cancellationToken); + if (byEmail is not null && byEmail.Id != id) + { + throw new ConflictException("Email already exists."); + } + + existing.ApplyFromDto(request); + + if (!string.IsNullOrWhiteSpace(request.Password)) + { + ValidatePasswordComplexity(request.Password); + var (hash, salt) = _passwordHashingService.HashPassword(request.Password); + existing.PasswordHash = hash; + existing.PasswordSalt = salt; + } + + await _userRepository.UpdateAsync(existing, cancellationToken); + } + + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + var user = await _userRepository.GetByIdAsync(id, cancellationToken) + ?? throw new NotFoundException($"User with id '{id}' was not found."); + + return user.ToDto(); + } + + public async Task DeleteAsync(Guid id, CancellationToken cancellationToken = default) + { + var user = await _userRepository.GetByIdAsync(id, cancellationToken) + ?? throw new NotFoundException($"User with id '{id}' was not found."); + + await _userRepository.DeleteAsync(user, cancellationToken); + } + + public async Task ValidatePasswordAsync(Guid id, string password, CancellationToken cancellationToken = default) + { + var user = await _userRepository.GetByIdAsync(id, cancellationToken) + ?? throw new NotFoundException($"User with id '{id}' was not found."); + + return _passwordHashingService.VerifyPassword(password, user.PasswordHash, user.PasswordSalt); + } + + private static void ValidatePasswordComplexity(string password) + { + if (string.IsNullOrWhiteSpace(password)) + { + throw new BadRequestException("Password is required."); + } + + if (password.Length < 12) + { + throw new BadRequestException("Password must be at least 12 characters long."); + } + + if (password.Any(char.IsWhiteSpace)) + { + throw new BadRequestException("Password must not contain whitespace."); + } + + if (!password.Any(char.IsUpper)) + { + throw new BadRequestException("Password must contain at least one uppercase letter."); + } + + if (!password.Any(char.IsLower)) + { + throw new BadRequestException("Password must contain at least one lowercase letter."); + } + + if (!password.Any(char.IsDigit)) + { + throw new BadRequestException("Password must contain at least one digit."); + } + + if (!password.Any(ch => !char.IsLetterOrDigit(ch))) + { + throw new BadRequestException("Password must contain at least one special character."); + } + } +} diff --git a/MikrocopApi/appsettings.Development.json b/MikrocopApi/appsettings.Development.json new file mode 100644 index 0000000..e06df4e --- /dev/null +++ b/MikrocopApi/appsettings.Development.json @@ -0,0 +1,11 @@ +{ + "ConnectionStrings": { + "DefaultConnection": "Data Source=mikrocop.dev.db" + }, + "Jwt": { + "Issuer": "MikrocopApi", + "Audience": "MikrocopApiClients", + "SigningKey": "dev-only-very-long-secret-signing-key-1234567890", + "ExpirationMinutes": 60 + } +} diff --git a/MikrocopApi/appsettings.json b/MikrocopApi/appsettings.json new file mode 100644 index 0000000..f2e736c --- /dev/null +++ b/MikrocopApi/appsettings.json @@ -0,0 +1,22 @@ +{ + "ConnectionStrings": { + "DefaultConnection": "Data Source=mikrocop.db" + }, + "Jwt": { + "Issuer": "MikrocopApi", + "Audience": "MikrocopApiClients", + "SigningKey": "replace-with-a-long-random-secret-key-min-32chars", + "ExpirationMinutes": 60 + }, + "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Warning", + "Microsoft.AspNetCore": "Warning" + } + }, + "Enrich": [ "FromLogContext" ] + }, + "AllowedHosts": "*" +} diff --git a/MikrocopDb/AppDbContext.cs b/MikrocopDb/AppDbContext.cs new file mode 100644 index 0000000..f0c4e1d --- /dev/null +++ b/MikrocopDb/AppDbContext.cs @@ -0,0 +1,32 @@ +using Microsoft.EntityFrameworkCore; +using MikrocopDb.Entities; + +namespace MikrocopDb; + +public sealed class AppDbContext : DbContext +{ + public AppDbContext(DbContextOptions options) : base(options) + { + } + + public DbSet Users => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasKey(x => x.Id); + entity.Property(x => x.UserName).HasMaxLength(100).IsRequired(); + entity.Property(x => x.FullName).HasMaxLength(200).IsRequired(); + entity.Property(x => x.Email).HasMaxLength(200).IsRequired(); + entity.Property(x => x.MobileNumber).HasMaxLength(30).IsRequired(); + entity.Property(x => x.Language).HasMaxLength(20).IsRequired(); + entity.Property(x => x.Culture).HasMaxLength(20).IsRequired(); + entity.Property(x => x.PasswordHash).IsRequired(); + entity.Property(x => x.PasswordSalt).HasMaxLength(128).IsRequired(); + + entity.HasIndex(x => x.UserName).IsUnique(); + entity.HasIndex(x => x.Email).IsUnique(); + }); + } +} diff --git a/MikrocopDb/AppDbContextFactory.cs b/MikrocopDb/AppDbContextFactory.cs new file mode 100644 index 0000000..9ea8e2c --- /dev/null +++ b/MikrocopDb/AppDbContextFactory.cs @@ -0,0 +1,15 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace MikrocopDb; + +public sealed class AppDbContextFactory : IDesignTimeDbContextFactory +{ + public AppDbContext CreateDbContext(string[] args) + { + var optionsBuilder = new DbContextOptionsBuilder(); + optionsBuilder.UseSqlite("Data Source=mikrocop.db"); + + return new AppDbContext(optionsBuilder.Options); + } +} diff --git a/MikrocopDb/Entities/UserEntity.cs b/MikrocopDb/Entities/UserEntity.cs new file mode 100644 index 0000000..c1e6b9d --- /dev/null +++ b/MikrocopDb/Entities/UserEntity.cs @@ -0,0 +1,14 @@ +namespace MikrocopDb.Entities; + +public sealed class UserEntity +{ + public Guid Id { get; set; } + public required string UserName { get; set; } + public required string FullName { get; set; } + public required string Email { get; set; } + public required string MobileNumber { get; set; } + public required string Language { get; set; } + public required string Culture { get; set; } + public required string PasswordHash { get; set; } + public required string PasswordSalt { get; set; } +} diff --git a/MikrocopDb/Migrations/20260315212014_Initial.Designer.cs b/MikrocopDb/Migrations/20260315212014_Initial.Designer.cs new file mode 100644 index 0000000..ae3e3a4 --- /dev/null +++ b/MikrocopDb/Migrations/20260315212014_Initial.Designer.cs @@ -0,0 +1,81 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using MikrocopDb; + +#nullable disable + +namespace MikrocopDb.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260315212014_Initial")] + partial class Initial + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.5"); + + modelBuilder.Entity("MikrocopDb.Entities.UserEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Culture") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("MobileNumber") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PasswordSalt") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UserName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("UserName") + .IsUnique(); + + b.ToTable("Users"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/MikrocopDb/Migrations/20260315212014_Initial.cs b/MikrocopDb/Migrations/20260315212014_Initial.cs new file mode 100644 index 0000000..11c40f4 --- /dev/null +++ b/MikrocopDb/Migrations/20260315212014_Initial.cs @@ -0,0 +1,53 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MikrocopDb.Migrations +{ + /// + public partial class Initial : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Users", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + UserName = table.Column(type: "TEXT", maxLength: 100, nullable: false), + FullName = table.Column(type: "TEXT", maxLength: 200, nullable: false), + Email = table.Column(type: "TEXT", maxLength: 200, nullable: false), + MobileNumber = table.Column(type: "TEXT", maxLength: 30, nullable: false), + Language = table.Column(type: "TEXT", maxLength: 20, nullable: false), + Culture = table.Column(type: "TEXT", maxLength: 20, nullable: false), + PasswordHash = table.Column(type: "TEXT", nullable: false), + PasswordSalt = table.Column(type: "TEXT", maxLength: 128, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_Users_Email", + table: "Users", + column: "Email", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Users_UserName", + table: "Users", + column: "UserName", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Users"); + } + } +} diff --git a/MikrocopDb/Migrations/AppDbContextModelSnapshot.cs b/MikrocopDb/Migrations/AppDbContextModelSnapshot.cs new file mode 100644 index 0000000..d8c214b --- /dev/null +++ b/MikrocopDb/Migrations/AppDbContextModelSnapshot.cs @@ -0,0 +1,78 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using MikrocopDb; + +#nullable disable + +namespace MikrocopDb.Migrations +{ + [DbContext(typeof(AppDbContext))] + partial class AppDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.5"); + + modelBuilder.Entity("MikrocopDb.Entities.UserEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Culture") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("MobileNumber") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PasswordSalt") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UserName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("UserName") + .IsUnique(); + + b.ToTable("Users"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/MikrocopDb/MikrocopDb.csproj b/MikrocopDb/MikrocopDb.csproj new file mode 100644 index 0000000..904dbe3 --- /dev/null +++ b/MikrocopDb/MikrocopDb.csproj @@ -0,0 +1,18 @@ + + + + net10.0 + enable + enable + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/MikrocopDb/Repositories/IUserRepository.cs b/MikrocopDb/Repositories/IUserRepository.cs new file mode 100644 index 0000000..cb70c3d --- /dev/null +++ b/MikrocopDb/Repositories/IUserRepository.cs @@ -0,0 +1,13 @@ +using MikrocopDb.Entities; + +namespace MikrocopDb.Repositories; + +public interface IUserRepository +{ + Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); + Task GetByUserNameAsync(string userName, CancellationToken cancellationToken = default); + Task GetByEmailAsync(string email, CancellationToken cancellationToken = default); + Task AddAsync(UserEntity user, CancellationToken cancellationToken = default); + Task UpdateAsync(UserEntity user, CancellationToken cancellationToken = default); + Task DeleteAsync(UserEntity user, CancellationToken cancellationToken = default); +} diff --git a/MikrocopDb/Repositories/UserRepository.cs b/MikrocopDb/Repositories/UserRepository.cs new file mode 100644 index 0000000..5499c73 --- /dev/null +++ b/MikrocopDb/Repositories/UserRepository.cs @@ -0,0 +1,47 @@ +using Microsoft.EntityFrameworkCore; +using MikrocopDb.Entities; + +namespace MikrocopDb.Repositories; + +public sealed class UserRepository : IUserRepository +{ + private readonly AppDbContext _dbContext; + + public UserRepository(AppDbContext dbContext) + { + _dbContext = dbContext; + } + + public Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + return _dbContext.Users.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + } + + public Task GetByUserNameAsync(string userName, CancellationToken cancellationToken = default) + { + return _dbContext.Users.FirstOrDefaultAsync(x => x.UserName == userName, cancellationToken); + } + + public Task GetByEmailAsync(string email, CancellationToken cancellationToken = default) + { + return _dbContext.Users.FirstOrDefaultAsync(x => x.Email == email, cancellationToken); + } + + public async Task AddAsync(UserEntity user, CancellationToken cancellationToken = default) + { + await _dbContext.Users.AddAsync(user, cancellationToken); + await _dbContext.SaveChangesAsync(cancellationToken); + } + + public async Task UpdateAsync(UserEntity user, CancellationToken cancellationToken = default) + { + _dbContext.Users.Update(user); + await _dbContext.SaveChangesAsync(cancellationToken); + } + + public async Task DeleteAsync(UserEntity user, CancellationToken cancellationToken = default) + { + _dbContext.Users.Remove(user); + await _dbContext.SaveChangesAsync(cancellationToken); + } +} diff --git a/MikrocopTest.sln b/MikrocopTest.sln new file mode 100644 index 0000000..30bd00f --- /dev/null +++ b/MikrocopTest.sln @@ -0,0 +1,28 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MikrocopTests", "MikrocopTests\MikrocopTests.csproj", "{97E68DAE-AE49-45FB-93D9-F80A8644409E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MikrocopApi", "MikrocopApi\MikrocopApi.csproj", "{9B06D5D5-434B-49B6-A941-C46B8B5D841B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MikrocopDb", "MikrocopDb\MikrocopDb.csproj", "{3612D5AB-3F51-4785-A7C5-A8BFF449AF92}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {97E68DAE-AE49-45FB-93D9-F80A8644409E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {97E68DAE-AE49-45FB-93D9-F80A8644409E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {97E68DAE-AE49-45FB-93D9-F80A8644409E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {97E68DAE-AE49-45FB-93D9-F80A8644409E}.Release|Any CPU.Build.0 = Release|Any CPU + {9B06D5D5-434B-49B6-A941-C46B8B5D841B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9B06D5D5-434B-49B6-A941-C46B8B5D841B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9B06D5D5-434B-49B6-A941-C46B8B5D841B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9B06D5D5-434B-49B6-A941-C46B8B5D841B}.Release|Any CPU.Build.0 = Release|Any CPU + {3612D5AB-3F51-4785-A7C5-A8BFF449AF92}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3612D5AB-3F51-4785-A7C5-A8BFF449AF92}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3612D5AB-3F51-4785-A7C5-A8BFF449AF92}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3612D5AB-3F51-4785-A7C5-A8BFF449AF92}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/MikrocopTests/AuthServiceTests.cs b/MikrocopTests/AuthServiceTests.cs new file mode 100644 index 0000000..add3749 --- /dev/null +++ b/MikrocopTests/AuthServiceTests.cs @@ -0,0 +1,104 @@ +using MikrocopApi.Dtos; +using MikrocopApi.Exceptions; +using MikrocopApi.Services; +using MikrocopDb.Entities; +using MikrocopDb.Repositories; +using Moq; + +namespace MikrocopTests; + +[TestFixture] +public sealed class AuthServiceTests +{ + [Test] + public async Task LoginAsync_ReturnsToken_WhenCredentialsAreValid() + { + var user = CreateUser(); + var repositoryMock = new Mock(MockBehavior.Strict); + var hashingServiceMock = new Mock(MockBehavior.Strict); + var jwtTokenServiceMock = new Mock(MockBehavior.Strict); + var request = new LoginRequestDto { UserName = user.UserName, Password = "ValidPassword!123" }; + var expectedExpiry = DateTime.UtcNow.AddHours(1); + + repositoryMock + .Setup(x => x.GetByUserNameAsync(request.UserName, It.IsAny())) + .ReturnsAsync(user); + hashingServiceMock + .Setup(x => x.VerifyPassword(request.Password, user.PasswordHash, user.PasswordSalt)) + .Returns(true); + jwtTokenServiceMock + .Setup(x => x.Generate(user)) + .Returns(("token-value", expectedExpiry)); + + var sut = new AuthService(repositoryMock.Object, hashingServiceMock.Object, jwtTokenServiceMock.Object); + + var result = await sut.LoginAsync(request); + + Assert.That(result.AccessToken, Is.EqualTo("token-value")); + Assert.That(result.ExpiresAtUtc, Is.EqualTo(expectedExpiry)); + Assert.That(result.TokenType, Is.EqualTo("Bearer")); + repositoryMock.VerifyAll(); + hashingServiceMock.VerifyAll(); + jwtTokenServiceMock.VerifyAll(); + } + + [Test] + public void LoginAsync_ThrowsUnauthorized_WhenUserDoesNotExist() + { + var repositoryMock = new Mock(MockBehavior.Strict); + var hashingServiceMock = new Mock(MockBehavior.Strict); + var jwtTokenServiceMock = new Mock(MockBehavior.Strict); + var request = new LoginRequestDto { UserName = "missing", Password = "ValidPassword!123" }; + + repositoryMock + .Setup(x => x.GetByUserNameAsync(request.UserName, It.IsAny())) + .ReturnsAsync((UserEntity?)null); + + var sut = new AuthService(repositoryMock.Object, hashingServiceMock.Object, jwtTokenServiceMock.Object); + + Assert.ThrowsAsync(() => sut.LoginAsync(request)); + repositoryMock.VerifyAll(); + hashingServiceMock.VerifyNoOtherCalls(); + jwtTokenServiceMock.VerifyNoOtherCalls(); + } + + [Test] + public void LoginAsync_ThrowsUnauthorized_WhenPasswordIsInvalid() + { + var user = CreateUser(); + var repositoryMock = new Mock(MockBehavior.Strict); + var hashingServiceMock = new Mock(MockBehavior.Strict); + var jwtTokenServiceMock = new Mock(MockBehavior.Strict); + var request = new LoginRequestDto { UserName = user.UserName, Password = "WrongPassword!123" }; + + repositoryMock + .Setup(x => x.GetByUserNameAsync(request.UserName, It.IsAny())) + .ReturnsAsync(user); + hashingServiceMock + .Setup(x => x.VerifyPassword(request.Password, user.PasswordHash, user.PasswordSalt)) + .Returns(false); + + var sut = new AuthService(repositoryMock.Object, hashingServiceMock.Object, jwtTokenServiceMock.Object); + + Assert.ThrowsAsync(() => sut.LoginAsync(request)); + repositoryMock.VerifyAll(); + hashingServiceMock.VerifyAll(); + jwtTokenServiceMock.VerifyNoOtherCalls(); + } + + private static UserEntity CreateUser() + { + return new UserEntity + { + Id = Guid.NewGuid(), + UserName = "test-user", + FullName = "Test User", + Email = "test@example.com", + MobileNumber = "+38640111222", + Language = "en", + Culture = "en-US", + PasswordHash = "hash", + PasswordSalt = "salt" + }; + } +} diff --git a/MikrocopTests/MikrocopTests.csproj b/MikrocopTests/MikrocopTests.csproj new file mode 100644 index 0000000..d7f63f8 --- /dev/null +++ b/MikrocopTests/MikrocopTests.csproj @@ -0,0 +1,29 @@ + + + + net10.0 + latest + enable + enable + false + + + + + + + + + + + + + + + + + + + + + diff --git a/MikrocopTests/UserServiceTests.cs b/MikrocopTests/UserServiceTests.cs new file mode 100644 index 0000000..a66d7e7 --- /dev/null +++ b/MikrocopTests/UserServiceTests.cs @@ -0,0 +1,401 @@ +using MikrocopApi.Dtos; +using MikrocopApi.Exceptions; +using MikrocopApi.Services; +using MikrocopDb.Entities; +using MikrocopDb.Repositories; +using Moq; + +namespace MikrocopTests; + +[TestFixture] +public sealed class UserServiceTests +{ + [Test] + public async Task CreateAsync_ReturnsCreatedUser_WhenRequestIsValid() + { + var repositoryMock = new Mock(MockBehavior.Strict); + var hashingServiceMock = new Mock(MockBehavior.Strict); + var request = CreateRequest(); + + repositoryMock.Setup(x => x.GetByUserNameAsync(request.UserName, It.IsAny())) + .ReturnsAsync((UserEntity?)null); + repositoryMock.Setup(x => x.GetByEmailAsync(request.Email, It.IsAny())) + .ReturnsAsync((UserEntity?)null); + hashingServiceMock.Setup(x => x.HashPassword(request.Password)) + .Returns(("hash-value", "salt-value")); + repositoryMock.Setup(x => x.AddAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + var sut = new UserService(repositoryMock.Object, hashingServiceMock.Object); + + var result = await sut.CreateAsync(request); + + Assert.That(result.UserName, Is.EqualTo("new-user")); + Assert.That(result.Email, Is.EqualTo("new.user@example.com")); + repositoryMock.Verify(x => x.AddAsync(It.Is(u => + u.UserName == "new-user" && + u.FullName == "New User" && + u.Email == "new.user@example.com" && + u.PasswordHash == "hash-value" && + u.PasswordSalt == "salt-value"), + It.IsAny()), Times.Once); + repositoryMock.VerifyAll(); + hashingServiceMock.VerifyAll(); + } + + [Test] + public void CreateAsync_ThrowsConflict_WhenUserNameExists() + { + var repositoryMock = new Mock(MockBehavior.Strict); + var hashingServiceMock = new Mock(MockBehavior.Strict); + var request = CreateRequest(); + + repositoryMock.Setup(x => x.GetByUserNameAsync(request.UserName, It.IsAny())) + .ReturnsAsync(CreateEntity()); + + var sut = new UserService(repositoryMock.Object, hashingServiceMock.Object); + + Assert.ThrowsAsync(() => sut.CreateAsync(request)); + repositoryMock.VerifyAll(); + hashingServiceMock.VerifyNoOtherCalls(); + } + + [Test] + public void CreateAsync_ThrowsConflict_WhenEmailExists() + { + var repositoryMock = new Mock(MockBehavior.Strict); + var hashingServiceMock = new Mock(MockBehavior.Strict); + var request = CreateRequest(); + + repositoryMock.Setup(x => x.GetByUserNameAsync(request.UserName, It.IsAny())) + .ReturnsAsync((UserEntity?)null); + repositoryMock.Setup(x => x.GetByEmailAsync(request.Email, It.IsAny())) + .ReturnsAsync(CreateEntity()); + + var sut = new UserService(repositoryMock.Object, hashingServiceMock.Object); + + Assert.ThrowsAsync(() => sut.CreateAsync(request)); + repositoryMock.VerifyAll(); + hashingServiceMock.VerifyNoOtherCalls(); + } + + [Test] + public void CreateAsync_ThrowsBadRequest_WhenPasswordIsNotComplexEnough() + { + var repositoryMock = new Mock(MockBehavior.Strict); + var hashingServiceMock = new Mock(MockBehavior.Strict); + var request = CreateRequest(); + request.Password = "weak"; + + repositoryMock.Setup(x => x.GetByUserNameAsync(request.UserName, It.IsAny())) + .ReturnsAsync((UserEntity?)null); + repositoryMock.Setup(x => x.GetByEmailAsync(request.Email, It.IsAny())) + .ReturnsAsync((UserEntity?)null); + + var sut = new UserService(repositoryMock.Object, hashingServiceMock.Object); + + Assert.ThrowsAsync(() => sut.CreateAsync(request)); + repositoryMock.VerifyAll(); + hashingServiceMock.VerifyNoOtherCalls(); + } + + [Test] + public async Task UpdateAsync_UpdatesUserWithoutPassword_WhenPasswordIsNotProvided() + { + var repositoryMock = new Mock(MockBehavior.Strict); + var hashingServiceMock = new Mock(MockBehavior.Strict); + var id = Guid.NewGuid(); + var existing = CreateEntity(id); + var request = CreateUpdateRequest(password: null); + + repositoryMock.Setup(x => x.GetByIdAsync(id, It.IsAny())) + .ReturnsAsync(existing); + repositoryMock.Setup(x => x.GetByUserNameAsync(request.UserName, It.IsAny())) + .ReturnsAsync((UserEntity?)null); + repositoryMock.Setup(x => x.GetByEmailAsync(request.Email, It.IsAny())) + .ReturnsAsync((UserEntity?)null); + repositoryMock.Setup(x => x.UpdateAsync(existing, It.IsAny())) + .Returns(Task.CompletedTask); + + var sut = new UserService(repositoryMock.Object, hashingServiceMock.Object); + + await sut.UpdateAsync(id, request); + + Assert.That(existing.UserName, Is.EqualTo(request.UserName)); + Assert.That(existing.PasswordHash, Is.EqualTo("hash")); + repositoryMock.VerifyAll(); + hashingServiceMock.VerifyNoOtherCalls(); + } + + [Test] + public async Task UpdateAsync_HashesPassword_WhenPasswordIsProvided() + { + var repositoryMock = new Mock(MockBehavior.Strict); + var hashingServiceMock = new Mock(MockBehavior.Strict); + var id = Guid.NewGuid(); + var existing = CreateEntity(id); + var request = CreateUpdateRequest(password: "UpdatedPassword!123"); + + repositoryMock.Setup(x => x.GetByIdAsync(id, It.IsAny())) + .ReturnsAsync(existing); + repositoryMock.Setup(x => x.GetByUserNameAsync(request.UserName, It.IsAny())) + .ReturnsAsync((UserEntity?)null); + repositoryMock.Setup(x => x.GetByEmailAsync(request.Email, It.IsAny())) + .ReturnsAsync((UserEntity?)null); + hashingServiceMock.Setup(x => x.HashPassword(request.Password!)) + .Returns(("new-hash", "new-salt")); + repositoryMock.Setup(x => x.UpdateAsync(existing, It.IsAny())) + .Returns(Task.CompletedTask); + + var sut = new UserService(repositoryMock.Object, hashingServiceMock.Object); + + await sut.UpdateAsync(id, request); + + Assert.That(existing.PasswordHash, Is.EqualTo("new-hash")); + Assert.That(existing.PasswordSalt, Is.EqualTo("new-salt")); + repositoryMock.VerifyAll(); + hashingServiceMock.VerifyAll(); + } + + [Test] + public void UpdateAsync_ThrowsNotFound_WhenUserDoesNotExist() + { + var repositoryMock = new Mock(MockBehavior.Strict); + var hashingServiceMock = new Mock(MockBehavior.Strict); + var id = Guid.NewGuid(); + var request = CreateUpdateRequest(password: null); + + repositoryMock.Setup(x => x.GetByIdAsync(id, It.IsAny())) + .ReturnsAsync((UserEntity?)null); + + var sut = new UserService(repositoryMock.Object, hashingServiceMock.Object); + + Assert.ThrowsAsync(() => sut.UpdateAsync(id, request)); + repositoryMock.VerifyAll(); + hashingServiceMock.VerifyNoOtherCalls(); + } + + [Test] + public void UpdateAsync_ThrowsConflict_WhenUserNameBelongsToAnotherUser() + { + var repositoryMock = new Mock(MockBehavior.Strict); + var hashingServiceMock = new Mock(MockBehavior.Strict); + var id = Guid.NewGuid(); + var request = CreateUpdateRequest(password: null); + + repositoryMock.Setup(x => x.GetByIdAsync(id, It.IsAny())) + .ReturnsAsync(CreateEntity(id)); + repositoryMock.Setup(x => x.GetByUserNameAsync(request.UserName, It.IsAny())) + .ReturnsAsync(CreateEntity(Guid.NewGuid())); + + var sut = new UserService(repositoryMock.Object, hashingServiceMock.Object); + + Assert.ThrowsAsync(() => sut.UpdateAsync(id, request)); + repositoryMock.VerifyAll(); + hashingServiceMock.VerifyNoOtherCalls(); + } + + [Test] + public void UpdateAsync_ThrowsConflict_WhenEmailBelongsToAnotherUser() + { + var repositoryMock = new Mock(MockBehavior.Strict); + var hashingServiceMock = new Mock(MockBehavior.Strict); + var id = Guid.NewGuid(); + var request = CreateUpdateRequest(password: null); + + repositoryMock.Setup(x => x.GetByIdAsync(id, It.IsAny())) + .ReturnsAsync(CreateEntity(id)); + repositoryMock.Setup(x => x.GetByUserNameAsync(request.UserName, It.IsAny())) + .ReturnsAsync((UserEntity?)null); + repositoryMock.Setup(x => x.GetByEmailAsync(request.Email, It.IsAny())) + .ReturnsAsync(CreateEntity(Guid.NewGuid())); + + var sut = new UserService(repositoryMock.Object, hashingServiceMock.Object); + + Assert.ThrowsAsync(() => sut.UpdateAsync(id, request)); + repositoryMock.VerifyAll(); + hashingServiceMock.VerifyNoOtherCalls(); + } + + [Test] + public void UpdateAsync_ThrowsBadRequest_WhenProvidedPasswordIsWeak() + { + var repositoryMock = new Mock(MockBehavior.Strict); + var hashingServiceMock = new Mock(MockBehavior.Strict); + var id = Guid.NewGuid(); + var request = CreateUpdateRequest(password: "weak"); + + repositoryMock.Setup(x => x.GetByIdAsync(id, It.IsAny())) + .ReturnsAsync(CreateEntity(id)); + repositoryMock.Setup(x => x.GetByUserNameAsync(request.UserName, It.IsAny())) + .ReturnsAsync((UserEntity?)null); + repositoryMock.Setup(x => x.GetByEmailAsync(request.Email, It.IsAny())) + .ReturnsAsync((UserEntity?)null); + + var sut = new UserService(repositoryMock.Object, hashingServiceMock.Object); + + Assert.ThrowsAsync(() => sut.UpdateAsync(id, request)); + repositoryMock.VerifyAll(); + hashingServiceMock.VerifyNoOtherCalls(); + } + + [Test] + public async Task GetByIdAsync_ReturnsUser_WhenUserExists() + { + var repositoryMock = new Mock(MockBehavior.Strict); + var hashingServiceMock = new Mock(MockBehavior.Strict); + var id = Guid.NewGuid(); + var user = CreateEntity(id); + + repositoryMock.Setup(x => x.GetByIdAsync(id, It.IsAny())) + .ReturnsAsync(user); + + var sut = new UserService(repositoryMock.Object, hashingServiceMock.Object); + + var result = await sut.GetByIdAsync(id); + + Assert.That(result.Id, Is.EqualTo(id)); + Assert.That(result.UserName, Is.EqualTo(user.UserName)); + repositoryMock.VerifyAll(); + hashingServiceMock.VerifyNoOtherCalls(); + } + + [Test] + public void GetByIdAsync_ThrowsNotFound_WhenUserDoesNotExist() + { + var repositoryMock = new Mock(MockBehavior.Strict); + var hashingServiceMock = new Mock(MockBehavior.Strict); + var id = Guid.NewGuid(); + + repositoryMock.Setup(x => x.GetByIdAsync(id, It.IsAny())) + .ReturnsAsync((UserEntity?)null); + + var sut = new UserService(repositoryMock.Object, hashingServiceMock.Object); + + Assert.ThrowsAsync(() => sut.GetByIdAsync(id)); + repositoryMock.VerifyAll(); + hashingServiceMock.VerifyNoOtherCalls(); + } + + [Test] + public async Task DeleteAsync_DeletesUser_WhenUserExists() + { + var repositoryMock = new Mock(MockBehavior.Strict); + var hashingServiceMock = new Mock(MockBehavior.Strict); + var id = Guid.NewGuid(); + var user = CreateEntity(id); + + repositoryMock.Setup(x => x.GetByIdAsync(id, It.IsAny())) + .ReturnsAsync(user); + repositoryMock.Setup(x => x.DeleteAsync(user, It.IsAny())) + .Returns(Task.CompletedTask); + + var sut = new UserService(repositoryMock.Object, hashingServiceMock.Object); + + await sut.DeleteAsync(id); + + repositoryMock.VerifyAll(); + hashingServiceMock.VerifyNoOtherCalls(); + } + + [Test] + public void DeleteAsync_ThrowsNotFound_WhenUserDoesNotExist() + { + var repositoryMock = new Mock(MockBehavior.Strict); + var hashingServiceMock = new Mock(MockBehavior.Strict); + var id = Guid.NewGuid(); + + repositoryMock.Setup(x => x.GetByIdAsync(id, It.IsAny())) + .ReturnsAsync((UserEntity?)null); + + var sut = new UserService(repositoryMock.Object, hashingServiceMock.Object); + + Assert.ThrowsAsync(() => sut.DeleteAsync(id)); + repositoryMock.VerifyAll(); + hashingServiceMock.VerifyNoOtherCalls(); + } + + [Test] + public async Task ValidatePasswordAsync_ReturnsResult_WhenUserExists() + { + var repositoryMock = new Mock(MockBehavior.Strict); + var hashingServiceMock = new Mock(MockBehavior.Strict); + var id = Guid.NewGuid(); + var user = CreateEntity(id); + const string password = "AnyPassword!123"; + + repositoryMock.Setup(x => x.GetByIdAsync(id, It.IsAny())) + .ReturnsAsync(user); + hashingServiceMock.Setup(x => x.VerifyPassword(password, user.PasswordHash, user.PasswordSalt)) + .Returns(true); + + var sut = new UserService(repositoryMock.Object, hashingServiceMock.Object); + + var result = await sut.ValidatePasswordAsync(id, password); + + Assert.That(result, Is.True); + repositoryMock.VerifyAll(); + hashingServiceMock.VerifyAll(); + } + + [Test] + public void ValidatePasswordAsync_ThrowsNotFound_WhenUserDoesNotExist() + { + var repositoryMock = new Mock(MockBehavior.Strict); + var hashingServiceMock = new Mock(MockBehavior.Strict); + var id = Guid.NewGuid(); + + repositoryMock.Setup(x => x.GetByIdAsync(id, It.IsAny())) + .ReturnsAsync((UserEntity?)null); + + var sut = new UserService(repositoryMock.Object, hashingServiceMock.Object); + + Assert.ThrowsAsync(() => sut.ValidatePasswordAsync(id, "AnyPassword!123")); + repositoryMock.VerifyAll(); + hashingServiceMock.VerifyNoOtherCalls(); + } + + private static CreateUserDto CreateRequest() + { + return new CreateUserDto + { + UserName = " new-user ", + FullName = " New User ", + Email = " new.user@example.com ", + MobileNumber = " +38640123456 ", + Language = " en ", + Culture = " en-US ", + Password = "ValidPassword!123" + }; + } + + private static UpdateUserDto CreateUpdateRequest(string? password) + { + return new UpdateUserDto + { + UserName = "updated-user", + FullName = "Updated User", + Email = "updated.user@example.com", + MobileNumber = "+38640999888", + Language = "en", + Culture = "en-US", + Password = password + }; + } + + private static UserEntity CreateEntity(Guid? id = null) + { + return new UserEntity + { + Id = id ?? Guid.NewGuid(), + UserName = "existing-user", + FullName = "Existing User", + Email = "existing@example.com", + MobileNumber = "+38640111222", + Language = "en", + Culture = "en-US", + PasswordHash = "hash", + PasswordSalt = "salt" + }; + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..bbfcd9a --- /dev/null +++ b/README.md @@ -0,0 +1,68 @@ +# MikrocopTest + +This repository contains a .NET 10 solution with: +- `MikrocopApi`: ASP.NET Core Web API +- `MikrocopDb`: Entity Framework Core data access layer (SQLite) +- `MikrocopTests`: NUnit test project + +## Technologies Used + +- .NET SDK 10 (`net10.0`) +- ASP.NET Core Web API +- Entity Framework Core 10 +- SQLite (`Microsoft.EntityFrameworkCore.Sqlite`) +- JWT authentication (`Microsoft.AspNetCore.Authentication.JwtBearer`) +- Swagger / OpenAPI (`Swashbuckle.AspNetCore`) +- Serilog (console + rolling file logs) +- NUnit + Moq + Microsoft.NET.Test.Sdk + +## Prerequisites + +- .NET 10 SDK installed + +Check your installed version: + +```bash +dotnet --version +``` + +## How to Run the Program + +From the repository root: + +```bash +dotnet restore +dotnet run --project MikrocopApi +``` + +Notes: +- In `Development`, EF Core migrations are applied automatically on startup. +- Swagger UI is available at `http://localhost:5018/swagger` by default. +- Development DB connection string is in `MikrocopApi/appsettings.Development.json` (`mikrocop.dev.db`). + +## How to Use the API (JWT Flow) + +To use protected endpoints, follow this order: +1. Register a user with `POST /api/auth/register` +2. Log in with `POST /api/auth/login` +3. Copy the returned JWT token and send it as: + `Authorization: Bearer ` + +Notes: +- `api/auth/register` and `api/auth/login` are anonymous endpoints. +- `api/users/*` endpoints require authentication (`[Authorize]`). +- In Swagger, use the **Authorize** button and paste the token as `Bearer `. + +## How to Run the Tests + +Run all tests in the solution: + +```bash +dotnet test MikrocopTest.sln +``` + +Or run only the test project: + +```bash +dotnet test MikrocopTests/MikrocopTests.csproj +```