Dev #11

Merged
serega404 merged 87 commits from dev into main 2026-05-25 03:22:55 +03:00
11 changed files with 206 additions and 8 deletions
Showing only changes of commit 6eeacd80cc - Show all commits
@@ -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); .Returns(authResult);
stub.RefreshTokenAsync(Arg.Any<string>()).Returns(authResult); stub.RefreshTokenAsync(Arg.Any<string>()).Returns(authResult);
stub.GetCurrentUserAsync(Arg.Any<int>()) 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; return stub;
} }
@@ -198,9 +198,11 @@ public class AuthController : ControllerBase
/// </remarks> /// </remarks>
/// <response code="200">Новая пара токенов.</response> /// <response code="200">Новая пара токенов.</response>
/// <response code="401">Refresh token отсутствует, просрочен или отозван.</response> /// <response code="401">Refresh token отсутствует, просрочен или отозван.</response>
/// <response code="403">Аккаунт деактивирован или refresh token недействителен.</response>
[HttpPost("refresh")] [HttpPost("refresh")]
[ProducesResponseType(typeof(AuthResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(AuthResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<AuthResponse>> Refresh() public async Task<ActionResult<AuthResponse>> Refresh()
{ {
var refreshToken = Request.Cookies["refreshToken"]; var refreshToken = Request.Cookies["refreshToken"];
@@ -236,8 +238,9 @@ public class AuthController : ControllerBase
/// <response code="404">Пользователь не найден в БД (рассинхронизация токена).</response> /// <response code="404">Пользователь не найден в БД (рассинхронизация токена).</response>
[Authorize] [Authorize]
[HttpGet("me")] [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.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> Me() 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": { "content": {
"application/json": { "application/json": {
"schema": { "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": { "404": {
"description": "Пользователь не найден в БД (рассинхронизация токена).", "description": "Пользователь не найден в БД (рассинхронизация токена).",
"content": { "content": {
@@ -4563,6 +4583,51 @@
}, },
"additionalProperties": false "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": { "DevLoginRequest": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -15,6 +15,18 @@ public record UserDto(
DateTime CreatedAt 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( public record UserStatsDto(
int TotalLectures, int TotalLectures,
int AttendedLectures, 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> DevLoginAsync(string email, string? displayName, IReadOnlyCollection<Domain.Enums.UserRole> roles, string? ipAddress = null);
Task<AuthResult> RefreshTokenAsync(string refreshToken); Task<AuthResult> RefreshTokenAsync(string refreshToken);
Task RevokeRefreshTokenAsync(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 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( public static UserAuthDto ToAuthDto(this User user) => new(
user.Id, user.Email, user.DisplayName, user.Roles.Select(r => r.Role).OrderBy(r => r).ToList() 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) if (token == null || !token.IsActive)
throw new ForbiddenException("Неверный или просроченный токен обновления."); throw new ForbiddenException("Неверный или просроченный токен обновления.");
if (!token.User.IsActive)
{
token.RevokedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
throw new ForbiddenException("Аккаунт деактивирован.");
}
// Revoke old token // Revoke old token
token.RevokedAt = DateTime.UtcNow; 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 var user = await _db.Users
.Include(u => u.Roles) .Include(u => u.Roles)
.FirstOrDefaultAsync(u => u.Id == userId) .FirstOrDefaultAsync(u => u.Id == userId)
?? throw new NotFoundException("User", 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) private async Task TrySendLoginNotificationAsync(User user, string? ipAddress)
+2 -1
View File
@@ -16,6 +16,7 @@ import type {
SyncStatusDto, SyncStatusDto,
TagDto, TagDto,
UserAchievementDto, UserAchievementDto,
CurrentUserDto,
UserDto, UserDto,
UserQuery, UserQuery,
UserNotificationDto, UserNotificationDto,
@@ -30,7 +31,7 @@ export const authApi = {
}), }),
refresh: () => apiRequest<AuthResponse>('/auth/refresh', { method: 'POST' }), refresh: () => apiRequest<AuthResponse>('/auth/refresh', { method: 'POST' }),
logout: () => apiRequest<void>('/auth/logout', { method: 'POST' }), logout: () => apiRequest<void>('/auth/logout', { method: 'POST' }),
me: () => apiRequest<UserDto>('/auth/me'), me: () => apiRequest<CurrentUserDto>('/auth/me'),
} }
export const lecturesApi = { export const lecturesApi = {
+2 -1
View File
@@ -4,6 +4,7 @@ import type {
CoinTransactionDto, CoinTransactionDto,
LectureDto, LectureDto,
ReviewDto, ReviewDto,
CurrentUserDto,
UserAuthDto, UserAuthDto,
UserDto, UserDto,
UserStatsDto, UserStatsDto,
@@ -29,7 +30,7 @@ function getDefaultActiveRole(roles: UserRole[]): UserRole {
return 'student' 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) const roles = mapApiRoles(user.roles)
return { return {
id: String(user.id), id: String(user.id),
+8
View File
@@ -44,6 +44,14 @@ export interface UserDto extends UserAuthDto {
createdAt: string createdAt: string
} }
export interface CurrentUserDto extends UserAuthDto {
avatarUrl?: string | null
xp: number
coins: number
level: number
createdAt: string
}
export interface UserQuery { export interface UserQuery {
Search?: string Search?: string
Role?: ApiUserRole Role?: ApiUserRole