Add initial implementation of API, database, and user management components.
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
/packages/
|
||||||
|
riderModule.iml
|
||||||
|
/_ReSharper.Caches/
|
||||||
15
.idea/.idea.MikrocopTest/.idea/.gitignore
generated
vendored
Normal file
15
.idea/.idea.MikrocopTest/.idea/.gitignore
generated
vendored
Normal file
@@ -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/
|
||||||
4
.idea/.idea.MikrocopTest/.idea/encodings.xml
generated
Normal file
4
.idea/.idea.MikrocopTest/.idea/encodings.xml
generated
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="Encoding" addBOMForNewFiles="with BOM under Windows, with no BOM otherwise" />
|
||||||
|
</project>
|
||||||
8
.idea/.idea.MikrocopTest/.idea/indexLayout.xml
generated
Normal file
8
.idea/.idea.MikrocopTest/.idea/indexLayout.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="UserContentModel">
|
||||||
|
<attachedFolders />
|
||||||
|
<explicitIncludes />
|
||||||
|
<explicitExcludes />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/.idea.MikrocopTest/.idea/vcs.xml
generated
Normal file
6
.idea/.idea.MikrocopTest/.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
9
MikrocopApi/Configuration/JwtOptions.cs
Normal file
9
MikrocopApi/Configuration/JwtOptions.cs
Normal file
@@ -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;
|
||||||
|
}
|
||||||
42
MikrocopApi/Controllers/AuthController.cs
Normal file
42
MikrocopApi/Controllers/AuthController.cs
Normal file
@@ -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<UserDto>(StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status409Conflict)]
|
||||||
|
[ProducesResponseType<ValidationProblemDetails>(StatusCodes.Status400BadRequest)]
|
||||||
|
public async Task<IActionResult> Register([FromBody] CreateUserDto request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var user = await _userService.CreateAsync(request, cancellationToken);
|
||||||
|
return Created($"/api/users/{user.Id}", user);
|
||||||
|
}
|
||||||
|
|
||||||
|
[AllowAnonymous]
|
||||||
|
[HttpPost("login")]
|
||||||
|
[ProducesResponseType<LoginResponseDto>(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType<ValidationProblemDetails>(StatusCodes.Status400BadRequest)]
|
||||||
|
public async Task<IActionResult> Login([FromBody] LoginRequestDto request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var response = await _authService.LoginAsync(request, cancellationToken);
|
||||||
|
return Ok(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
69
MikrocopApi/Controllers/UsersController.cs
Normal file
69
MikrocopApi/Controllers/UsersController.cs
Normal file
@@ -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<UserDto>(StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType<ValidationProblemDetails>(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> 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<UserDto>(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> GetById([FromRoute] Guid id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var user = await _userService.GetByIdAsync(id, cancellationToken);
|
||||||
|
return Ok(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:guid}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType<ValidationProblemDetails>(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> Update([FromRoute] Guid id, [FromBody] UpdateUserDto request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await _userService.UpdateAsync(id, request, cancellationToken);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{id:guid}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> Delete([FromRoute] Guid id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await _userService.DeleteAsync(id, cancellationToken);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/validate-password")]
|
||||||
|
[ProducesResponseType<ValidatePasswordResponseDto>(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType<ValidationProblemDetails>(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> ValidatePassword([FromRoute] Guid id, [FromBody] ValidatePasswordRequestDto request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var isValid = await _userService.ValidatePasswordAsync(id, request.Password, cancellationToken);
|
||||||
|
return Ok(isValid.ToDto());
|
||||||
|
}
|
||||||
|
}
|
||||||
38
MikrocopApi/Dtos/CreateUserDto.cs
Normal file
38
MikrocopApi/Dtos/CreateUserDto.cs
Normal file
@@ -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;
|
||||||
|
}
|
||||||
14
MikrocopApi/Dtos/LoginRequestDto.cs
Normal file
14
MikrocopApi/Dtos/LoginRequestDto.cs
Normal file
@@ -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;
|
||||||
|
}
|
||||||
8
MikrocopApi/Dtos/LoginResponseDto.cs
Normal file
8
MikrocopApi/Dtos/LoginResponseDto.cs
Normal file
@@ -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; }
|
||||||
|
}
|
||||||
37
MikrocopApi/Dtos/UpdateUserDto.cs
Normal file
37
MikrocopApi/Dtos/UpdateUserDto.cs
Normal file
@@ -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; }
|
||||||
|
}
|
||||||
12
MikrocopApi/Dtos/UserDto.cs
Normal file
12
MikrocopApi/Dtos/UserDto.cs
Normal file
@@ -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; }
|
||||||
|
}
|
||||||
10
MikrocopApi/Dtos/ValidatePasswordRequestDto.cs
Normal file
10
MikrocopApi/Dtos/ValidatePasswordRequestDto.cs
Normal file
@@ -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;
|
||||||
|
}
|
||||||
6
MikrocopApi/Dtos/ValidatePasswordResponseDto.cs
Normal file
6
MikrocopApi/Dtos/ValidatePasswordResponseDto.cs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
namespace MikrocopApi.Dtos;
|
||||||
|
|
||||||
|
public sealed class ValidatePasswordResponseDto
|
||||||
|
{
|
||||||
|
public bool IsValid { get; init; }
|
||||||
|
}
|
||||||
11
MikrocopApi/Exceptions/AppException.cs
Normal file
11
MikrocopApi/Exceptions/AppException.cs
Normal file
@@ -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; }
|
||||||
|
}
|
||||||
11
MikrocopApi/Exceptions/BadRequestException.cs
Normal file
11
MikrocopApi/Exceptions/BadRequestException.cs
Normal file
@@ -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";
|
||||||
|
}
|
||||||
11
MikrocopApi/Exceptions/ConflictException.cs
Normal file
11
MikrocopApi/Exceptions/ConflictException.cs
Normal file
@@ -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";
|
||||||
|
}
|
||||||
11
MikrocopApi/Exceptions/NotFoundException.cs
Normal file
11
MikrocopApi/Exceptions/NotFoundException.cs
Normal file
@@ -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";
|
||||||
|
}
|
||||||
11
MikrocopApi/Exceptions/UnauthorizedException.cs
Normal file
11
MikrocopApi/Exceptions/UnauthorizedException.cs
Normal file
@@ -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";
|
||||||
|
}
|
||||||
65
MikrocopApi/Extensions/SwaggerExtensions.cs
Normal file
65
MikrocopApi/Extensions/SwaggerExtensions.cs
Normal file
@@ -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<string>()
|
||||||
|
}
|
||||||
|
];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
app.UseSwaggerUI(options =>
|
||||||
|
{
|
||||||
|
options.SwaggerEndpoint("/swagger/v1/swagger.json", "Mikrocop API v1");
|
||||||
|
options.EnablePersistAuthorization();
|
||||||
|
});
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
}
|
||||||
24
MikrocopApi/Mappers/AuthMappingExtensions.cs
Normal file
24
MikrocopApi/Mappers/AuthMappingExtensions.cs
Normal file
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
47
MikrocopApi/Mappers/UserMappingExtensions.cs
Normal file
47
MikrocopApi/Mappers/UserMappingExtensions.cs
Normal file
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
188
MikrocopApi/Middleware/ApiRequestLoggingMiddleware.cs
Normal file
188
MikrocopApi/Middleware/ApiRequestLoggingMiddleware.cs
Normal file
@@ -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<string> SensitiveFields = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
"password",
|
||||||
|
"passwordhash",
|
||||||
|
"apikey",
|
||||||
|
"x-api-key"
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly RequestDelegate _next;
|
||||||
|
private readonly ILogger<ApiRequestLoggingMiddleware> _logger;
|
||||||
|
|
||||||
|
public ApiRequestLoggingMiddleware(RequestDelegate next, ILogger<ApiRequestLoggingMiddleware> 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<string?> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
42
MikrocopApi/Middleware/ExceptionHandlingMiddleware.cs
Normal file
42
MikrocopApi/Middleware/ExceptionHandlingMiddleware.cs
Normal file
@@ -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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
23
MikrocopApi/MikrocopApi.csproj
Normal file
23
MikrocopApi/MikrocopApi.csproj
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.3"/>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.3" />
|
||||||
|
<PackageReference Include="Microsoft.OpenApi" Version="2.3.0" />
|
||||||
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.0.1" />
|
||||||
|
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
|
||||||
|
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||||
|
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\MikrocopDb\MikrocopDb.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
75
MikrocopApi/MikrocopApi.http
Normal file
75
MikrocopApi/MikrocopApi.http
Normal file
@@ -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}}
|
||||||
|
|
||||||
|
###
|
||||||
108
MikrocopApi/Program.cs
Normal file
108
MikrocopApi/Program.cs
Normal file
@@ -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<JwtOptions>(builder.Configuration.GetSection("Jwt"));
|
||||||
|
builder.Services.AddControllers();
|
||||||
|
builder.Services.AddSwaggerWithJwtAuth();
|
||||||
|
|
||||||
|
var jwtOptions = builder.Configuration.GetSection("Jwt").Get<JwtOptions>() ?? 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<AppDbContext>(options =>
|
||||||
|
options.UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection")));
|
||||||
|
|
||||||
|
builder.Services.AddScoped<IUserRepository, UserRepository>();
|
||||||
|
builder.Services.AddScoped<IUserService, UserService>();
|
||||||
|
builder.Services.AddScoped<IAuthService, AuthService>();
|
||||||
|
builder.Services.AddScoped<IJwtTokenService, JwtTokenService>();
|
||||||
|
builder.Services.AddSingleton<IPasswordHashingService, PasswordHashingService>();
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
using (var scope = app.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
if (app.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
await dbContext.Database.MigrateAsync();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await dbContext.Database.EnsureCreatedAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (app.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
app.UseSwaggerWithJwtAuth();
|
||||||
|
}
|
||||||
|
|
||||||
|
app.UseMiddleware<ExceptionHandlingMiddleware>();
|
||||||
|
app.UseMiddleware<ApiRequestLoggingMiddleware>();
|
||||||
|
|
||||||
|
if (!app.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
app.UseHttpsRedirection();
|
||||||
|
}
|
||||||
|
|
||||||
|
app.UseAuthentication();
|
||||||
|
app.UseAuthorization();
|
||||||
|
|
||||||
|
app.MapControllers();
|
||||||
|
|
||||||
|
await app.RunAsync();
|
||||||
25
MikrocopApi/Properties/launchSettings.json
Normal file
25
MikrocopApi/Properties/launchSettings.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
40
MikrocopApi/Services/AuthService.cs
Normal file
40
MikrocopApi/Services/AuthService.cs
Normal file
@@ -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<LoginResponseDto> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
8
MikrocopApi/Services/Interfaces/IAuthService.cs
Normal file
8
MikrocopApi/Services/Interfaces/IAuthService.cs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
using MikrocopApi.Dtos;
|
||||||
|
|
||||||
|
namespace MikrocopApi.Services;
|
||||||
|
|
||||||
|
public interface IAuthService
|
||||||
|
{
|
||||||
|
Task<LoginResponseDto> LoginAsync(LoginRequestDto request, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
8
MikrocopApi/Services/Interfaces/IJwtTokenService.cs
Normal file
8
MikrocopApi/Services/Interfaces/IJwtTokenService.cs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
using MikrocopDb.Entities;
|
||||||
|
|
||||||
|
namespace MikrocopApi.Services;
|
||||||
|
|
||||||
|
public interface IJwtTokenService
|
||||||
|
{
|
||||||
|
(string AccessToken, DateTime ExpiresAtUtc) Generate(UserEntity user);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
12
MikrocopApi/Services/Interfaces/IUserService.cs
Normal file
12
MikrocopApi/Services/Interfaces/IUserService.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
using MikrocopApi.Dtos;
|
||||||
|
|
||||||
|
namespace MikrocopApi.Services;
|
||||||
|
|
||||||
|
public interface IUserService
|
||||||
|
{
|
||||||
|
Task<UserDto> CreateAsync(CreateUserDto request, CancellationToken cancellationToken = default);
|
||||||
|
Task UpdateAsync(Guid id, UpdateUserDto request, CancellationToken cancellationToken = default);
|
||||||
|
Task<UserDto> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||||
|
Task DeleteAsync(Guid id, CancellationToken cancellationToken = default);
|
||||||
|
Task<bool> ValidatePasswordAsync(Guid id, string password, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
47
MikrocopApi/Services/JwtTokenService.cs
Normal file
47
MikrocopApi/Services/JwtTokenService.cs
Normal file
@@ -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<JwtOptions> 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<Claim>
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
37
MikrocopApi/Services/PasswordHashingService.cs
Normal file
37
MikrocopApi/Services/PasswordHashingService.cs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
133
MikrocopApi/Services/UserService.cs
Normal file
133
MikrocopApi/Services/UserService.cs
Normal file
@@ -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<UserDto> 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<UserDto> 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<bool> 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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
MikrocopApi/appsettings.Development.json
Normal file
11
MikrocopApi/appsettings.Development.json
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
22
MikrocopApi/appsettings.json
Normal file
22
MikrocopApi/appsettings.json
Normal file
@@ -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": "*"
|
||||||
|
}
|
||||||
32
MikrocopDb/AppDbContext.cs
Normal file
32
MikrocopDb/AppDbContext.cs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using MikrocopDb.Entities;
|
||||||
|
|
||||||
|
namespace MikrocopDb;
|
||||||
|
|
||||||
|
public sealed class AppDbContext : DbContext
|
||||||
|
{
|
||||||
|
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public DbSet<UserEntity> Users => Set<UserEntity>();
|
||||||
|
|
||||||
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
modelBuilder.Entity<UserEntity>(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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
15
MikrocopDb/AppDbContextFactory.cs
Normal file
15
MikrocopDb/AppDbContextFactory.cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Design;
|
||||||
|
|
||||||
|
namespace MikrocopDb;
|
||||||
|
|
||||||
|
public sealed class AppDbContextFactory : IDesignTimeDbContextFactory<AppDbContext>
|
||||||
|
{
|
||||||
|
public AppDbContext CreateDbContext(string[] args)
|
||||||
|
{
|
||||||
|
var optionsBuilder = new DbContextOptionsBuilder<AppDbContext>();
|
||||||
|
optionsBuilder.UseSqlite("Data Source=mikrocop.db");
|
||||||
|
|
||||||
|
return new AppDbContext(optionsBuilder.Options);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
MikrocopDb/Entities/UserEntity.cs
Normal file
14
MikrocopDb/Entities/UserEntity.cs
Normal file
@@ -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; }
|
||||||
|
}
|
||||||
81
MikrocopDb/Migrations/20260315212014_Initial.Designer.cs
generated
Normal file
81
MikrocopDb/Migrations/20260315212014_Initial.Designer.cs
generated
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
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
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Culture")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("FullName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Language")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("MobileNumber")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordSalt")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
53
MikrocopDb/Migrations/20260315212014_Initial.cs
Normal file
53
MikrocopDb/Migrations/20260315212014_Initial.cs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace MikrocopDb.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class Initial : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Users",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
UserName = table.Column<string>(type: "TEXT", maxLength: 100, nullable: false),
|
||||||
|
FullName = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
|
||||||
|
Email = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
|
||||||
|
MobileNumber = table.Column<string>(type: "TEXT", maxLength: 30, nullable: false),
|
||||||
|
Language = table.Column<string>(type: "TEXT", maxLength: 20, nullable: false),
|
||||||
|
Culture = table.Column<string>(type: "TEXT", maxLength: 20, nullable: false),
|
||||||
|
PasswordHash = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
PasswordSalt = table.Column<string>(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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Users");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
78
MikrocopDb/Migrations/AppDbContextModelSnapshot.cs
Normal file
78
MikrocopDb/Migrations/AppDbContextModelSnapshot.cs
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Culture")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("FullName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Language")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("MobileNumber")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordSalt")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
18
MikrocopDb/MikrocopDb.csproj
Normal file
18
MikrocopDb/MikrocopDb.csproj
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.5" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.5" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.5">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
13
MikrocopDb/Repositories/IUserRepository.cs
Normal file
13
MikrocopDb/Repositories/IUserRepository.cs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
using MikrocopDb.Entities;
|
||||||
|
|
||||||
|
namespace MikrocopDb.Repositories;
|
||||||
|
|
||||||
|
public interface IUserRepository
|
||||||
|
{
|
||||||
|
Task<UserEntity?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||||
|
Task<UserEntity?> GetByUserNameAsync(string userName, CancellationToken cancellationToken = default);
|
||||||
|
Task<UserEntity?> 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);
|
||||||
|
}
|
||||||
47
MikrocopDb/Repositories/UserRepository.cs
Normal file
47
MikrocopDb/Repositories/UserRepository.cs
Normal file
@@ -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<UserEntity?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return _dbContext.Users.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<UserEntity?> GetByUserNameAsync(string userName, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return _dbContext.Users.FirstOrDefaultAsync(x => x.UserName == userName, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<UserEntity?> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
28
MikrocopTest.sln
Normal file
28
MikrocopTest.sln
Normal file
@@ -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
|
||||||
104
MikrocopTests/AuthServiceTests.cs
Normal file
104
MikrocopTests/AuthServiceTests.cs
Normal file
@@ -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<IUserRepository>(MockBehavior.Strict);
|
||||||
|
var hashingServiceMock = new Mock<IPasswordHashingService>(MockBehavior.Strict);
|
||||||
|
var jwtTokenServiceMock = new Mock<IJwtTokenService>(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<CancellationToken>()))
|
||||||
|
.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<IUserRepository>(MockBehavior.Strict);
|
||||||
|
var hashingServiceMock = new Mock<IPasswordHashingService>(MockBehavior.Strict);
|
||||||
|
var jwtTokenServiceMock = new Mock<IJwtTokenService>(MockBehavior.Strict);
|
||||||
|
var request = new LoginRequestDto { UserName = "missing", Password = "ValidPassword!123" };
|
||||||
|
|
||||||
|
repositoryMock
|
||||||
|
.Setup(x => x.GetByUserNameAsync(request.UserName, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync((UserEntity?)null);
|
||||||
|
|
||||||
|
var sut = new AuthService(repositoryMock.Object, hashingServiceMock.Object, jwtTokenServiceMock.Object);
|
||||||
|
|
||||||
|
Assert.ThrowsAsync<UnauthorizedException>(() => sut.LoginAsync(request));
|
||||||
|
repositoryMock.VerifyAll();
|
||||||
|
hashingServiceMock.VerifyNoOtherCalls();
|
||||||
|
jwtTokenServiceMock.VerifyNoOtherCalls();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void LoginAsync_ThrowsUnauthorized_WhenPasswordIsInvalid()
|
||||||
|
{
|
||||||
|
var user = CreateUser();
|
||||||
|
var repositoryMock = new Mock<IUserRepository>(MockBehavior.Strict);
|
||||||
|
var hashingServiceMock = new Mock<IPasswordHashingService>(MockBehavior.Strict);
|
||||||
|
var jwtTokenServiceMock = new Mock<IJwtTokenService>(MockBehavior.Strict);
|
||||||
|
var request = new LoginRequestDto { UserName = user.UserName, Password = "WrongPassword!123" };
|
||||||
|
|
||||||
|
repositoryMock
|
||||||
|
.Setup(x => x.GetByUserNameAsync(request.UserName, It.IsAny<CancellationToken>()))
|
||||||
|
.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<UnauthorizedException>(() => 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"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
29
MikrocopTests/MikrocopTests.csproj
Normal file
29
MikrocopTests/MikrocopTests.csproj
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.0.4"/>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0"/>
|
||||||
|
<PackageReference Include="Moq" Version="4.20.72"/>
|
||||||
|
<PackageReference Include="NUnit" Version="4.3.2"/>
|
||||||
|
<PackageReference Include="NUnit.Analyzers" Version="4.7.0"/>
|
||||||
|
<PackageReference Include="NUnit3TestAdapter" Version="5.0.0"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="NUnit.Framework"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\MikrocopApi\MikrocopApi.csproj" />
|
||||||
|
<ProjectReference Include="..\MikrocopDb\MikrocopDb.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
401
MikrocopTests/UserServiceTests.cs
Normal file
401
MikrocopTests/UserServiceTests.cs
Normal file
@@ -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<IUserRepository>(MockBehavior.Strict);
|
||||||
|
var hashingServiceMock = new Mock<IPasswordHashingService>(MockBehavior.Strict);
|
||||||
|
var request = CreateRequest();
|
||||||
|
|
||||||
|
repositoryMock.Setup(x => x.GetByUserNameAsync(request.UserName, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync((UserEntity?)null);
|
||||||
|
repositoryMock.Setup(x => x.GetByEmailAsync(request.Email, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync((UserEntity?)null);
|
||||||
|
hashingServiceMock.Setup(x => x.HashPassword(request.Password))
|
||||||
|
.Returns(("hash-value", "salt-value"));
|
||||||
|
repositoryMock.Setup(x => x.AddAsync(It.IsAny<UserEntity>(), It.IsAny<CancellationToken>()))
|
||||||
|
.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<UserEntity>(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<CancellationToken>()), Times.Once);
|
||||||
|
repositoryMock.VerifyAll();
|
||||||
|
hashingServiceMock.VerifyAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void CreateAsync_ThrowsConflict_WhenUserNameExists()
|
||||||
|
{
|
||||||
|
var repositoryMock = new Mock<IUserRepository>(MockBehavior.Strict);
|
||||||
|
var hashingServiceMock = new Mock<IPasswordHashingService>(MockBehavior.Strict);
|
||||||
|
var request = CreateRequest();
|
||||||
|
|
||||||
|
repositoryMock.Setup(x => x.GetByUserNameAsync(request.UserName, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(CreateEntity());
|
||||||
|
|
||||||
|
var sut = new UserService(repositoryMock.Object, hashingServiceMock.Object);
|
||||||
|
|
||||||
|
Assert.ThrowsAsync<ConflictException>(() => sut.CreateAsync(request));
|
||||||
|
repositoryMock.VerifyAll();
|
||||||
|
hashingServiceMock.VerifyNoOtherCalls();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void CreateAsync_ThrowsConflict_WhenEmailExists()
|
||||||
|
{
|
||||||
|
var repositoryMock = new Mock<IUserRepository>(MockBehavior.Strict);
|
||||||
|
var hashingServiceMock = new Mock<IPasswordHashingService>(MockBehavior.Strict);
|
||||||
|
var request = CreateRequest();
|
||||||
|
|
||||||
|
repositoryMock.Setup(x => x.GetByUserNameAsync(request.UserName, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync((UserEntity?)null);
|
||||||
|
repositoryMock.Setup(x => x.GetByEmailAsync(request.Email, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(CreateEntity());
|
||||||
|
|
||||||
|
var sut = new UserService(repositoryMock.Object, hashingServiceMock.Object);
|
||||||
|
|
||||||
|
Assert.ThrowsAsync<ConflictException>(() => sut.CreateAsync(request));
|
||||||
|
repositoryMock.VerifyAll();
|
||||||
|
hashingServiceMock.VerifyNoOtherCalls();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void CreateAsync_ThrowsBadRequest_WhenPasswordIsNotComplexEnough()
|
||||||
|
{
|
||||||
|
var repositoryMock = new Mock<IUserRepository>(MockBehavior.Strict);
|
||||||
|
var hashingServiceMock = new Mock<IPasswordHashingService>(MockBehavior.Strict);
|
||||||
|
var request = CreateRequest();
|
||||||
|
request.Password = "weak";
|
||||||
|
|
||||||
|
repositoryMock.Setup(x => x.GetByUserNameAsync(request.UserName, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync((UserEntity?)null);
|
||||||
|
repositoryMock.Setup(x => x.GetByEmailAsync(request.Email, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync((UserEntity?)null);
|
||||||
|
|
||||||
|
var sut = new UserService(repositoryMock.Object, hashingServiceMock.Object);
|
||||||
|
|
||||||
|
Assert.ThrowsAsync<BadRequestException>(() => sut.CreateAsync(request));
|
||||||
|
repositoryMock.VerifyAll();
|
||||||
|
hashingServiceMock.VerifyNoOtherCalls();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task UpdateAsync_UpdatesUserWithoutPassword_WhenPasswordIsNotProvided()
|
||||||
|
{
|
||||||
|
var repositoryMock = new Mock<IUserRepository>(MockBehavior.Strict);
|
||||||
|
var hashingServiceMock = new Mock<IPasswordHashingService>(MockBehavior.Strict);
|
||||||
|
var id = Guid.NewGuid();
|
||||||
|
var existing = CreateEntity(id);
|
||||||
|
var request = CreateUpdateRequest(password: null);
|
||||||
|
|
||||||
|
repositoryMock.Setup(x => x.GetByIdAsync(id, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(existing);
|
||||||
|
repositoryMock.Setup(x => x.GetByUserNameAsync(request.UserName, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync((UserEntity?)null);
|
||||||
|
repositoryMock.Setup(x => x.GetByEmailAsync(request.Email, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync((UserEntity?)null);
|
||||||
|
repositoryMock.Setup(x => x.UpdateAsync(existing, It.IsAny<CancellationToken>()))
|
||||||
|
.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<IUserRepository>(MockBehavior.Strict);
|
||||||
|
var hashingServiceMock = new Mock<IPasswordHashingService>(MockBehavior.Strict);
|
||||||
|
var id = Guid.NewGuid();
|
||||||
|
var existing = CreateEntity(id);
|
||||||
|
var request = CreateUpdateRequest(password: "UpdatedPassword!123");
|
||||||
|
|
||||||
|
repositoryMock.Setup(x => x.GetByIdAsync(id, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(existing);
|
||||||
|
repositoryMock.Setup(x => x.GetByUserNameAsync(request.UserName, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync((UserEntity?)null);
|
||||||
|
repositoryMock.Setup(x => x.GetByEmailAsync(request.Email, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync((UserEntity?)null);
|
||||||
|
hashingServiceMock.Setup(x => x.HashPassword(request.Password!))
|
||||||
|
.Returns(("new-hash", "new-salt"));
|
||||||
|
repositoryMock.Setup(x => x.UpdateAsync(existing, It.IsAny<CancellationToken>()))
|
||||||
|
.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<IUserRepository>(MockBehavior.Strict);
|
||||||
|
var hashingServiceMock = new Mock<IPasswordHashingService>(MockBehavior.Strict);
|
||||||
|
var id = Guid.NewGuid();
|
||||||
|
var request = CreateUpdateRequest(password: null);
|
||||||
|
|
||||||
|
repositoryMock.Setup(x => x.GetByIdAsync(id, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync((UserEntity?)null);
|
||||||
|
|
||||||
|
var sut = new UserService(repositoryMock.Object, hashingServiceMock.Object);
|
||||||
|
|
||||||
|
Assert.ThrowsAsync<NotFoundException>(() => sut.UpdateAsync(id, request));
|
||||||
|
repositoryMock.VerifyAll();
|
||||||
|
hashingServiceMock.VerifyNoOtherCalls();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void UpdateAsync_ThrowsConflict_WhenUserNameBelongsToAnotherUser()
|
||||||
|
{
|
||||||
|
var repositoryMock = new Mock<IUserRepository>(MockBehavior.Strict);
|
||||||
|
var hashingServiceMock = new Mock<IPasswordHashingService>(MockBehavior.Strict);
|
||||||
|
var id = Guid.NewGuid();
|
||||||
|
var request = CreateUpdateRequest(password: null);
|
||||||
|
|
||||||
|
repositoryMock.Setup(x => x.GetByIdAsync(id, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(CreateEntity(id));
|
||||||
|
repositoryMock.Setup(x => x.GetByUserNameAsync(request.UserName, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(CreateEntity(Guid.NewGuid()));
|
||||||
|
|
||||||
|
var sut = new UserService(repositoryMock.Object, hashingServiceMock.Object);
|
||||||
|
|
||||||
|
Assert.ThrowsAsync<ConflictException>(() => sut.UpdateAsync(id, request));
|
||||||
|
repositoryMock.VerifyAll();
|
||||||
|
hashingServiceMock.VerifyNoOtherCalls();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void UpdateAsync_ThrowsConflict_WhenEmailBelongsToAnotherUser()
|
||||||
|
{
|
||||||
|
var repositoryMock = new Mock<IUserRepository>(MockBehavior.Strict);
|
||||||
|
var hashingServiceMock = new Mock<IPasswordHashingService>(MockBehavior.Strict);
|
||||||
|
var id = Guid.NewGuid();
|
||||||
|
var request = CreateUpdateRequest(password: null);
|
||||||
|
|
||||||
|
repositoryMock.Setup(x => x.GetByIdAsync(id, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(CreateEntity(id));
|
||||||
|
repositoryMock.Setup(x => x.GetByUserNameAsync(request.UserName, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync((UserEntity?)null);
|
||||||
|
repositoryMock.Setup(x => x.GetByEmailAsync(request.Email, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(CreateEntity(Guid.NewGuid()));
|
||||||
|
|
||||||
|
var sut = new UserService(repositoryMock.Object, hashingServiceMock.Object);
|
||||||
|
|
||||||
|
Assert.ThrowsAsync<ConflictException>(() => sut.UpdateAsync(id, request));
|
||||||
|
repositoryMock.VerifyAll();
|
||||||
|
hashingServiceMock.VerifyNoOtherCalls();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void UpdateAsync_ThrowsBadRequest_WhenProvidedPasswordIsWeak()
|
||||||
|
{
|
||||||
|
var repositoryMock = new Mock<IUserRepository>(MockBehavior.Strict);
|
||||||
|
var hashingServiceMock = new Mock<IPasswordHashingService>(MockBehavior.Strict);
|
||||||
|
var id = Guid.NewGuid();
|
||||||
|
var request = CreateUpdateRequest(password: "weak");
|
||||||
|
|
||||||
|
repositoryMock.Setup(x => x.GetByIdAsync(id, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(CreateEntity(id));
|
||||||
|
repositoryMock.Setup(x => x.GetByUserNameAsync(request.UserName, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync((UserEntity?)null);
|
||||||
|
repositoryMock.Setup(x => x.GetByEmailAsync(request.Email, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync((UserEntity?)null);
|
||||||
|
|
||||||
|
var sut = new UserService(repositoryMock.Object, hashingServiceMock.Object);
|
||||||
|
|
||||||
|
Assert.ThrowsAsync<BadRequestException>(() => sut.UpdateAsync(id, request));
|
||||||
|
repositoryMock.VerifyAll();
|
||||||
|
hashingServiceMock.VerifyNoOtherCalls();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GetByIdAsync_ReturnsUser_WhenUserExists()
|
||||||
|
{
|
||||||
|
var repositoryMock = new Mock<IUserRepository>(MockBehavior.Strict);
|
||||||
|
var hashingServiceMock = new Mock<IPasswordHashingService>(MockBehavior.Strict);
|
||||||
|
var id = Guid.NewGuid();
|
||||||
|
var user = CreateEntity(id);
|
||||||
|
|
||||||
|
repositoryMock.Setup(x => x.GetByIdAsync(id, It.IsAny<CancellationToken>()))
|
||||||
|
.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<IUserRepository>(MockBehavior.Strict);
|
||||||
|
var hashingServiceMock = new Mock<IPasswordHashingService>(MockBehavior.Strict);
|
||||||
|
var id = Guid.NewGuid();
|
||||||
|
|
||||||
|
repositoryMock.Setup(x => x.GetByIdAsync(id, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync((UserEntity?)null);
|
||||||
|
|
||||||
|
var sut = new UserService(repositoryMock.Object, hashingServiceMock.Object);
|
||||||
|
|
||||||
|
Assert.ThrowsAsync<NotFoundException>(() => sut.GetByIdAsync(id));
|
||||||
|
repositoryMock.VerifyAll();
|
||||||
|
hashingServiceMock.VerifyNoOtherCalls();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task DeleteAsync_DeletesUser_WhenUserExists()
|
||||||
|
{
|
||||||
|
var repositoryMock = new Mock<IUserRepository>(MockBehavior.Strict);
|
||||||
|
var hashingServiceMock = new Mock<IPasswordHashingService>(MockBehavior.Strict);
|
||||||
|
var id = Guid.NewGuid();
|
||||||
|
var user = CreateEntity(id);
|
||||||
|
|
||||||
|
repositoryMock.Setup(x => x.GetByIdAsync(id, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(user);
|
||||||
|
repositoryMock.Setup(x => x.DeleteAsync(user, It.IsAny<CancellationToken>()))
|
||||||
|
.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<IUserRepository>(MockBehavior.Strict);
|
||||||
|
var hashingServiceMock = new Mock<IPasswordHashingService>(MockBehavior.Strict);
|
||||||
|
var id = Guid.NewGuid();
|
||||||
|
|
||||||
|
repositoryMock.Setup(x => x.GetByIdAsync(id, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync((UserEntity?)null);
|
||||||
|
|
||||||
|
var sut = new UserService(repositoryMock.Object, hashingServiceMock.Object);
|
||||||
|
|
||||||
|
Assert.ThrowsAsync<NotFoundException>(() => sut.DeleteAsync(id));
|
||||||
|
repositoryMock.VerifyAll();
|
||||||
|
hashingServiceMock.VerifyNoOtherCalls();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task ValidatePasswordAsync_ReturnsResult_WhenUserExists()
|
||||||
|
{
|
||||||
|
var repositoryMock = new Mock<IUserRepository>(MockBehavior.Strict);
|
||||||
|
var hashingServiceMock = new Mock<IPasswordHashingService>(MockBehavior.Strict);
|
||||||
|
var id = Guid.NewGuid();
|
||||||
|
var user = CreateEntity(id);
|
||||||
|
const string password = "AnyPassword!123";
|
||||||
|
|
||||||
|
repositoryMock.Setup(x => x.GetByIdAsync(id, It.IsAny<CancellationToken>()))
|
||||||
|
.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<IUserRepository>(MockBehavior.Strict);
|
||||||
|
var hashingServiceMock = new Mock<IPasswordHashingService>(MockBehavior.Strict);
|
||||||
|
var id = Guid.NewGuid();
|
||||||
|
|
||||||
|
repositoryMock.Setup(x => x.GetByIdAsync(id, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync((UserEntity?)null);
|
||||||
|
|
||||||
|
var sut = new UserService(repositoryMock.Object, hashingServiceMock.Object);
|
||||||
|
|
||||||
|
Assert.ThrowsAsync<NotFoundException>(() => 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"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
68
README.md
Normal file
68
README.md
Normal file
@@ -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 <token>`
|
||||||
|
|
||||||
|
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 <token>`.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user