Refactor user ID access in SignalR hubs and add online user tracking

Introduces IHubUserAccessor and its implementation to centralize user ID retrieval from SignalR HubCallerContext, replacing duplicated logic in ChatsHub, FriendsHub, and PresenceHub. Moves extension and mapping files to a Common directory, adds UserToUserDtoMappingAction for online status mapping, and implements OnlineUserStore with tests for tracking online users. Updates dependency injection and test code to use the new abstractions.
This commit is contained in:
Artemy
2025-07-23 22:21:51 +07:00
parent 31fdf4cb37
commit be0edb0f94
17 changed files with 326 additions and 96 deletions
@@ -0,0 +1,114 @@
using System.Security.Claims;
using Govor.API.Common.SignalR.Helpers;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
using Moq;
namespace Govor.API.Tests.Common.SignalR.Helpers;
[TestFixture]
[TestOf(typeof(HubUserAccessor))]
public class HubUserAccessorTests
{
private HubUserAccessor _accessor;
private Mock<ILogger<HubUserAccessor>> _loggerMock;
[SetUp]
public void SetUp()
{
_loggerMock = new Mock<ILogger<HubUserAccessor>>();
_accessor = new HubUserAccessor(_loggerMock.Object);
}
private HubCallerContext CreateContextWithClaims(params Claim[] claims)
{
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims));
var contextMock = new Mock<HubCallerContext>();
contextMock.Setup(c => c.User).Returns(principal);
return contextMock.Object;
}
[Test]
public void GetUserId_ValidClaim_ReturnsGuid()
{
// Arrange
var expectedGuid = Guid.NewGuid();
var context = CreateContextWithClaims(new Claim("userId", expectedGuid.ToString()));
// Act
var result = _accessor.GetUserId(context);
// Assert
Assert.That(expectedGuid, Is.EqualTo(result));
}
[Test]
public void GetUserId_InvalidClaim_ThrowsException_WhenNotSuppressed()
{
// Arrange
var context = CreateContextWithClaims(new Claim("userId", "not-a-guid"));
// Act & Assert
Assert.Throws<UnauthorizedAccessException>(() => _accessor.GetUserId(context, suppressException: false));
}
[Test]
public void GetUserId_InvalidClaim_ReturnsEmptyGuid_WhenSuppressed()
{
// Arrange
var context = CreateContextWithClaims(new Claim("userId", "not-a-guid"));
// Act
var result = _accessor.GetUserId(context, suppressException: true);
// Assert
Assert.That(Guid.Empty, Is.EqualTo(result));
}
[Test]
public void GetUserId_NoClaim_ThrowsException_WhenNotSuppressed()
{
// Arrange
var context = CreateContextWithClaims();
// Act & Assert
Assert.Throws<UnauthorizedAccessException>(() => _accessor.GetUserId(context, suppressException: false));
}
[Test]
public void GetUserId_NoClaim_ReturnsEmptyGuid_WhenSuppressed()
{
// Arrange
var context = CreateContextWithClaims();
// Act
var result = _accessor.GetUserId(context, suppressException: true);
// Assert
Assert.That(Guid.Empty, Is.EqualTo(result));
}
[Test]
public void GetUserId_UserIsNull_ReturnsEmptyGuid_WhenSuppressed()
{
// Arrange
var contextMock = new Mock<HubCallerContext>();
contextMock.Setup(c => c.User).Returns((ClaimsPrincipal?)null);
// Act
var result = _accessor.GetUserId(contextMock.Object, suppressException: true);
// Assert
Assert.That(Guid.Empty, Is.EqualTo(result));
}
[Test]
public void GetUserId_UserIsNull_ThrowsException_WhenNotSuppressed()
{
// Arrange
var contextMock = new Mock<HubCallerContext>();
contextMock.Setup(c => c.User).Returns((ClaimsPrincipal?)null);
// Act & Assert
Assert.Throws<UnauthorizedAccessException>(() => _accessor.GetUserId(contextMock.Object, suppressException: false));
}
}
@@ -1,4 +1,5 @@
using AutoFixture;
using Govor.API.Common.SignalR.Helpers;
using Govor.API.Hubs;
using Govor.Application.Interfaces;
using Govor.Application.Interfaces.Messages;
@@ -13,6 +14,7 @@ public class ChatsHubTests
private Mock<ILogger<ChatsHub>> _loggerMock;
private Mock<IMessageCommandService> _messageServiceMock;
private Mock<IUserGroupsService> _userGroupsServiceMock;
private Mock<IHubUserAccessor> _hubUserAccessorMock;
private Fixture _fixture;
private ChatsHub _chatsHub;
@@ -26,11 +28,13 @@ public class ChatsHubTests
_messageServiceMock = new Mock<IMessageCommandService>();
_userGroupsServiceMock = new Mock<IUserGroupsService>();
_loggerMock = new Mock<ILogger<ChatsHub>>();
_hubUserAccessorMock = new Mock<IHubUserAccessor>();
_chatsHub = new ChatsHub(
_loggerMock.Object,
_messageServiceMock.Object,
_userGroupsServiceMock.Object
_userGroupsServiceMock.Object,
_hubUserAccessorMock.Object
);
}
@@ -1,5 +1,6 @@
using AutoFixture;
using AutoMapper;
using Govor.API.Common.SignalR.Helpers;
using Govor.API.Hubs;
using Govor.Application.Exceptions.FriendsService;
using Govor.Application.Interfaces.Friends;
@@ -17,7 +18,7 @@ namespace Govor.API.Tests.IntegrationTests.Hubs;
public class FriendsHubTests
{
private Mock<IFriendRequestCommandService> _friendRequestServiceMock = null!;
private Mock<ICurrentUserService> _currentUserServiceMock = null!;
private Mock<IHubUserAccessor> _currentUserServiceMock = null!;
private Mock<IHubCallerClients> _clientsMock = null!;
private Mock<IClientProxy> _clientProxyMock = null!;
private Mock<ILogger<FriendsHub>> _loggerMock = null!;
@@ -35,19 +36,23 @@ public class FriendsHubTests
_fixture.Behaviors.Add(new OmitOnRecursionBehavior());
_friendRequestServiceMock = new Mock<IFriendRequestCommandService>();
_currentUserServiceMock = new Mock<ICurrentUserService>();
_currentUserServiceMock = new Mock<IHubUserAccessor>();
_clientsMock = new Mock<IHubCallerClients>();
_clientProxyMock = new Mock<IClientProxy>();
_loggerMock = new Mock<ILogger<FriendsHub>>();
_mapperMock = new Mock<IMapper>();
_currentUserServiceMock.Setup(x => x.GetCurrentUserId()).Returns(_userId);
_currentUserServiceMock.Setup(x => x.GetUserId(
It.IsAny<HubCallerContext>(),
It.IsAny<bool>()))
.Returns(_userId);
_clientsMock.Setup(c => c.Group(It.IsAny<string>())).Returns(_clientProxyMock.Object);
_hub = new FriendsHub(
_loggerMock.Object,
_friendRequestServiceMock.Object,
_currentUserServiceMock.Object,
_loggerMock.Object,
_mapperMock.Object)
{
Clients = _clientsMock.Object
@@ -129,7 +134,7 @@ public class FriendsHubTests
public async Task SendRequest_ShouldReturnUnauthorized_WhenCurrentUserIsNotAuthenticated()
{
// Arrange
_currentUserServiceMock.Setup(x => x.GetCurrentUserId())
_currentUserServiceMock.Setup(x => x.GetUserId(It.IsAny<HubCallerContext>(), It.IsAny<bool>()))
.Throws(new UnauthorizedAccessException("userId claim is missing or invalid"));
// Act
@@ -1,6 +1,6 @@
using Govor.Application.Services.Authentication;
namespace Govor.API.Extensions;
namespace Govor.API.Common.Extensions;
public static class AddOptionExtensions
{
@@ -1,3 +1,5 @@
using Govor.API.Common.Mapping;
using Govor.API.Common.SignalR.Helpers;
using Govor.Application.Infrastructure.AdminsStuff;
using Govor.Application.Infrastructure.Extensions;
using Govor.Application.Infrastructure.Validators;
@@ -34,7 +36,7 @@ using Govor.Data;
using Govor.Data.Repositories;
using Microsoft.EntityFrameworkCore;
namespace Govor.API.Extensions;
namespace Govor.API.Common.Extensions;
public static class ConfigurationProgramExtensions
{
@@ -83,6 +85,8 @@ public static class ConfigurationProgramExtensions
// Auto Mapper
services.AddAutoMapper(typeof(MappingProfile));
services.AddScoped<IHubUserAccessor, HubUserAccessor>();
}
public static void AddRepositories(this IServiceCollection services)
@@ -1,7 +1,7 @@
using Govor.API.Filters;
using Microsoft.AspNetCore.SignalR;
namespace Govor.API.Extensions;
namespace Govor.API.Common.Extensions;
public static class ConfigurationSignalR
{
@@ -1,6 +1,6 @@
using Serilog;
namespace Govor.API.Extensions;
namespace Govor.API.Common.Extensions;
public static class ConfiguratorLoggerExtensions
{
@@ -1,11 +1,12 @@
using AutoMapper;
using Govor.API.Extensions.Mapping;
using Govor.Contracts.DTOs;
using Govor.Contracts.Responses;
using Govor.Core.Models;
using Govor.Core.Models.Messages;
using Govor.Core.Models.Users;
namespace Govor.API.Extensions;
namespace Govor.API.Common.Mapping;
public class MappingProfile : Profile
{
@@ -16,7 +17,9 @@ public class MappingProfile : Profile
CreateMap<MessageReaction, MessageReactionResponse>();
CreateMap<MessageView, MessageViewResponse>();
CreateMap<User, UserDto>();
CreateMap<User, UserDto>()
.AfterMap<UserToUserDtoMappingAction>();
CreateMap<Friendship, FriendshipDto>();
}
}
@@ -0,0 +1,21 @@
using AutoMapper;
using Govor.Application.Interfaces.UserOnlineStatus;
using Govor.Contracts.DTOs;
using Govor.Core.Models.Users;
namespace Govor.API.Extensions.Mapping;
public class UserToUserDtoMappingAction : IMappingAction<User, UserDto>
{
private readonly IOnlineUserStore _onlineUserStore;
public UserToUserDtoMappingAction(IOnlineUserStore onlineUserStore)
{
_onlineUserStore = onlineUserStore;
}
public void Process(User source, UserDto destination, ResolutionContext context)
{
destination.IsOnline = _onlineUserStore.IsOnline(source.Id);
}
}
@@ -0,0 +1,30 @@
using Microsoft.AspNetCore.SignalR;
namespace Govor.API.Common.SignalR.Helpers;
public class HubUserAccessor : IHubUserAccessor
{
private readonly ILogger<HubUserAccessor> _logger;
public HubUserAccessor(ILogger<HubUserAccessor> logger)
{
_logger = logger;
}
public Guid GetUserId(HubCallerContext context, bool suppressException = false)
{
var userIdClaim = context.User?.FindFirst("userId")?.Value;
if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId))
{
if (!suppressException)
{
_logger.LogError("Could not retrieve sender userId. Claim was: {UserIDClaim}", userIdClaim);
throw new UnauthorizedAccessException("userID claim is missing or invalid.");
}
return Guid.Empty;
}
return userId;
}
}
@@ -0,0 +1,8 @@
using Microsoft.AspNetCore.SignalR;
namespace Govor.API.Common.SignalR.Helpers;
public interface IHubUserAccessor
{
Guid GetUserId(HubCallerContext context, bool suppressException = false);
}
+9 -25
View File
@@ -1,3 +1,4 @@
using Govor.API.Common.SignalR.Helpers;
using Govor.Application.Exceptions.VerifyFriendship;
using Govor.Application.Interfaces;
using Govor.Application.Interfaces.Messages;
@@ -16,17 +17,19 @@ public class ChatsHub : Hub
private readonly ILogger<ChatsHub> _logger;
private readonly IMessageCommandService _messageCommandService;
private readonly IUserGroupsService _userService;
private readonly IHubUserAccessor _userAccessor;
public ChatsHub(ILogger<ChatsHub> logger, IMessageCommandService messageCommandService, IUserGroupsService userService)
public ChatsHub(ILogger<ChatsHub> logger, IMessageCommandService messageCommandService, IUserGroupsService userService, IHubUserAccessor userAccessor)
{
_logger = logger;
_messageCommandService = messageCommandService;
_userService = userService;
_userAccessor = userAccessor;
}
public override async Task OnConnectedAsync()
{
var userId = GetUserId();
var userId = _userAccessor.GetUserId(Context);
if (userId == Guid.Empty)
{
_logger.LogWarning("User connected with invalid UserID claim.");
@@ -50,8 +53,7 @@ public class ChatsHub : Hub
public override async Task OnDisconnectedAsync(Exception? exception)
{
var userId =
GetUserId(suppressException: true);
var userId = _userAccessor.GetUserId(Context, true);
if (userId != Guid.Empty)
{
await Groups.RemoveFromGroupAsync(Context.ConnectionId, userId.ToString());
@@ -85,7 +87,7 @@ public class ChatsHub : Hub
public async Task<HubResult<UserMessageResponse>> Send(MessageRequest request)
{
var senderId = GetUserId();
var senderId= _userAccessor.GetUserId(Context);
if (string.IsNullOrWhiteSpace(request.EncryptedContent) &&
(request.MediaAttachments == null || !request.MediaAttachments.Any()))
@@ -144,7 +146,7 @@ public class ChatsHub : Hub
public async Task<HubResult<MessageRemovedResponse>> Remove(RemoveMessageRequest request)
{
var removerId = GetUserId();
var removerId = _userAccessor.GetUserId(Context);
_logger.LogInformation("Removing message {MessageId} by user {RemoverId}", request.MessageId, removerId);
try
@@ -184,7 +186,7 @@ public class ChatsHub : Hub
public async Task<HubResult<MessageEditResponse>> Edit(EditMessageRequest request)
{
var editor = GetUserId();
var editor = _userAccessor.GetUserId(Context);
_logger.LogInformation("Editing message {MessageId} by user {EditorId}", request.MessageId, editor);
var editMessageParam = new EditMessage(editor,
@@ -303,22 +305,4 @@ public class ChatsHub : Hub
_logger.LogWarning(ex, "{Msg}: {UserId} -> {TargetId}", msg, userId, targetId);
return HubResult<T>.NotFound("Message not found.");
}
private Guid GetUserId(bool suppressException = false)
{
var userIdClaim = Context.User?.FindFirst("userId")?.Value;
if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId))
{
if (!suppressException)
{
_logger.LogError("Could not retrieve sender userId. Claim was: {UserIDClaim}", userIdClaim);
throw new UnauthorizedAccessException("userID claim is missing or invalid.");
}
return Guid.Empty;
}
return userId;
}
}
+12 -28
View File
@@ -1,5 +1,6 @@
using System.ComponentModel.DataAnnotations;
using AutoMapper;
using Govor.API.Common.SignalR.Helpers;
using Govor.Application.Exceptions.FriendsService;
using Govor.Application.Interfaces.Friends;
using Govor.Application.Interfaces.Infrastructure.Extensions;
@@ -13,23 +14,24 @@ public class FriendsHub : Hub
{
private readonly ILogger<FriendsHub> _logger;
private readonly IFriendRequestCommandService _friendRequestService;
private readonly ICurrentUserService _currentUserService;
private readonly IHubUserAccessor _userAccessor;
private readonly IMapper _mapper;
public FriendsHub(IFriendRequestCommandService friendRequestService,
ICurrentUserService currentUserService,
public FriendsHub(
ILogger<FriendsHub> logger,
IFriendRequestCommandService friendRequestService,
IHubUserAccessor userAccessor,
IMapper mapper)
{
_friendRequestService = friendRequestService;
_currentUserService = currentUserService;
_logger = logger;
_friendRequestService = friendRequestService;
_userAccessor = userAccessor;
_mapper = mapper;
}
public override async Task OnConnectedAsync()
{
var userId = GetUserId();
var userId = _userAccessor.GetUserId(Context);
if (userId == Guid.Empty)
{
_logger.LogWarning("User connected with invalid UserID claim.");
@@ -46,8 +48,7 @@ public class FriendsHub : Hub
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
var userId =
GetUserId(suppressException: true);
var userId = _userAccessor.GetUserId(Context, true);
if (userId != Guid.Empty)
{
// Remove user from their own group
@@ -76,7 +77,7 @@ public class FriendsHub : Hub
{
try
{
var userId = _currentUserService.GetCurrentUserId();
var userId = _userAccessor.GetUserId(Context);
var friendship = await _friendRequestService.SendAsync(userId, targetUserId);
await Clients.Group(targetUserId.ToString())
@@ -111,7 +112,7 @@ public class FriendsHub : Hub
{
try
{
var userId = _currentUserService.GetCurrentUserId();
var userId = _userAccessor.GetUserId(Context);
var friendship = await _friendRequestService.AcceptAsync(friendshipId, userId);
await Clients.Group(userId.ToString())
.SendAsync("FriendRequestAccepted", _mapper.Map<FriendshipDto>(friendship));
@@ -140,7 +141,7 @@ public class FriendsHub : Hub
{
try
{
var userId = _currentUserService.GetCurrentUserId();
var userId = _userAccessor.GetUserId(Context);
var friendship = await _friendRequestService.RejectAsync(friendshipId, userId);
await Clients.Group(userId.ToString())
.SendAsync("FriendRequestRejected", _mapper.Map<FriendshipDto>(friendship));
@@ -164,21 +165,4 @@ public class FriendsHub : Hub
return HubResult<object>.Error("Unexpected error! Please try later!");
}
}
private Guid GetUserId(bool suppressException = false)
{
var userIdClaim = Context.User?.FindFirst("userId")?.Value;
if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId))
{
if (!suppressException)
{
_logger.LogError("Could not retrieve sender userId. Claim was: {UserIDClaim}", userIdClaim);
throw new UnauthorizedAccessException("userID claim is missing or invalid.");
}
return Guid.Empty;
}
return userId;
}
}
+10 -22
View File
@@ -1,5 +1,4 @@
using Govor.Application.Interfaces;
using Govor.Application.Interfaces.Infrastructure.Extensions;
using Govor.API.Common.SignalR.Helpers;
using Govor.Application.Interfaces.UserOnlineStatus;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components;
@@ -14,17 +13,23 @@ public class PresenceHub : Hub
private readonly ILogger<PresenceHub> _logger;
private readonly IUserNotificationScopeService _notificationScopeService;
private readonly IOnlineUserStore _onlineUserStore;
private readonly IHubUserAccessor _userAccessor;
public PresenceHub(ILogger<PresenceHub> logger, IUserNotificationScopeService notificationScopeService, IOnlineUserStore onlineUserStore)
public PresenceHub(
ILogger<PresenceHub> logger,
IUserNotificationScopeService notificationScopeService,
IOnlineUserStore onlineUserStore,
IHubUserAccessor userAccessor)
{
_logger = logger;
_notificationScopeService = notificationScopeService;
_onlineUserStore = onlineUserStore;
_userAccessor = userAccessor;
}
public override async Task OnConnectedAsync()
{
var userId = GetUserId();
var userId = _userAccessor.GetUserId(Context);
if (userId == Guid.Empty)
{
_logger.LogWarning("User connected with invalid UserID claim.");
@@ -48,7 +53,7 @@ public class PresenceHub : Hub
public override async Task OnDisconnectedAsync(Exception? exception)
{
var userId = GetUserId();
var userId = _userAccessor.GetUserId(Context, true);
if (userId == Guid.Empty) return;
_onlineUserStore.SetOfflineUser(userId);
@@ -63,21 +68,4 @@ public class PresenceHub : Hub
await base.OnDisconnectedAsync(exception);
}
private Guid GetUserId(bool suppressException = false)
{
var userIdClaim = Context.User?.FindFirst("userId")?.Value;
if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId))
{
if (!suppressException)
{
_logger.LogError("Could not retrieve sender userId. Claim was: {UserIDClaim}", userIdClaim);
throw new UnauthorizedAccessException("userId claim is missing or invalid.");
}
return Guid.Empty;
}
return userId;
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
using System.Text;
using Govor.API.Extensions;
using Govor.API.Common.Extensions;
using Govor.API.Hubs;
using Govor.Application.Services.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
@@ -0,0 +1,82 @@
using Govor.Application.Interfaces.UserOnlineStatus;
using Govor.Application.Services.UserOnlineStatus;
namespace Govor.Application.Tests.Services.UserOnlineStatus;
[TestFixture]
public class OnlineUserStoreTests
{
private IOnlineUserStore _store;
private Guid _userId1;
private Guid _userId2;
[SetUp]
public void Setup()
{
_store = new OnlineUserStore();
_userId1 = Guid.NewGuid();
_userId2 = Guid.NewGuid();
}
[Test]
public void SetOnlineUser_UserIsMarkedOnline()
{
// Act
_store.SetOnlineUser(_userId1);
// Assert
Assert.That(_store.IsOnline(_userId1), Is.True);
}
[Test]
public void SetOfflineUser_UserIsNoLongerOnline()
{
// Act
_store.SetOnlineUser(_userId1);
_store.SetOfflineUser(_userId1);
// Assert
Assert.That(_store.IsOnline(_userId1), Is.False);
}
[Test]
public void IsOnline_ReturnsFalse_ForUnknownUser()
{
// Act & Assert
Assert.That(_store.IsOnline(Guid.NewGuid()), Is.False);
}
[Test]
public void GetAllOnlineUsers_ReturnsAllCurrentlyOnlineUsers()
{
// Arrange
_store.SetOnlineUser(_userId1);
_store.SetOnlineUser(_userId2);
// Act
var onlineUsers = _store.GetAllOnlineUsers();
// Assert
Assert.That(onlineUsers, Is.EquivalentTo(new[] { _userId1, _userId2 }));
}
[Test]
public void SetOnlineUser_Twice_DoesNotThrow()
{
// Act & Assert
Assert.DoesNotThrow(() =>
{
_store.SetOnlineUser(_userId1);
_store.SetOnlineUser(_userId1);
});
Assert.That(_store.IsOnline(_userId1), Is.True);
}
[Test]
public void SetOfflineUser_ForUnknownUser_DoesNotThrow()
{
// Act & Assert
Assert.DoesNotThrow(() => _store.SetOfflineUser(Guid.NewGuid()));
}
}
@@ -1,26 +1,29 @@
using System.Collections.Concurrent;
using Govor.Application.Interfaces.UserOnlineStatus;
namespace Govor.Application.Services.UserOnlineStatus;
public class OnlineUserStore : IOnlineUserStore
{
private readonly ConcurrentDictionary<Guid, DateTime> _onlineUsers = new();
public void SetOnlineUser(Guid userId)
{
throw new NotImplementedException();
_onlineUsers[userId] = DateTime.UtcNow;
}
public void SetOfflineUser(Guid userId)
{
throw new NotImplementedException();
_onlineUsers.TryRemove(userId, out _);
}
public bool IsOnline(Guid userId)
{
throw new NotImplementedException();
return _onlineUsers.ContainsKey(userId);
}
public IReadOnlyCollection<Guid> GetAllOnlineUsers()
{
throw new NotImplementedException();
return _onlineUsers.Keys.ToList();
}
}