Add initial implementation of API, database, and user management components.

This commit is contained in:
2026-03-15 23:17:51 +01:00
commit 0543120f3b
53 changed files with 2241 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
bin/
obj/
/packages/
riderModule.iml
/_ReSharper.Caches/

15
.idea/.idea.MikrocopTest/.idea/.gitignore generated vendored Normal file
View 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/

View 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>

View 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
View 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>

View 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;
}

View 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);
}
}

View 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());
}
}

View 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;
}

View 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;
}

View 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; }
}

View 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; }
}

View 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; }
}

View 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;
}

View File

@@ -0,0 +1,6 @@
namespace MikrocopApi.Dtos;
public sealed class ValidatePasswordResponseDto
{
public bool IsValid { get; init; }
}

View 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; }
}

View 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";
}

View 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";
}

View 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";
}

View 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";
}

View 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;
}
}

View 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
};
}
}

View 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
};
}
}

View 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;
}
}

View 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
});
}
}
}

View 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>

View 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
View 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();

View 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"
}
}
}
}

View 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();
}
}

View File

@@ -0,0 +1,8 @@
using MikrocopApi.Dtos;
namespace MikrocopApi.Services;
public interface IAuthService
{
Task<LoginResponseDto> LoginAsync(LoginRequestDto request, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,8 @@
using MikrocopDb.Entities;
namespace MikrocopApi.Services;
public interface IJwtTokenService
{
(string AccessToken, DateTime ExpiresAtUtc) Generate(UserEntity user);
}

View File

@@ -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);
}

View 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);
}

View 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);
}
}

View 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);
}
}

View 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.");
}
}
}

View 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
}
}

View 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": "*"
}

View 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();
});
}
}

View 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);
}
}

View 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; }
}

View 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
}
}
}

View 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");
}
}
}

View 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
}
}
}

View 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>

View 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);
}

View 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
View 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

View 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"
};
}
}

View 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>

View 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
View 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
```