diff --git a/backend/UniVerse.Api.Tests/Auth/AuthServiceTests.cs b/backend/UniVerse.Api.Tests/Auth/AuthServiceTests.cs new file mode 100644 index 0000000..b62a111 --- /dev/null +++ b/backend/UniVerse.Api.Tests/Auth/AuthServiceTests.cs @@ -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(() => 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(() => service.GetCurrentUserAsync(1)); + } + + private static AppDbContext CreateDbContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase($"AuthServiceTests_{Guid.NewGuid()}") + .Options; + return new AppDbContext(options); + } + + private static AuthService CreateService(AppDbContext db) + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["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(); + gamification.CalculateLevelAsync(Arg.Any()).Returns(1); + + var notifications = Substitute.For(); + notifications.SendAsync(Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); + + return new AuthService(db, config, gamification, notifications, NullLogger.Instance); + } +} diff --git a/backend/UniVerse.Api.Tests/Helpers/ApiWebApplicationFactory.cs b/backend/UniVerse.Api.Tests/Helpers/ApiWebApplicationFactory.cs index 1a19e1c..13cf403 100644 --- a/backend/UniVerse.Api.Tests/Helpers/ApiWebApplicationFactory.cs +++ b/backend/UniVerse.Api.Tests/Helpers/ApiWebApplicationFactory.cs @@ -123,7 +123,7 @@ public class ApiWebApplicationFactory : WebApplicationFactory .Returns(authResult); stub.RefreshTokenAsync(Arg.Any()).Returns(authResult); stub.GetCurrentUserAsync(Arg.Any()) - .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; } diff --git a/backend/UniVerse.Api/Controllers/AuthController.cs b/backend/UniVerse.Api/Controllers/AuthController.cs index db9d462..e59b4d5 100644 --- a/backend/UniVerse.Api/Controllers/AuthController.cs +++ b/backend/UniVerse.Api/Controllers/AuthController.cs @@ -198,9 +198,11 @@ public class AuthController : ControllerBase /// /// Новая пара токенов. /// Refresh token отсутствует, просрочен или отозван. + /// Аккаунт деактивирован или refresh token недействителен. [HttpPost("refresh")] [ProducesResponseType(typeof(AuthResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] public async Task> Refresh() { var refreshToken = Request.Cookies["refreshToken"]; @@ -236,8 +238,9 @@ public class AuthController : ControllerBase /// Пользователь не найден в БД (рассинхронизация токена). [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 Me() { diff --git a/backend/UniVerse.Api/openapi.json b/backend/UniVerse.Api/openapi.json index d9c8332..5d77df6 100644 --- a/backend/UniVerse.Api/openapi.json +++ b/backend/UniVerse.Api/openapi.json @@ -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": { diff --git a/backend/UniVerse.Application/DTOs/Users/UserDtos.cs b/backend/UniVerse.Application/DTOs/Users/UserDtos.cs index 9628d92..2c89f2d 100644 --- a/backend/UniVerse.Application/DTOs/Users/UserDtos.cs +++ b/backend/UniVerse.Application/DTOs/Users/UserDtos.cs @@ -15,6 +15,18 @@ public record UserDto( DateTime CreatedAt ); +public record CurrentUserDto( + int Id, + string Email, + string? DisplayName, + string? AvatarUrl, + IReadOnlyList Roles, + int Xp, + int Coins, + int Level, + DateTime CreatedAt +); + public record UserStatsDto( int TotalLectures, int AttendedLectures, diff --git a/backend/UniVerse.Application/Interfaces/IAuthService.cs b/backend/UniVerse.Application/Interfaces/IAuthService.cs index 75b49cc..4179803 100644 --- a/backend/UniVerse.Application/Interfaces/IAuthService.cs +++ b/backend/UniVerse.Application/Interfaces/IAuthService.cs @@ -9,5 +9,5 @@ public interface IAuthService Task DevLoginAsync(string email, string? displayName, IReadOnlyCollection roles, string? ipAddress = null); Task RefreshTokenAsync(string refreshToken); Task RevokeRefreshTokenAsync(string refreshToken); - Task GetCurrentUserAsync(int userId); + Task GetCurrentUserAsync(int userId); } diff --git a/backend/UniVerse.Application/Mappings/MappingExtensions.cs b/backend/UniVerse.Application/Mappings/MappingExtensions.cs index 6b5942e..01d4f2f 100644 --- a/backend/UniVerse.Application/Mappings/MappingExtensions.cs +++ b/backend/UniVerse.Application/Mappings/MappingExtensions.cs @@ -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() ); diff --git a/backend/UniVerse.Infrastructure/Services/AuthService.cs b/backend/UniVerse.Infrastructure/Services/AuthService.cs index a48c5bb..3bf5595 100644 --- a/backend/UniVerse.Infrastructure/Services/AuthService.cs +++ b/backend/UniVerse.Infrastructure/Services/AuthService.cs @@ -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 GetCurrentUserAsync(int userId) + public async Task 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) diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 68125ff..51fe591 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -16,6 +16,7 @@ import type { SyncStatusDto, TagDto, UserAchievementDto, + CurrentUserDto, UserDto, UserQuery, UserNotificationDto, @@ -30,7 +31,7 @@ export const authApi = { }), refresh: () => apiRequest('/auth/refresh', { method: 'POST' }), logout: () => apiRequest('/auth/logout', { method: 'POST' }), - me: () => apiRequest('/auth/me'), + me: () => apiRequest('/auth/me'), } export const lecturesApi = { diff --git a/frontend/src/api/mappers.ts b/frontend/src/api/mappers.ts index 7c54682..2f633f3 100644 --- a/frontend/src/api/mappers.ts +++ b/frontend/src/api/mappers.ts @@ -4,6 +4,7 @@ import type { CoinTransactionDto, LectureDto, ReviewDto, + CurrentUserDto, UserAuthDto, UserDto, UserStatsDto, @@ -29,7 +30,7 @@ function getDefaultActiveRole(roles: UserRole[]): UserRole { return 'student' } -export function mapApiUser(user: UserAuthDto | UserDto, stats?: UserStatsDto): User { +export function mapApiUser(user: UserAuthDto | UserDto | CurrentUserDto, stats?: UserStatsDto): User { const roles = mapApiRoles(user.roles) return { id: String(user.id), diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index c3a2afc..20b5ec8 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -44,6 +44,14 @@ export interface UserDto extends UserAuthDto { createdAt: string } +export interface CurrentUserDto extends UserAuthDto { + avatarUrl?: string | null + xp: number + coins: number + level: number + createdAt: string +} + export interface UserQuery { Search?: string Role?: ApiUserRole