diff --git a/Govor.API.Tests/IntegrationTests/Controllers/OnlinePingingControllerTests.cs b/Govor.API.Tests/IntegrationTests/Controllers/OnlinePingingControllerTests.cs new file mode 100644 index 0000000..e68ae43 --- /dev/null +++ b/Govor.API.Tests/IntegrationTests/Controllers/OnlinePingingControllerTests.cs @@ -0,0 +1,131 @@ +using Govor.API.Controllers; +using Govor.Application.Interfaces; +using Govor.Application.Interfaces.Infrastructure.Extensions; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Moq; + +namespace Govor.API.Tests.IntegrationTests.Controllers; + +[TestFixture] +public class OnlinePingingControllerTests +{ + private Mock> _loggerMock; + private Mock _pingHandlerServiceMock; + private Mock _currentUserServiceMock; + private OnlinePingingController _controller; + + [SetUp] + public void SetUp() + { + _loggerMock = new Mock>(); + _pingHandlerServiceMock = new Mock(); + _currentUserServiceMock = new Mock(); + _controller = new OnlinePingingController(_loggerMock.Object, _pingHandlerServiceMock.Object, + _currentUserServiceMock.Object); + } + + [Test] + public async Task Ping_ValidUserId_ReturnsOk() + { + // Arrange + var userId = Guid.NewGuid(); + _currentUserServiceMock.Setup(x => x.GetCurrentUserId()).Returns(userId); + _pingHandlerServiceMock.Setup(x => x.Ping(userId)).Returns(Task.CompletedTask); + + // Act + var result = await _controller.Ping(); + + // Assert + Assert.That(result, Is.InstanceOf()); + _pingHandlerServiceMock.Verify(x => x.Ping(userId), Times.Once()); + _loggerMock.VerifyLog(LogLevel.Information, $"Ping from user {userId} processed successfully", Times.Once()); + } + + [Test] + public async Task Ping_InvalidOperationException_ReturnsBadRequest() + { + // Arrange + var userId = Guid.NewGuid(); + var exception = new InvalidOperationException("User not found"); + _currentUserServiceMock.Setup(x => x.GetCurrentUserId()).Returns(userId); + _pingHandlerServiceMock.Setup(x => x.Ping(userId)).ThrowsAsync(exception); + + // Act + var result = await _controller.Ping(); + + // Assert + Assert.That(result, Is.InstanceOf()); + var badRequestResult = (BadRequestObjectResult)result; + Assert.That(badRequestResult.Value, Is.EqualTo("User can't be found in our database.")); + _loggerMock.VerifyLog(LogLevel.Error, exception.Message, Times.Once()); + } + + [Test] + public async Task Ping_UnauthorizedAccessException_ReturnsForbid() + { + // Arrange + var userId = Guid.NewGuid(); + var exception = new UnauthorizedAccessException("Unauthorized"); + _currentUserServiceMock.Setup(x => x.GetCurrentUserId()).Returns(userId); + _pingHandlerServiceMock.Setup(x => x.Ping(userId)).ThrowsAsync(exception); + + // Act + var result = await _controller.Ping(); + + // Assert + Assert.That(result, Is.InstanceOf()); + _loggerMock.VerifyLog(LogLevel.Error, exception.Message, Times.Once()); + } + + [Test] + public async Task Ping_GeneralException_ReturnsStatusCode500() + { + // Arrange + var userId = Guid.NewGuid(); + var exception = new Exception("Unexpected error"); + _currentUserServiceMock.Setup(x => x.GetCurrentUserId()).Returns(userId); + _pingHandlerServiceMock.Setup(x => x.Ping(userId)).ThrowsAsync(exception); + + // Act + var result = await _controller.Ping(); + + // Assert + Assert.That(result, Is.InstanceOf()); + var objectResult = (ObjectResult)result; + Assert.That(objectResult.StatusCode, Is.EqualTo(500)); + _loggerMock.VerifyLog(LogLevel.Error, exception.Message, Times.Once()); + } + + [Test] + public async Task Ping_UnauthorizedUserId_ThrowsUnauthorizedAccessException() + { + // Arrange + var exception = new UnauthorizedAccessException("userID claim is missing or invalid"); + _currentUserServiceMock.Setup(x => x.GetCurrentUserId()).Throws(exception); + + // Act + var result = await _controller.Ping(); + + // Assert + Assert.That(result, Is.InstanceOf()); + _loggerMock.VerifyLog(LogLevel.Error, exception.Message, Times.Once()); + _pingHandlerServiceMock.Verify(x => x.Ping(It.IsAny()), Times.Never()); + } +} + +// Helper extension for verifying logger calls +public static class LoggerMockExtensions +{ + public static void VerifyLog(this Mock> logger, LogLevel level, string message, Times times) + { + logger.Verify( + x => x.Log( + It.Is(l => l == level), + It.IsAny(), + It.Is((v, t) => v.ToString().Contains(message)), + It.IsAny(), + It.IsAny>()), + times); + } +} diff --git a/Govor.API.Tests/UnitTests/Services/CurrentUserServiceTests.cs b/Govor.API.Tests/UnitTests/Services/CurrentUserServiceTests.cs new file mode 100644 index 0000000..352339e --- /dev/null +++ b/Govor.API.Tests/UnitTests/Services/CurrentUserServiceTests.cs @@ -0,0 +1,106 @@ +using System.Security.Claims; +using Govor.Application.Infrastructure.Extensions; +using Microsoft.AspNetCore.Http; +using Moq; + +namespace Govor.API.Tests.UnitTests.Services; + +[TestFixture] +public class CurrentUserServiceTests +{ + private Mock _httpContextAccessorMock; + private CurrentUserService _currentUserService; + + [SetUp] + public void SetUp() + { + _httpContextAccessorMock = new Mock(); + _currentUserService = new CurrentUserService(_httpContextAccessorMock.Object); + } + + [Test] + public void GetCurrentUserId_ValidUserIdClaim_ReturnsGuid() + { + // Arrange + var userId = Guid.NewGuid(); + var claims = new[] { new Claim("userId", userId.ToString()) }; + var identity = new ClaimsIdentity(claims); + var principal = new ClaimsPrincipal(identity); + + var httpContextMock = new Mock(); + httpContextMock.Setup(x => x.User).Returns(principal); + + _httpContextAccessorMock.Setup(x => x.HttpContext).Returns(httpContextMock.Object); + + // Act + var result = _currentUserService.GetCurrentUserId(); + + // Assert + Assert.That(result, Is.EqualTo(userId)); + } + + [Test] + public void GetCurrentUserId_NoHttpContext_ThrowsUnauthorizedAccessException() + { + // Arrange + _httpContextAccessorMock.Setup(x => x.HttpContext).Returns((HttpContext)null); + + // Act & Assert + var ex = Assert.Throws(() => _currentUserService.GetCurrentUserId()); + Assert.That(ex.Message, Is.EqualTo("userID claim is missing or invalid")); + } + + [Test] + public void GetCurrentUserId_NoUserIdClaim_ThrowsUnauthorizedAccessException() + { + // Arrange + var claims = new[] { new Claim("otherClaim", "value") }; + var identity = new ClaimsIdentity(claims); + var principal = new ClaimsPrincipal(identity); + + var httpContextMock = new Mock(); + httpContextMock.Setup(x => x.User).Returns(principal); + + _httpContextAccessorMock.Setup(x => x.HttpContext).Returns(httpContextMock.Object); + + // Act & Assert + var ex = Assert.Throws(() => _currentUserService.GetCurrentUserId()); + Assert.That(ex.Message, Is.EqualTo("userID claim is missing or invalid")); + } + + [Test] + public void GetCurrentUserId_InvalidUserIdClaim_ThrowsUnauthorizedAccessException() + { + // Arrange + var claims = new[] { new Claim("userId", "invalid-guid") }; + var identity = new ClaimsIdentity(claims); + var principal = new ClaimsPrincipal(identity); + + var httpContextMock = new Mock(); + httpContextMock.Setup(x => x.User).Returns(principal); + + _httpContextAccessorMock.Setup(x => x.HttpContext).Returns(httpContextMock.Object); + + // Act & Assert + var ex = Assert.Throws(() => _currentUserService.GetCurrentUserId()); + Assert.That(ex.Message, Is.EqualTo("userID claim is missing or invalid")); + } + + [Test] + public void GetCurrentUserId_EmptyUserIdClaim_ThrowsUnauthorizedAccessException() + { + // Arrange + var claims = new[] { new Claim("userId", "") }; + var identity = new ClaimsIdentity(claims); + var principal = new ClaimsPrincipal(identity); + + var httpContextMock = new Mock(); + httpContextMock.Setup(x => x.User).Returns(principal); + + _httpContextAccessorMock.Setup(x => x.HttpContext).Returns(httpContextMock.Object); + + // Act & Assert + var ex = Assert.Throws(() => _currentUserService.GetCurrentUserId()); + Assert.That(ex.Message, Is.EqualTo("userID claim is missing or invalid")); + } +} \ No newline at end of file diff --git a/Govor.API.Tests/UnitTests/Services/PingHandlerServiceTests.cs b/Govor.API.Tests/UnitTests/Services/PingHandlerServiceTests.cs new file mode 100644 index 0000000..4510910 --- /dev/null +++ b/Govor.API.Tests/UnitTests/Services/PingHandlerServiceTests.cs @@ -0,0 +1,58 @@ +using Govor.Application.Services; +using Govor.Core.Models; +using Govor.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; + +namespace Govor.Tests.Application.Services; + +[TestFixture] +public class PingHandlerServiceTests +{ + private GovorDbContext _dbContext = null!; + private IMemoryCache _memoryCache = null!; + private PingHandlerService _service = null!; + private Guid _userId; + + [SetUp] + public void SetUp() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + _dbContext = new GovorDbContext(options); + _memoryCache = new MemoryCache(new MemoryCacheOptions()); + _service = new PingHandlerService(_dbContext, _memoryCache); + + _userId = Guid.NewGuid(); + _dbContext.Users.Add(new User + { + Id = _userId, + Username = "TestUser", + WasOnline = DateTime.UtcNow.AddHours(-1), + Description = "Test description", + PasswordHash = "hashed_password_here" + }); + _dbContext.SaveChanges(); + } + + [Test] + public async Task Ping_DoesNotUpdate_WhenPingTooRecent() + { + // Arrange + var initial = DateTime.UtcNow.AddMinutes(-1); + _memoryCache.Set($"LastPing_{_userId}", DateTime.UtcNow); + + var user = await _dbContext.Users.FirstAsync(u => u.Id == _userId); + var originalTime = user.WasOnline; + + // Act + await _service.Ping(_userId); + + var updatedUser = await _dbContext.Users.FirstAsync(u => u.Id == _userId); + + // Assert + Assert.That(updatedUser.WasOnline, Is.EqualTo(originalTime)); + } +} diff --git a/Govor.API/Controllers/MediaController.cs b/Govor.API/Controllers/MediaController.cs index 947c9cf..4aece1b 100644 --- a/Govor.API/Controllers/MediaController.cs +++ b/Govor.API/Controllers/MediaController.cs @@ -14,7 +14,6 @@ public class MediaController : Controller { private readonly ILogger _logger; private readonly IStorageService _storageService; - private readonly IMediaAttachmentsRepository _repository; public MediaController(ILogger logger, IStorageService storageService) { @@ -31,14 +30,6 @@ public class MediaController : Controller var url = await _storageService.SaveAsync(request.Data,request.FileName); var mediaId = Guid.NewGuid(); - _repository.AddAsync(new MediaAttachments() - { - Id = mediaId, - FilePath = url, - EncryptedKey = request.EncryptedKey, - MimeType = request.MimeType, - Type = request.Type, - }); return Ok(mediaId); } diff --git a/Govor.API/Controllers/OnlinePingingController.cs b/Govor.API/Controllers/OnlinePingingController.cs new file mode 100644 index 0000000..71d74d7 --- /dev/null +++ b/Govor.API/Controllers/OnlinePingingController.cs @@ -0,0 +1,57 @@ +using Govor.Application.Interfaces; +using Govor.Application.Interfaces.Infrastructure.Extensions; +using Govor.Core.Repositories.Users; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Govor.API.Controllers; + +[ApiController] +[Route("api/online")] +[Authorize(Roles = "User,Admin")] +public class OnlinePingingController : Controller +{ + private readonly ILogger _logger; + private readonly IPingHandlerService _ping; + private readonly ICurrentUserService _currentUserService; + + public OnlinePingingController(ILogger logger, + IPingHandlerService ping, + ICurrentUserService currentUserService) + { + _logger = logger; + _ping = ping; + _currentUserService = currentUserService; + } + + + [HttpPatch("ping")]// api/online/ping + public async Task Ping() + { + try + { + _logger.LogInformation("Ping..."); + + var id = _currentUserService.GetCurrentUserId(); + await _ping.Ping(id); + + _logger.LogInformation($"Ping from user {id} processed successfully"); + return Ok(); + } + catch (InvalidOperationException e) + { + _logger.LogError(e, e.Message); + return BadRequest("User can't be found in our database."); + } + catch (UnauthorizedAccessException e) + { + _logger.LogError(e, e.Message); + return Forbid(e.Message); + } + catch (Exception e) + { + _logger.LogError(e, e.Message); + return StatusCode(500, new { error = "Failed to send friend request." }); + } + } +} \ No newline at end of file diff --git a/Govor.API/Extensions/ConfigurationProgramExtensions.cs b/Govor.API/Extensions/ConfigurationProgramExtensions.cs index b09764b..15281fe 100644 --- a/Govor.API/Extensions/ConfigurationProgramExtensions.cs +++ b/Govor.API/Extensions/ConfigurationProgramExtensions.cs @@ -43,6 +43,9 @@ public static class ConfigurationProgramExtensions services.AddHttpContextAccessor(); // it's very important for CurrentUserService services.AddScoped(); + + services.AddMemoryCache(); + services.AddScoped(); } public static void AddRepositories(this IServiceCollection services) diff --git a/Govor.API/Hubs/ChatsHub.cs b/Govor.API/Hubs/ChatsHub.cs index e0ffe2d..4942226 100644 --- a/Govor.API/Hubs/ChatsHub.cs +++ b/Govor.API/Hubs/ChatsHub.cs @@ -1,4 +1,5 @@ using System.Security.Claims; +using Govor.Application.Interfaces; using Govor.Contracts.Requests.SignalR; using Govor.Core.Models; using Govor.Core.Repositories.Users; @@ -13,6 +14,7 @@ namespace Govor.API.Hubs; public class ChatsHub : Hub { private readonly IUsersRepository _usersRepository; + private readonly IVerifyFriendship _verifyFriendship; private readonly ILogger _logger; public ChatsHub(IUsersRepository usersRepository, ILogger logger) @@ -60,21 +62,22 @@ public class ChatsHub : Hub var senderId = GetUserId(); - // Проверка существования получателя - /*try + // Проверка существования получателя и установленной дружбы + try { - await _usersRepository.FindByIdAsync(toUserId); + await _usersRepository.FindByIdAsync(request.RecipientId); + await _verifyFriendship.VerifyAsync(senderId, request.RecipientId); } catch (NotFoundByKeyException ex) { - _logger.LogWarning("Recipient user {ToUserId} not found", toUserId); + _logger.LogWarning("Recipient user {ToUserId} not found", request.RecipientId); throw; } catch (ArgumentException ex) { _logger.LogWarning("Invalid recipient userId received from user {UserId}", GetUserId()); throw; - }*/ + } try { diff --git a/Govor.Application/Exceptions/VerifyFriendship/FriendshipException.cs b/Govor.Application/Exceptions/VerifyFriendship/FriendshipException.cs new file mode 100644 index 0000000..ab56926 --- /dev/null +++ b/Govor.Application/Exceptions/VerifyFriendship/FriendshipException.cs @@ -0,0 +1,9 @@ +using Govor.Core; + +namespace Govor.Application.Exceptions.VerifyFriendship; + +public class FriendshipException : GovorCoreException +{ + public FriendshipException(string s) + :base(s) { } +} \ No newline at end of file diff --git a/Govor.Application/Infrastructure/Extensions/CurrentUserService.cs b/Govor.Application/Infrastructure/Extensions/CurrentUserService.cs index 3cb4b67..dfe4732 100644 --- a/Govor.Application/Infrastructure/Extensions/CurrentUserService.cs +++ b/Govor.Application/Infrastructure/Extensions/CurrentUserService.cs @@ -6,21 +6,23 @@ namespace Govor.Application.Infrastructure.Extensions; public class CurrentUserService : ICurrentUserService { - private readonly ClaimsPrincipal _user; - + private readonly IHttpContextAccessor _httpContextAccessor; + public CurrentUserService(IHttpContextAccessor httpContextAccessor) { - _user = httpContextAccessor.HttpContext.User; + _httpContextAccessor = httpContextAccessor; } - + public Guid GetCurrentUserId() { - var userIdClaim = _user.FindFirst("userId")?.Value; - + var user = _httpContextAccessor.HttpContext?.User; + var userIdClaim = user?.FindFirst("userId")?.Value; + if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId)) { throw new UnauthorizedAccessException("userID claim is missing or invalid"); } + return userId; } -} \ No newline at end of file +} diff --git a/Govor.Application/Interfaces/IPingHandlerService.cs b/Govor.Application/Interfaces/IPingHandlerService.cs new file mode 100644 index 0000000..fe3fa0f --- /dev/null +++ b/Govor.Application/Interfaces/IPingHandlerService.cs @@ -0,0 +1,6 @@ +namespace Govor.Application.Interfaces; + +public interface IPingHandlerService +{ + Task Ping(Guid userId); +} \ No newline at end of file diff --git a/Govor.Application/Interfaces/IVerifyFriendship.cs b/Govor.Application/Interfaces/IVerifyFriendship.cs new file mode 100644 index 0000000..27fb6bb --- /dev/null +++ b/Govor.Application/Interfaces/IVerifyFriendship.cs @@ -0,0 +1,7 @@ +namespace Govor.Application.Interfaces; + +public interface IVerifyFriendship +{ + Task VerifyAsync(Guid targetUserId, Guid friendUserId); + Task TryVerifyAsync(Guid targetUserId, Guid friendUserId); +} \ No newline at end of file diff --git a/Govor.Application/Services/PingHandlerService.cs b/Govor.Application/Services/PingHandlerService.cs new file mode 100644 index 0000000..1cc0e37 --- /dev/null +++ b/Govor.Application/Services/PingHandlerService.cs @@ -0,0 +1,34 @@ +using Govor.Application.Interfaces; +using Govor.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; + +namespace Govor.Application.Services; + +public class PingHandlerService : IPingHandlerService +{ + private readonly GovorDbContext _context; + private readonly IMemoryCache _cache; + + public PingHandlerService(GovorDbContext context, IMemoryCache cache) + { + _context = context; + _cache = cache; + } + + public async Task Ping(Guid userId) + { + var cacheKey = $"LastPing_{userId}"; + if (_cache.TryGetValue(cacheKey, out DateTime lastPing) && + DateTime.UtcNow.Subtract(lastPing).TotalSeconds < 30) + { + return; // Пропускаем слишком частые пинги + } + + await _context.Users + .Where(u => u.Id == userId) + .ExecuteUpdateAsync(u => u.SetProperty(x => x.WasOnline, DateTime.UtcNow)); + + _cache.Set(cacheKey, DateTime.UtcNow, TimeSpan.FromSeconds(30)); + } +} \ No newline at end of file diff --git a/Govor.Application/Services/VerifierFriendship.cs b/Govor.Application/Services/VerifierFriendship.cs new file mode 100644 index 0000000..9729110 --- /dev/null +++ b/Govor.Application/Services/VerifierFriendship.cs @@ -0,0 +1,55 @@ +using Govor.Application.Exceptions.VerifyFriendship; +using Govor.Application.Interfaces; +using Govor.Core.Models; +using Govor.Core.Repositories.Friendships; +using Microsoft.Extensions.Logging; + +namespace Govor.Application.Services; + + +public class VerifyFriendship : IVerifyFriendship +{ + private readonly IFriendshipsRepository _friendshipsRepository; + private readonly ILogger _logger; + private const string FriendshipNotAcceptedError = "Friendship between user {0} and friend {1} does not exist or is not accepted."; + + public VerifyFriendship(IFriendshipsRepository friendshipsRepository, ILogger logger = null) + { + _friendshipsRepository = friendshipsRepository ?? throw new ArgumentNullException(nameof(friendshipsRepository)); + _logger = logger; + } + + public async Task VerifyAsync(Guid targetUserId, Guid friendUserId) + { + if (targetUserId == Guid.Empty || friendUserId == Guid.Empty) + { + _logger?.LogWarning("Invalid user IDs provided: targetUserId={TargetUserId}, friendUserId={FriendUserId}", targetUserId, friendUserId); + throw new ArgumentException("User IDs cannot be empty.", nameof(targetUserId)); + } + + var friendships = await _friendshipsRepository.FindByUserIdAsync(targetUserId); + var friendship = friendships.Where(f => f.AddresseeId == friendUserId || f.RequesterId == friendUserId)?.FirstOrDefault(); + + if (friendship == null || friendship.Status != FriendshipStatus.Accepted) + { + var errorMessage = string.Format(FriendshipNotAcceptedError, targetUserId, friendUserId); + _logger?.LogError(errorMessage); + throw new FriendshipException(errorMessage); + } + + _logger?.LogInformation("Friendship verified successfully for targetUserId={TargetUserId}, friendUserId={FriendUserId}", targetUserId, friendUserId); + } + + public async Task TryVerifyAsync(Guid targetUserId, Guid friendUserId) + { + try + { + await VerifyAsync(targetUserId, friendUserId); + return true; + } + catch (FriendshipException ex) + { + return false; + } + } +} \ No newline at end of file diff --git a/Govor.Data/Repositories/UsersRepository.cs b/Govor.Data/Repositories/UsersRepository.cs index c9822aa..3cd8dfb 100644 --- a/Govor.Data/Repositories/UsersRepository.cs +++ b/Govor.Data/Repositories/UsersRepository.cs @@ -22,10 +22,6 @@ public class UsersRepository : IUsersRepository { return await _context.Users .AsNoTracking() - .Include(u => u.Invite) - .Include(u => u.ReceivedFriendRequests) - .Include(u => u.SentFriendRequests) - .AsSplitQuery() .ToListOrThrowIfEmpty(new NotFoundException("Users in Database not exists")); } @@ -52,10 +48,6 @@ public class UsersRepository : IUsersRepository return await _context.Users .AsNoTracking() .Where(x => ids.Contains(x.Id)) - .Include(u => u.Invite) - .Include(u => u.ReceivedFriendRequests) - .Include(u => u.SentFriendRequests) - .AsSplitQuery() .ToListOrThrowIfEmpty(new NotFoundByKeyException>(ids,"Users with given ids not found")); } @@ -78,9 +70,6 @@ public class UsersRepository : IUsersRepository { return await _context.Users .AsNoTracking() - .Include(u => u.Invite) - .Include(u => u.ReceivedFriendRequests) - .Include(u => u.SentFriendRequests) .AsSplitQuery() .Where(u => u.Id != currentUserId && u.Username.ToLower().Contains(query.ToLower()) &&