fix: скрыл инфу о активности ака и перестал выдавать рефреши если ак не активен

This commit is contained in:
2026-05-18 03:15:45 +03:00
parent 934682f035
commit 6eeacd80cc
11 changed files with 206 additions and 8 deletions
@@ -0,0 +1,93 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using UniVerse.Application.DTOs.Notifications;
using UniVerse.Application.Interfaces;
using UniVerse.Domain.Entities;
using UniVerse.Domain.Enums;
using UniVerse.Domain.Exceptions;
using UniVerse.Infrastructure.Data;
using UniVerse.Infrastructure.Services;
using Xunit;
namespace UniVerse.Api.Tests.Auth;
public class AuthServiceTests
{
[Fact]
public async Task RefreshTokenAsync_InactiveUser_RevokesTokenAndThrowsForbidden()
{
await using var db = CreateDbContext();
db.Users.Add(new User
{
Id = 1,
Email = "blocked@test.local",
IsActive = false,
Roles = [new UserRoleAssignment { UserId = 1, Role = UserRole.Student }]
});
db.RefreshTokens.Add(new RefreshToken
{
Id = 1,
UserId = 1,
Token = "refresh-token",
ExpiresAt = DateTime.UtcNow.AddDays(1),
CreatedAt = DateTime.UtcNow
});
await db.SaveChangesAsync();
var service = CreateService(db);
await Assert.ThrowsAsync<ForbiddenException>(() => service.RefreshTokenAsync("refresh-token"));
var token = await db.RefreshTokens.SingleAsync(t => t.Token == "refresh-token");
Assert.NotNull(token.RevokedAt);
}
[Fact]
public async Task GetCurrentUserAsync_InactiveUser_ThrowsForbidden()
{
await using var db = CreateDbContext();
db.Users.Add(new User
{
Id = 1,
Email = "blocked@test.local",
IsActive = false,
Roles = [new UserRoleAssignment { UserId = 1, Role = UserRole.Student }]
});
await db.SaveChangesAsync();
var service = CreateService(db);
await Assert.ThrowsAsync<ForbiddenException>(() => service.GetCurrentUserAsync(1));
}
private static AppDbContext CreateDbContext()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase($"AuthServiceTests_{Guid.NewGuid()}")
.Options;
return new AppDbContext(options);
}
private static AuthService CreateService(AppDbContext db)
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Jwt:Secret"] = "test-secret-test-secret-test-secret-test-secret",
["Jwt:Issuer"] = "UniVerse.Tests",
["Jwt:Audience"] = "UniVerse.Tests",
["Jwt:AccessTokenExpirationMinutes"] = "15",
["Jwt:RefreshTokenExpirationDays"] = "30"
})
.Build();
var gamification = Substitute.For<IGamificationService>();
gamification.CalculateLevelAsync(Arg.Any<int>()).Returns(1);
var notifications = Substitute.For<INotificationService>();
notifications.SendAsync(Arg.Any<NotificationMessage>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
return new AuthService(db, config, gamification, notifications, NullLogger<AuthService>.Instance);
}
}
@@ -123,7 +123,7 @@ public class ApiWebApplicationFactory : WebApplicationFactory<Program>
.Returns(authResult);
stub.RefreshTokenAsync(Arg.Any<string>()).Returns(authResult);
stub.GetCurrentUserAsync(Arg.Any<int>())
.Returns(new UserDto(1, "test@test.com", "Test", null, [UserRole.Student], true, 0, 0, 1, DateTime.UtcNow));
.Returns(new CurrentUserDto(1, "test@test.com", "Test", null, [UserRole.Student], 0, 0, 1, DateTime.UtcNow));
return stub;
}
@@ -198,9 +198,11 @@ public class AuthController : ControllerBase
/// </remarks>
/// <response code="200">Новая пара токенов.</response>
/// <response code="401">Refresh token отсутствует, просрочен или отозван.</response>
/// <response code="403">Аккаунт деактивирован или refresh token недействителен.</response>
[HttpPost("refresh")]
[ProducesResponseType(typeof(AuthResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<AuthResponse>> Refresh()
{
var refreshToken = Request.Cookies["refreshToken"];
@@ -236,8 +238,9 @@ public class AuthController : ControllerBase
/// <response code="404">Пользователь не найден в БД (рассинхронизация токена).</response>
[Authorize]
[HttpGet("me")]
[ProducesResponseType(typeof(UniVerse.Application.DTOs.Users.UserDto), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(UniVerse.Application.DTOs.Users.CurrentUserDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> Me()
{
+66 -1
View File
@@ -547,6 +547,16 @@
}
}
}
},
"403": {
"description": "Аккаунт деактивирован или refresh token недействителен.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
}
}
@@ -593,7 +603,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserDto"
"$ref": "#/components/schemas/CurrentUserDto"
}
}
}
@@ -608,6 +618,16 @@
}
}
},
"403": {
"description": "Forbidden",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"404": {
"description": "Пользователь не найден в БД (рассинхронизация токена).",
"content": {
@@ -4563,6 +4583,51 @@
},
"additionalProperties": false
},
"CurrentUserDto": {
"type": "object",
"properties": {
"id": {
"type": "integer",
"format": "int32"
},
"email": {
"type": "string",
"nullable": true
},
"displayName": {
"type": "string",
"nullable": true
},
"avatarUrl": {
"type": "string",
"nullable": true
},
"roles": {
"type": "array",
"items": {
"$ref": "#/components/schemas/UserRole"
},
"nullable": true
},
"xp": {
"type": "integer",
"format": "int32"
},
"coins": {
"type": "integer",
"format": "int32"
},
"level": {
"type": "integer",
"format": "int32"
},
"createdAt": {
"type": "string",
"format": "date-time"
}
},
"additionalProperties": false
},
"DevLoginRequest": {
"type": "object",
"properties": {
@@ -15,6 +15,18 @@ public record UserDto(
DateTime CreatedAt
);
public record CurrentUserDto(
int Id,
string Email,
string? DisplayName,
string? AvatarUrl,
IReadOnlyList<UserRole> Roles,
int Xp,
int Coins,
int Level,
DateTime CreatedAt
);
public record UserStatsDto(
int TotalLectures,
int AttendedLectures,
@@ -9,5 +9,5 @@ public interface IAuthService
Task<AuthResult> DevLoginAsync(string email, string? displayName, IReadOnlyCollection<Domain.Enums.UserRole> roles, string? ipAddress = null);
Task<AuthResult> RefreshTokenAsync(string refreshToken);
Task RevokeRefreshTokenAsync(string refreshToken);
Task<UserDto> GetCurrentUserAsync(int userId);
Task<CurrentUserDto> GetCurrentUserAsync(int userId);
}
@@ -19,6 +19,11 @@ public static class MappingExtensions
user.Roles.Select(r => r.Role).OrderBy(r => r).ToList(), user.IsActive, user.Xp, user.Coins, level, user.CreatedAt
);
public static CurrentUserDto ToCurrentUserDto(this User user, int level) => new(
user.Id, user.Email, user.DisplayName, user.AvatarUrl,
user.Roles.Select(r => r.Role).OrderBy(r => r).ToList(), user.Xp, user.Coins, level, user.CreatedAt
);
public static UserAuthDto ToAuthDto(this User user) => new(
user.Id, user.Email, user.DisplayName, user.Roles.Select(r => r.Role).OrderBy(r => r).ToList()
);
@@ -186,6 +186,13 @@ public class AuthService : IAuthService
if (token == null || !token.IsActive)
throw new ForbiddenException("Неверный или просроченный токен обновления.");
if (!token.User.IsActive)
{
token.RevokedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
throw new ForbiddenException("Аккаунт деактивирован.");
}
// Revoke old token
token.RevokedAt = DateTime.UtcNow;
@@ -215,13 +222,16 @@ public class AuthService : IAuthService
}
}
public async Task<UserDto> GetCurrentUserAsync(int userId)
public async Task<CurrentUserDto> GetCurrentUserAsync(int userId)
{
var user = await _db.Users
.Include(u => u.Roles)
.FirstOrDefaultAsync(u => u.Id == userId)
?? throw new NotFoundException("User", userId);
return user.ToDto(await _gamification.CalculateLevelAsync(user.Xp));
if (!user.IsActive)
throw new ForbiddenException("Аккаунт деактивирован.");
return user.ToCurrentUserDto(await _gamification.CalculateLevelAsync(user.Xp));
}
private async Task TrySendLoginNotificationAsync(User user, string? ipAddress)