feat: добавил интеграционные тесты

This commit is contained in:
2026-05-11 03:42:47 +03:00
parent fc380c7c51
commit f168050637
19 changed files with 1616 additions and 51 deletions
@@ -8,23 +8,36 @@ using System.Security.Claims;
namespace UniVerse.Api.Controllers;
/// <summary>Аутентификация и управление сессией пользователя.</summary>
[ApiController]
[Route("api/v1/auth")]
[Produces("application/json")]
public class AuthController : ControllerBase
{
private readonly IAuthService _auth;
private readonly IConfiguration _config;
private const string MicrosoftStateCookieName = "msAuthState";
private const string MicrosoftStateCookieName = "msAuthState";
private const string MicrosoftReturnUrlCookieName = "msAuthReturnUrl";
public AuthController(IAuthService auth, IConfiguration config)
{
_auth = auth;
_auth = auth;
_config = config;
}
/// <summary>Вход через Microsoft Entra ID (SPA/PKCE flow).</summary>
/// <remarks>
/// Фронтенд самостоятельно обрабатывает редирект к Microsoft и передаёт сюда
/// полученный authorization code. В ответ возвращается пара JWT-токенов;
/// refresh token устанавливается в HttpOnly cookie.
/// </remarks>
/// <param name="request">Authorization code и redirect URI из Microsoft OAuth2.</param>
/// <response code="200">Успешный вход — возвращает access token и данные пользователя.</response>
/// <response code="400">Неверный или просроченный authorization code.</response>
[HttpPost("login/microsoft")]
[ProducesResponseType(typeof(AuthResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<AuthResponse>> LoginMicrosoft([FromBody] LoginMicrosoftRequest request)
{
var result = await _auth.LoginWithMicrosoftAsync(request.AuthorizationCode, request.RedirectUri);
@@ -32,10 +45,19 @@ public class AuthController : ControllerBase
return Ok(result.Response);
}
// Server-driven auth flow: frontend just navigates here; backend builds Microsoft authorize URL.
// Optional returnUrl is stored in a short-lived cookie and used by callback.
/// <summary>Инициация server-driven входа через Microsoft (редирект-flow).</summary>
/// <remarks>
/// Браузер переходит на этот URL; backend строит Microsoft authorize URL с CSRF state
/// и редиректит пользователя на `login.microsoftonline.com`.
/// После успешного входа Microsoft редиректит на `GET /api/v1/auth/callback/microsoft`.
/// </remarks>
/// <param name="returnUrl">URL для редиректа после успешного входа (опционально).</param>
/// <response code="302">Редирект на Microsoft authorize endpoint.</response>
/// <response code="500">Microsoft authentication не настроен (AzureAd:TenantId/ClientId отсутствуют).</response>
[HttpGet("login/microsoft")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status302Found)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public IActionResult LoginMicrosoftRedirect([FromQuery] string? returnUrl = null)
{
var tenantId = _config["AzureAd:TenantId"];
@@ -51,9 +73,9 @@ public class AuthController : ControllerBase
Response.Cookies.Append(MicrosoftStateCookieName, state, new CookieOptions
{
HttpOnly = true,
Secure = Request.IsHttps,
Secure = Request.IsHttps,
SameSite = SameSiteMode.Lax,
Expires = DateTimeOffset.UtcNow.AddMinutes(10)
Expires = DateTimeOffset.UtcNow.AddMinutes(10)
});
if (!string.IsNullOrWhiteSpace(returnUrl) && IsAllowedReturnUrl(returnUrl))
@@ -61,9 +83,9 @@ public class AuthController : ControllerBase
Response.Cookies.Append(MicrosoftReturnUrlCookieName, returnUrl, new CookieOptions
{
HttpOnly = true,
Secure = Request.IsHttps,
Secure = Request.IsHttps,
SameSite = SameSiteMode.Lax,
Expires = DateTimeOffset.UtcNow.AddMinutes(10)
Expires = DateTimeOffset.UtcNow.AddMinutes(10)
});
}
@@ -72,19 +94,37 @@ public class AuthController : ControllerBase
var authorizeUrl = QueryHelpers.AddQueryString(authorizeEndpoint, new Dictionary<string, string?>
{
["client_id"] = clientId,
["client_id"] = clientId,
["response_type"] = "code",
["redirect_uri"] = redirectUri,
["redirect_uri"] = redirectUri,
["response_mode"] = "query",
["scope"] = scope,
["state"] = state
["scope"] = scope,
["state"] = state
});
return Redirect(authorizeUrl);
}
/// <summary>OAuth2 callback — обмен code на токены (server-driven flow).</summary>
/// <remarks>
/// Microsoft редиректит браузер сюда после успешного входа.
/// Backend валидирует CSRF state, обменивает code на токены,
/// устанавливает refresh token cookie и редиректит на `returnUrl` с access token в URL-фрагменте.
/// </remarks>
/// <param name="code">Authorization code от Microsoft.</param>
/// <param name="state">CSRF state для верификации.</param>
/// <param name="error">Код ошибки от Microsoft (если вход не удался).</param>
/// <param name="errorDescription">Описание ошибки от Microsoft.</param>
/// <response code="302">Успешный вход — редирект на returnUrl с токеном в URL-фрагменте.</response>
/// <response code="200">Если returnUrl не задан — возвращает JSON с токенами (удобно для тестирования).</response>
/// <response code="400">Отсутствует authorization code.</response>
/// <response code="401">Ошибка от Microsoft или невалидный CSRF state.</response>
[HttpGet("callback/microsoft")]
[AllowAnonymous]
[ProducesResponseType(typeof(AuthResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status302Found)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> CallbackMicrosoft(
[FromQuery] string? code = null,
[FromQuery] string? state = null,
@@ -129,7 +169,17 @@ public class AuthController : ControllerBase
return Ok(result.Response);
}
/// <summary>Dev-only вход без OAuth (только в Development-окружении).</summary>
/// <remarks>
/// Создаёт или находит пользователя по email без реального OAuth flow.
/// Возвращает 404 в Production и Staging.
/// </remarks>
/// <param name="request">Email, отображаемое имя и роль тестового пользователя.</param>
/// <response code="200">Успешный вход.</response>
/// <response code="404">Endpoint недоступен вне Development.</response>
[HttpPost("login/dev")]
[ProducesResponseType(typeof(AuthResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<AuthResponse>> DevLogin([FromBody] DevLoginRequest request)
{
if (!HttpContext.RequestServices.GetRequiredService<IWebHostEnvironment>().IsDevelopment())
@@ -139,7 +189,16 @@ public class AuthController : ControllerBase
return Ok(result.Response);
}
/// <summary>Обновление access token по refresh token из HttpOnly cookie.</summary>
/// <remarks>
/// Refresh token читается из HttpOnly cookie `refreshToken` (устанавливается при входе).
/// Возвращает новую пару токенов и обновляет cookie.
/// </remarks>
/// <response code="200">Новая пара токенов.</response>
/// <response code="401">Refresh token отсутствует, просрочен или отозван.</response>
[HttpPost("refresh")]
[ProducesResponseType(typeof(AuthResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult<AuthResponse>> Refresh()
{
var refreshToken = Request.Cookies["refreshToken"];
@@ -149,8 +208,17 @@ public class AuthController : ControllerBase
return Ok(result.Response);
}
/// <summary>Выход из системы — отзыв refresh token.</summary>
/// <remarks>
/// Инвалидирует текущий refresh token в БД и удаляет cookie.
/// После этого вызова access token остаётся валидным до истечения его TTL (30 минут).
/// </remarks>
/// <response code="204">Выход выполнен успешно.</response>
/// <response code="401">Требуется аутентификация.</response>
[Authorize]
[HttpPost("logout")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> Logout()
{
var refreshToken = Request.Cookies["refreshToken"];
@@ -160,8 +228,15 @@ public class AuthController : ControllerBase
return NoContent();
}
/// <summary>Получение профиля текущего авторизованного пользователя.</summary>
/// <response code="200">Данные текущего пользователя.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="404">Пользователь не найден в БД (рассинхронизация токена).</response>
[Authorize]
[HttpGet("me")]
[ProducesResponseType(typeof(UniVerse.Application.DTOs.Users.UserDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> Me()
{
var userId = int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)
@@ -175,7 +250,7 @@ public class AuthController : ControllerBase
Response.Cookies.Append("refreshToken", token, new CookieOptions
{
HttpOnly = true, Secure = true, SameSite = SameSiteMode.Strict,
Expires = DateTime.UtcNow.AddDays(30)
Expires = DateTime.UtcNow.AddDays(30)
});
}