Add Telegram bot integration and enhance user management features
- Introduced Telegram.Bot package for bot functionality. - Updated user management to include Telegram linking and blocking features. - Enhanced activation request handling with notifications via Telegram. - Added new database entities for Telegram link tokens and login requests. - Implemented traffic synchronization for client stats in the XuiPanelGateway. - Updated application structure to support new test projects and improved dependency injection for Telegram services.
This commit is contained in:
+94
@@ -0,0 +1,94 @@
|
||||
using NSubstitute;
|
||||
using PnvPanel.Application.Activation;
|
||||
using PnvPanel.Application.Admin.Activation;
|
||||
using PnvPanel.Application.Auth;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
using PnvPanel.Application.Tests.TestSupport;
|
||||
using PnvPanel.Domain.Activation;
|
||||
using Xunit;
|
||||
|
||||
namespace PnvPanel.Application.Tests.Activation;
|
||||
|
||||
public class ApproveActivationCommandHandlerTests
|
||||
{
|
||||
private readonly IIdentityService _identityService = Substitute.For<IIdentityService>();
|
||||
private readonly IRealtimeNotifier _notifier = Substitute.For<IRealtimeNotifier>();
|
||||
private readonly ICurrentUser _currentUser = Substitute.For<ICurrentUser>();
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenPending_ApprovesActivatesUserAndNotifies()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var adminId = Guid.NewGuid();
|
||||
var request = ActivationRequest.Create(Guid.NewGuid(), "please");
|
||||
dbContext.ActivationRequests.Add(request);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
_currentUser.UserId.Returns(adminId);
|
||||
_identityService.ActivateUserAsync(request.UserId, adminId, Arg.Any<CancellationToken>()).Returns(Result.Success());
|
||||
|
||||
var handler = new ApproveActivationCommandHandler(dbContext, _identityService, _notifier, _currentUser);
|
||||
|
||||
var result = await handler.Handle(new ApproveActivationCommand(request.Id), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(ActivationStatus.Approved, request.Status);
|
||||
await _notifier.Received(1).NotifyUserActivatedAsync(request.UserId, Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenRequestNotFound_ReturnsNotFound()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
_currentUser.UserId.Returns(Guid.NewGuid());
|
||||
|
||||
var handler = new ApproveActivationCommandHandler(dbContext, _identityService, _notifier, _currentUser);
|
||||
|
||||
var result = await handler.Handle(new ApproveActivationCommand(Guid.NewGuid()), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(ActivationErrors.NotFound, result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenAlreadyDecided_ReturnsConflict()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var request = ActivationRequest.Create(Guid.NewGuid(), null);
|
||||
request.Approve(Guid.NewGuid());
|
||||
dbContext.ActivationRequests.Add(request);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
_currentUser.UserId.Returns(Guid.NewGuid());
|
||||
|
||||
var handler = new ApproveActivationCommandHandler(dbContext, _identityService, _notifier, _currentUser);
|
||||
|
||||
var result = await handler.Handle(new ApproveActivationCommand(request.Id), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(ActivationErrors.AlreadyDecided, result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenActivateUserFails_PropagatesFailureWithoutNotifying()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var adminId = Guid.NewGuid();
|
||||
var request = ActivationRequest.Create(Guid.NewGuid(), null);
|
||||
dbContext.ActivationRequests.Add(request);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
_currentUser.UserId.Returns(adminId);
|
||||
var failure = Error.NotFound("User.NotFound", "Пользователь не найден.");
|
||||
_identityService.ActivateUserAsync(request.UserId, adminId, Arg.Any<CancellationToken>()).Returns(Result.Failure(failure));
|
||||
|
||||
var handler = new ApproveActivationCommandHandler(dbContext, _identityService, _notifier, _currentUser);
|
||||
|
||||
var result = await handler.Handle(new ApproveActivationCommand(request.Id), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(failure, result.Error);
|
||||
await _notifier.DidNotReceive().NotifyUserActivatedAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
using NSubstitute;
|
||||
using PnvPanel.Application.Activation;
|
||||
using PnvPanel.Application.Admin.Activation;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Tests.TestSupport;
|
||||
using PnvPanel.Domain.Activation;
|
||||
using Xunit;
|
||||
|
||||
namespace PnvPanel.Application.Tests.Activation;
|
||||
|
||||
public class RejectActivationCommandHandlerTests
|
||||
{
|
||||
private readonly ICurrentUser _currentUser = Substitute.For<ICurrentUser>();
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenPending_RejectsWithReason()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var adminId = Guid.NewGuid();
|
||||
var request = ActivationRequest.Create(Guid.NewGuid(), null);
|
||||
dbContext.ActivationRequests.Add(request);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
_currentUser.UserId.Returns(adminId);
|
||||
|
||||
var handler = new RejectActivationCommandHandler(dbContext, _currentUser);
|
||||
|
||||
var result = await handler.Handle(new RejectActivationCommand(request.Id, "недостаточно информации"), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(ActivationStatus.Rejected, request.Status);
|
||||
Assert.Equal("недостаточно информации", request.RejectionReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenRequestNotFound_ReturnsNotFound()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
_currentUser.UserId.Returns(Guid.NewGuid());
|
||||
|
||||
var handler = new RejectActivationCommandHandler(dbContext, _currentUser);
|
||||
|
||||
var result = await handler.Handle(new RejectActivationCommand(Guid.NewGuid(), null), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(ActivationErrors.NotFound, result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenAlreadyDecided_ReturnsConflict()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var request = ActivationRequest.Create(Guid.NewGuid(), null);
|
||||
request.Reject(Guid.NewGuid(), null);
|
||||
dbContext.ActivationRequests.Add(request);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
_currentUser.UserId.Returns(Guid.NewGuid());
|
||||
|
||||
var handler = new RejectActivationCommandHandler(dbContext, _currentUser);
|
||||
|
||||
var result = await handler.Handle(new RejectActivationCommand(request.Id, null), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(ActivationErrors.AlreadyDecided, result.Error);
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
using NSubstitute;
|
||||
using PnvPanel.Application.Activation;
|
||||
using PnvPanel.Application.Auth;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Tests.TestSupport;
|
||||
using PnvPanel.Domain.Activation;
|
||||
using Xunit;
|
||||
|
||||
namespace PnvPanel.Application.Tests.Activation;
|
||||
|
||||
public class RequestActivationCommandHandlerTests
|
||||
{
|
||||
private readonly IRealtimeNotifier _notifier = Substitute.For<IRealtimeNotifier>();
|
||||
private readonly ITelegramNotifier _telegramNotifier = Substitute.For<ITelegramNotifier>();
|
||||
private readonly ICurrentUser _currentUser = Substitute.For<ICurrentUser>();
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenNoPendingRequest_CreatesRequestAndNotifies()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var userId = Guid.NewGuid();
|
||||
_currentUser.UserId.Returns(userId);
|
||||
_currentUser.UserName.Returns("alice");
|
||||
|
||||
var handler = new RequestActivationCommandHandler(dbContext, _notifier, _telegramNotifier, _currentUser);
|
||||
|
||||
var result = await handler.Handle(new RequestActivationCommand("Please activate"), CancellationToken.None);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal("Please activate", result.Value.Comment);
|
||||
Assert.Single(dbContext.ActivationRequests);
|
||||
await _notifier.Received(1).NotifyActivationRequestedAsync(
|
||||
Arg.Any<Guid>(), userId, "alice", "Please activate", Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>());
|
||||
await _telegramNotifier.Received(1).NotifyAdminsActivationRequestedAsync(
|
||||
Arg.Any<Guid>(), "alice", "Please activate", Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenPendingRequestAlreadyExists_ReturnsConflictWithoutNotifying()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var userId = Guid.NewGuid();
|
||||
dbContext.ActivationRequests.Add(ActivationRequest.Create(userId, null));
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
_currentUser.UserId.Returns(userId);
|
||||
_currentUser.UserName.Returns("alice");
|
||||
|
||||
var handler = new RequestActivationCommandHandler(dbContext, _notifier, _telegramNotifier, _currentUser);
|
||||
|
||||
var result = await handler.Handle(new RequestActivationCommand(null), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(ActivationErrors.AlreadyPending, result.Error);
|
||||
await _notifier.DidNotReceive().NotifyActivationRequestedAsync(
|
||||
Arg.Any<Guid>(), Arg.Any<Guid>(), Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenNotAuthenticated_ReturnsUnauthorized()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
_currentUser.UserId.Returns((Guid?)null);
|
||||
|
||||
var handler = new RequestActivationCommandHandler(dbContext, _notifier, _telegramNotifier, _currentUser);
|
||||
|
||||
var result = await handler.Handle(new RequestActivationCommand(null), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(AuthErrors.Unauthorized, result.Error);
|
||||
}
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
using PnvPanel.Application.Admin.Inbounds;
|
||||
using PnvPanel.Application.Tests.TestSupport;
|
||||
using PnvPanel.Domain.Inbounds;
|
||||
using Xunit;
|
||||
|
||||
namespace PnvPanel.Application.Tests.Admin.Inbounds;
|
||||
|
||||
public class PublishInboundCommandHandlerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Handle_WhenPublishingExistingInbound_UpdatesPublishState()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var inbound = Inbound.FromRemote(Guid.NewGuid(), "1", VpnProtocol.Vless, "remark", 443);
|
||||
dbContext.Inbounds.Add(inbound);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var roleId = Guid.NewGuid();
|
||||
var handler = new PublishInboundCommandHandler(dbContext);
|
||||
|
||||
var command = new PublishInboundCommand(inbound.Id, true, "EU Fast", [roleId], 100);
|
||||
|
||||
var result = await handler.Handle(command, CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.True(inbound.IsPublished);
|
||||
Assert.Equal("EU Fast", inbound.DisplayName);
|
||||
Assert.Equal(100, inbound.MaxClients);
|
||||
Assert.Contains(roleId, inbound.AllowedRoleIds);
|
||||
Assert.Equal("EU Fast", result.Value.DisplayName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenUnpublishing_ClearsPublishedFlag()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var inbound = Inbound.FromRemote(Guid.NewGuid(), "1", VpnProtocol.Vless, "remark", 443);
|
||||
inbound.Publish("EU Fast", [Guid.NewGuid()], 100);
|
||||
dbContext.Inbounds.Add(inbound);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var handler = new PublishInboundCommandHandler(dbContext);
|
||||
|
||||
var command = new PublishInboundCommand(inbound.Id, false, null, [], null);
|
||||
|
||||
var result = await handler.Handle(command, CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.False(inbound.IsPublished);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenInboundNotFound_ReturnsNotFound()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
|
||||
var handler = new PublishInboundCommandHandler(dbContext);
|
||||
|
||||
var command = new PublishInboundCommand(Guid.NewGuid(), true, "EU Fast", [], null);
|
||||
|
||||
var result = await handler.Handle(command, CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(InboundErrors.NotFound, result.Error);
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
using NSubstitute;
|
||||
using PnvPanel.Application.Admin.Nodes;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
using PnvPanel.Application.Tests.TestSupport;
|
||||
using Xunit;
|
||||
|
||||
namespace PnvPanel.Application.Tests.Admin.Nodes;
|
||||
|
||||
public class RegisterNodeCommandHandlerTests
|
||||
{
|
||||
private readonly IXuiPanelGateway _gateway = Substitute.For<IXuiPanelGateway>();
|
||||
private readonly ISecretProtector _secretProtector = Substitute.For<ISecretProtector>();
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithValidAddress_RegistersNodeWithProtectedPassword()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
|
||||
_gateway.ValidateBaseAddress(Arg.Any<Uri>()).Returns(Result.Success());
|
||||
_secretProtector.Protect("secret-password").Returns("protected-secret-password");
|
||||
|
||||
var handler = new RegisterNodeCommandHandler(dbContext, _gateway, _secretProtector);
|
||||
|
||||
var command = new RegisterNodeCommand("node-1", "https://node1.example.com", "admin", "secret-password", "eu-west");
|
||||
|
||||
var result = await handler.Handle(command, CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal("node-1", result.Value.Name);
|
||||
Assert.Equal("admin", result.Value.Username);
|
||||
Assert.Single(dbContext.Nodes.Local);
|
||||
Assert.Equal("protected-secret-password", dbContext.Nodes.Local.Single().Credentials.ProtectedPassword);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithInvalidBaseAddress_ReturnsValidationErrorWithoutTouchingGateway()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
|
||||
var handler = new RegisterNodeCommandHandler(dbContext, _gateway, _secretProtector);
|
||||
|
||||
var command = new RegisterNodeCommand("node-1", "not-a-uri", "admin", "secret-password", null);
|
||||
|
||||
var result = await handler.Handle(command, CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(NodeErrors.InvalidBaseAddress, result.Error);
|
||||
_gateway.DidNotReceive().ValidateBaseAddress(Arg.Any<Uri>());
|
||||
Assert.Empty(dbContext.Nodes.Local);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenGatewayRejectsBaseAddress_ReturnsFailureWithoutRegisteringNode()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
|
||||
var error = Error.Validation("Nodes.SchemeNotAllowed", "Разрешён только HTTPS.");
|
||||
_gateway.ValidateBaseAddress(Arg.Any<Uri>()).Returns(Result.Failure(error));
|
||||
|
||||
var handler = new RegisterNodeCommandHandler(dbContext, _gateway, _secretProtector);
|
||||
|
||||
var command = new RegisterNodeCommand("node-1", "http://node1.example.com", "admin", "secret-password", null);
|
||||
|
||||
var result = await handler.Handle(command, CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(error, result.Error);
|
||||
Assert.Empty(dbContext.Nodes.Local);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
using NSubstitute;
|
||||
using PnvPanel.Application.Admin.Users;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
using PnvPanel.Application.Tests.TestSupport;
|
||||
using PnvPanel.Domain.Configs;
|
||||
using PnvPanel.Domain.Inbounds;
|
||||
using PnvPanel.Domain.Nodes;
|
||||
using Xunit;
|
||||
|
||||
namespace PnvPanel.Application.Tests.Admin.Users;
|
||||
|
||||
public class BlockUserCommandHandlerTests
|
||||
{
|
||||
private readonly IIdentityService _identityService = Substitute.For<IIdentityService>();
|
||||
private readonly IXuiPanelGateway _gateway = Substitute.For<IXuiPanelGateway>();
|
||||
private readonly IRealtimeNotifier _notifier = Substitute.For<IRealtimeNotifier>();
|
||||
private readonly ICurrentUser _currentUser = Substitute.For<ICurrentUser>();
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_IdentityServiceFails_ReturnsFailureWithoutTouchingConfigs()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var userId = Guid.NewGuid();
|
||||
var failure = UserErrors.NotFound;
|
||||
_identityService.BlockUserAsync(userId, Arg.Any<CancellationToken>()).Returns(Result.Failure(failure));
|
||||
|
||||
var handler = new BlockUserCommandHandler(dbContext, _identityService, _gateway, _notifier, _currentUser);
|
||||
|
||||
var result = await handler.Handle(new BlockUserCommand(userId), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(failure, result.Error);
|
||||
await _gateway.DidNotReceive().UpdateClientAsync(
|
||||
Arg.Any<Node>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<VpnProtocol>(),
|
||||
Arg.Any<string>(), Arg.Any<int>(), Arg.Any<bool>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_Success_DisablesActiveConfigsAndWritesAudit()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var userId = Guid.NewGuid();
|
||||
var adminId = Guid.NewGuid();
|
||||
|
||||
var node = Node.Register("node-1", new Uri("https://node1.example.com"), new NodeCredentials("admin", "protected"), null);
|
||||
var inbound = Inbound.FromRemote(node.Id, "1", VpnProtocol.Vless, "remark", 443);
|
||||
var config = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "my-config", 0);
|
||||
|
||||
dbContext.Nodes.Add(node);
|
||||
dbContext.Inbounds.Add(inbound);
|
||||
dbContext.VpnConfigs.Add(config);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
_identityService.BlockUserAsync(userId, Arg.Any<CancellationToken>()).Returns(Result.Success());
|
||||
_currentUser.UserId.Returns(adminId);
|
||||
|
||||
var handler = new BlockUserCommandHandler(dbContext, _identityService, _gateway, _notifier, _currentUser);
|
||||
|
||||
var result = await handler.Handle(new BlockUserCommand(userId), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(ConfigStatus.Disabled, config.Status);
|
||||
|
||||
await _gateway.Received(1).UpdateClientAsync(
|
||||
Arg.Is<Node>(n => n.Id == node.Id), inbound.RemoteInboundId, config.ClientExternalId, config.Protocol,
|
||||
"my-config", config.DeviceLimit, enable: false, Arg.Any<CancellationToken>());
|
||||
await _notifier.Received(1).NotifyConfigStatusChangedAsync(userId, config.Id, ConfigStatus.Disabled, Arg.Any<CancellationToken>());
|
||||
|
||||
var audit = Assert.Single(dbContext.AuditLogs.Local);
|
||||
Assert.Equal("UserBlocked", audit.Action);
|
||||
Assert.Equal(adminId, audit.ActorId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NoActiveConfigs_SkipsGatewayCalls()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var userId = Guid.NewGuid();
|
||||
|
||||
_identityService.BlockUserAsync(userId, Arg.Any<CancellationToken>()).Returns(Result.Success());
|
||||
_currentUser.UserId.Returns(Guid.NewGuid());
|
||||
|
||||
var handler = new BlockUserCommandHandler(dbContext, _identityService, _gateway, _notifier, _currentUser);
|
||||
|
||||
var result = await handler.Handle(new BlockUserCommand(userId), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
await _gateway.DidNotReceive().UpdateClientAsync(
|
||||
Arg.Any<Node>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<VpnProtocol>(),
|
||||
Arg.Any<string>(), Arg.Any<int>(), Arg.Any<bool>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
using NSubstitute;
|
||||
using PnvPanel.Application.Admin.Users;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace PnvPanel.Application.Tests.Admin.Users;
|
||||
|
||||
public class ChangeUserRoleCommandHandlerTests
|
||||
{
|
||||
private readonly IRoleService _roleService = Substitute.For<IRoleService>();
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_DelegatesToRoleServiceAndReturnsSuccess()
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
var roleId = Guid.NewGuid();
|
||||
_roleService.ChangeUserRoleAsync(userId, roleId, Arg.Any<CancellationToken>()).Returns(Result.Success());
|
||||
|
||||
var handler = new ChangeUserRoleCommandHandler(_roleService);
|
||||
|
||||
var result = await handler.Handle(new ChangeUserRoleCommand(userId, roleId), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
await _roleService.Received(1).ChangeUserRoleAsync(userId, roleId, Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenRoleServiceFails_PropagatesFailure()
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
var roleId = Guid.NewGuid();
|
||||
var error = UserErrors.NotFound;
|
||||
_roleService.ChangeUserRoleAsync(userId, roleId, Arg.Any<CancellationToken>()).Returns(Result.Failure(error));
|
||||
|
||||
var handler = new ChangeUserRoleCommandHandler(_roleService);
|
||||
|
||||
var result = await handler.Handle(new ChangeUserRoleCommand(userId, roleId), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(error, result.Error);
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
using NSubstitute;
|
||||
using PnvPanel.Application.Admin.Users;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
using PnvPanel.Application.Tests.TestSupport;
|
||||
using PnvPanel.Domain.Configs;
|
||||
using PnvPanel.Domain.Inbounds;
|
||||
using PnvPanel.Domain.Nodes;
|
||||
using Xunit;
|
||||
|
||||
namespace PnvPanel.Application.Tests.Admin.Users;
|
||||
|
||||
public class UnblockUserCommandHandlerTests
|
||||
{
|
||||
private readonly IIdentityService _identityService = Substitute.For<IIdentityService>();
|
||||
private readonly IXuiPanelGateway _gateway = Substitute.For<IXuiPanelGateway>();
|
||||
private readonly IRealtimeNotifier _notifier = Substitute.For<IRealtimeNotifier>();
|
||||
private readonly ICurrentUser _currentUser = Substitute.For<ICurrentUser>();
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenUnblockSucceeds_ReEnablesDisabledConfigsAndWritesAudit()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var userId = Guid.NewGuid();
|
||||
var adminId = Guid.NewGuid();
|
||||
|
||||
var node = Node.Register("node-1", new Uri("https://node1.example.com"), new NodeCredentials("admin", "protected"), null);
|
||||
var inbound = Inbound.FromRemote(node.Id, "1", VpnProtocol.Vless, "remark", 443);
|
||||
var config = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "my-config", 0);
|
||||
config.Disable();
|
||||
|
||||
dbContext.Nodes.Add(node);
|
||||
dbContext.Inbounds.Add(inbound);
|
||||
dbContext.VpnConfigs.Add(config);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
_currentUser.UserId.Returns(adminId);
|
||||
_identityService.UnblockUserAsync(userId, Arg.Any<CancellationToken>()).Returns(Result.Success());
|
||||
|
||||
var handler = new UnblockUserCommandHandler(dbContext, _identityService, _gateway, _notifier, _currentUser);
|
||||
|
||||
var result = await handler.Handle(new UnblockUserCommand(userId), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(ConfigStatus.Active, config.Status);
|
||||
await _gateway.Received(1).UpdateClientAsync(
|
||||
node, inbound.RemoteInboundId, config.ClientExternalId, config.Protocol,
|
||||
Arg.Any<string>(), config.DeviceLimit, true, Arg.Any<CancellationToken>());
|
||||
await _notifier.Received(1).NotifyConfigStatusChangedAsync(userId, config.Id, ConfigStatus.Active, Arg.Any<CancellationToken>());
|
||||
Assert.Single(dbContext.AuditLogs.Local);
|
||||
Assert.Equal("UserUnblocked", dbContext.AuditLogs.Local.Single().Action);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenIdentityServiceFails_ReturnsFailureWithoutTouchingConfigs()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var userId = Guid.NewGuid();
|
||||
var error = UserErrors.NotFound;
|
||||
|
||||
_identityService.UnblockUserAsync(userId, Arg.Any<CancellationToken>()).Returns(Result.Failure(error));
|
||||
|
||||
var handler = new UnblockUserCommandHandler(dbContext, _identityService, _gateway, _notifier, _currentUser);
|
||||
|
||||
var result = await handler.Handle(new UnblockUserCommand(userId), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(error, result.Error);
|
||||
Assert.Empty(dbContext.AuditLogs);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using NSubstitute;
|
||||
using PnvPanel.Application.Auth;
|
||||
using PnvPanel.Application.Auth.ChangePassword;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
using PnvPanel.Application.Tests.TestSupport;
|
||||
using Xunit;
|
||||
|
||||
namespace PnvPanel.Application.Tests.Auth;
|
||||
|
||||
public class ChangePasswordCommandHandlerTests
|
||||
{
|
||||
private readonly IIdentityService _identityService = Substitute.For<IIdentityService>();
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_Unauthenticated_ReturnsUnauthorized()
|
||||
{
|
||||
var handler = new ChangePasswordCommandHandler(_identityService, FakeCurrentUser.Anonymous());
|
||||
|
||||
var result = await handler.Handle(new ChangePasswordCommand("old", "new"), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(AuthErrors.Unauthorized, result.Error);
|
||||
await _identityService.DidNotReceive().ChangePasswordAsync(
|
||||
Arg.Any<Guid>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_Authenticated_DelegatesToIdentityService()
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
_identityService.ChangePasswordAsync(userId, "old-pass", "new-pass", Arg.Any<CancellationToken>())
|
||||
.Returns(Result.Success());
|
||||
|
||||
var handler = new ChangePasswordCommandHandler(_identityService, FakeCurrentUser.Authenticated(userId));
|
||||
|
||||
var result = await handler.Handle(new ChangePasswordCommand("old-pass", "new-pass"), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
await _identityService.Received(1).ChangePasswordAsync(userId, "old-pass", "new-pass", Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_IdentityServiceFails_PropagatesFailure()
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
var error = Error.Validation("Auth.WrongCurrentPassword", "Текущий пароль неверен.");
|
||||
_identityService.ChangePasswordAsync(userId, "wrong", "new-pass", Arg.Any<CancellationToken>())
|
||||
.Returns(Result.Failure(error));
|
||||
|
||||
var handler = new ChangePasswordCommandHandler(_identityService, FakeCurrentUser.Authenticated(userId));
|
||||
|
||||
var result = await handler.Handle(new ChangePasswordCommand("wrong", "new-pass"), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(error, result.Error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using NSubstitute;
|
||||
using PnvPanel.Application.Auth;
|
||||
using PnvPanel.Application.Auth.Me;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Tests.TestSupport;
|
||||
using Xunit;
|
||||
|
||||
namespace PnvPanel.Application.Tests.Auth;
|
||||
|
||||
public class GetCurrentUserQueryHandlerTests
|
||||
{
|
||||
private readonly IIdentityService _identityService = Substitute.For<IIdentityService>();
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_Unauthenticated_ReturnsUnauthorized()
|
||||
{
|
||||
var handler = new GetCurrentUserQueryHandler(_identityService, FakeCurrentUser.Anonymous());
|
||||
|
||||
var result = await handler.Handle(new GetCurrentUserQuery(), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(AuthErrors.Unauthorized, result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ProfileMissing_ReturnsUnauthorized()
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
_identityService.GetProfileAsync(userId, Arg.Any<CancellationToken>()).Returns((CurrentUserProfile?)null);
|
||||
|
||||
var handler = new GetCurrentUserQueryHandler(_identityService, FakeCurrentUser.Authenticated(userId));
|
||||
|
||||
var result = await handler.Handle(new GetCurrentUserQuery(), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(AuthErrors.Unauthorized, result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_AuthenticatedWithProfile_ReturnsCurrentUserDto()
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
var profile = new CurrentUserProfile(userId, "alice", Guid.NewGuid(), "user", true, false, 3);
|
||||
_identityService.GetProfileAsync(userId, Arg.Any<CancellationToken>()).Returns(profile);
|
||||
_identityService.GetTelegramLinkInfoAsync(userId, Arg.Any<CancellationToken>())
|
||||
.Returns(new TelegramLinkInfo(true, 42, "alice_tg"));
|
||||
|
||||
var handler = new GetCurrentUserQueryHandler(_identityService, FakeCurrentUser.Authenticated(userId, "alice"));
|
||||
|
||||
var result = await handler.Handle(new GetCurrentUserQuery(), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(userId, result.Value.Id);
|
||||
Assert.Equal("alice", result.Value.UserName);
|
||||
Assert.Equal("user", result.Value.Role);
|
||||
Assert.True(result.Value.IsActivated);
|
||||
Assert.True(result.Value.TelegramLinked);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using NSubstitute;
|
||||
using PnvPanel.Application.Auth;
|
||||
using PnvPanel.Application.Auth.Login;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace PnvPanel.Application.Tests.Auth;
|
||||
|
||||
public class LoginCommandHandlerTests
|
||||
{
|
||||
private readonly IIdentityService _identityService = Substitute.For<IIdentityService>();
|
||||
private readonly IJwtTokenService _jwtTokenService = Substitute.For<IJwtTokenService>();
|
||||
private readonly IRefreshTokenService _refreshTokenService = Substitute.For<IRefreshTokenService>();
|
||||
|
||||
private LoginCommandHandler CreateHandler() => new(_identityService, _jwtTokenService, _refreshTokenService);
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithValidCredentials_ReturnsAuthResult()
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
var authUser = new AuthenticatedUser(userId, "alice", "user");
|
||||
var profile = new CurrentUserProfile(userId, "alice", Guid.NewGuid(), "user", IsActivated: true, IsBlocked: false, MaxConfigs: 3);
|
||||
|
||||
_identityService.ValidateCredentialsAsync("alice", "P@ssw0rd", Arg.Any<CancellationToken>())
|
||||
.Returns(Result.Success(authUser));
|
||||
_identityService.GetProfileAsync(userId, Arg.Any<CancellationToken>()).Returns(profile);
|
||||
_identityService.GetTelegramLinkInfoAsync(userId, Arg.Any<CancellationToken>())
|
||||
.Returns(new TelegramLinkInfo(true, 123456, "alice_tg"));
|
||||
_jwtTokenService.GenerateAccessToken(Arg.Any<AuthenticatedUser>())
|
||||
.Returns(("access-token", DateTimeOffset.UtcNow.AddMinutes(15)));
|
||||
_refreshTokenService.IssueAsync(userId, Arg.Any<CancellationToken>())
|
||||
.Returns(new IssuedRefreshToken("refresh-token", DateTimeOffset.UtcNow.AddDays(30)));
|
||||
|
||||
var result = await CreateHandler().Handle(new LoginCommand("alice", "P@ssw0rd"), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal("access-token", result.Value.AccessToken);
|
||||
Assert.Equal("refresh-token", result.Value.RefreshToken);
|
||||
Assert.Equal("alice", result.Value.User.UserName);
|
||||
Assert.True(result.Value.User.TelegramLinked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithInvalidCredentials_ReturnsFailureWithoutIssuingTokens()
|
||||
{
|
||||
_identityService.ValidateCredentialsAsync("alice", "wrong", Arg.Any<CancellationToken>())
|
||||
.Returns(Result.Failure<AuthenticatedUser>(AuthErrors.InvalidCredentials));
|
||||
|
||||
var result = await CreateHandler().Handle(new LoginCommand("alice", "wrong"), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(AuthErrors.InvalidCredentials, result.Error);
|
||||
await _refreshTokenService.DidNotReceive().IssueAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenProfileMissingAfterValidCredentials_ReturnsInvalidCredentials()
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
var authUser = new AuthenticatedUser(userId, "alice", "user");
|
||||
|
||||
_identityService.ValidateCredentialsAsync("alice", "P@ssw0rd", Arg.Any<CancellationToken>())
|
||||
.Returns(Result.Success(authUser));
|
||||
_identityService.GetProfileAsync(userId, Arg.Any<CancellationToken>()).Returns((CurrentUserProfile?)null);
|
||||
|
||||
var result = await CreateHandler().Handle(new LoginCommand("alice", "P@ssw0rd"), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(AuthErrors.InvalidCredentials, result.Error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using NSubstitute;
|
||||
using PnvPanel.Application.Auth;
|
||||
using PnvPanel.Application.Auth.Refresh;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace PnvPanel.Application.Tests.Auth;
|
||||
|
||||
public class RefreshCommandHandlerTests
|
||||
{
|
||||
private readonly IIdentityService _identityService = Substitute.For<IIdentityService>();
|
||||
private readonly IJwtTokenService _jwtTokenService = Substitute.For<IJwtTokenService>();
|
||||
private readonly IRefreshTokenService _refreshTokenService = Substitute.For<IRefreshTokenService>();
|
||||
|
||||
private RefreshCommandHandler CreateHandler() => new(_identityService, _jwtTokenService, _refreshTokenService);
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithValidToken_RotatesAndReturnsNewAuthResult()
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
var profile = new CurrentUserProfile(userId, "alice", Guid.NewGuid(), "user", IsActivated: true, IsBlocked: false, MaxConfigs: 3);
|
||||
var rotated = new RotatedRefreshToken(userId, "new-refresh-token", DateTimeOffset.UtcNow.AddDays(30));
|
||||
|
||||
_refreshTokenService.RotateAsync("old-token", Arg.Any<CancellationToken>()).Returns(Result.Success(rotated));
|
||||
_identityService.GetProfileAsync(userId, Arg.Any<CancellationToken>()).Returns(profile);
|
||||
_identityService.GetTelegramLinkInfoAsync(userId, Arg.Any<CancellationToken>())
|
||||
.Returns(new TelegramLinkInfo(false, null, null));
|
||||
_jwtTokenService.GenerateAccessToken(Arg.Any<AuthenticatedUser>())
|
||||
.Returns(("new-access-token", DateTimeOffset.UtcNow.AddMinutes(15)));
|
||||
|
||||
var result = await CreateHandler().Handle(new RefreshCommand("old-token"), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal("new-access-token", result.Value.AccessToken);
|
||||
Assert.Equal("new-refresh-token", result.Value.RefreshToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithInvalidOrReusedToken_ReturnsFailure()
|
||||
{
|
||||
_refreshTokenService.RotateAsync("stolen-token", Arg.Any<CancellationToken>())
|
||||
.Returns(Result.Failure<RotatedRefreshToken>(AuthErrors.InvalidRefreshToken));
|
||||
|
||||
var result = await CreateHandler().Handle(new RefreshCommand("stolen-token"), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(AuthErrors.InvalidRefreshToken, result.Error);
|
||||
await _identityService.DidNotReceive().GetProfileAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenProfileNoLongerExists_ReturnsInvalidRefreshToken()
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
var rotated = new RotatedRefreshToken(userId, "new-refresh-token", DateTimeOffset.UtcNow.AddDays(30));
|
||||
|
||||
_refreshTokenService.RotateAsync("old-token", Arg.Any<CancellationToken>()).Returns(Result.Success(rotated));
|
||||
_identityService.GetProfileAsync(userId, Arg.Any<CancellationToken>()).Returns((CurrentUserProfile?)null);
|
||||
|
||||
var result = await CreateHandler().Handle(new RefreshCommand("old-token"), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(AuthErrors.InvalidRefreshToken, result.Error);
|
||||
}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
using NSubstitute;
|
||||
using PnvPanel.Application.Auth;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Configs.GetMyConfigs;
|
||||
using PnvPanel.Application.Tests.TestSupport;
|
||||
using PnvPanel.Domain.Configs;
|
||||
using PnvPanel.Domain.Inbounds;
|
||||
using Xunit;
|
||||
|
||||
namespace PnvPanel.Application.Tests.Configs.GetMyConfigs;
|
||||
|
||||
public class GetMyConfigsQueryHandlerTests
|
||||
{
|
||||
private readonly IIdentityService _identityService = Substitute.For<IIdentityService>();
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ReturnsOnlyNonRevokedConfigsForCurrentUserWithMaxConfigsFromProfile()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var userId = Guid.NewGuid();
|
||||
var otherUserId = Guid.NewGuid();
|
||||
|
||||
var inbound = Inbound.FromRemote(Guid.NewGuid(), "1", VpnProtocol.Vless, "remark", 443);
|
||||
inbound.Publish("My inbound", [], null);
|
||||
|
||||
var activeConfig = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "Active", 0);
|
||||
var revokedConfig = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "Revoked", 0);
|
||||
revokedConfig.Revoke();
|
||||
var otherUsersConfig = VpnConfig.Create(otherUserId, inbound.Id, VpnProtocol.Vless, "Other", 0);
|
||||
|
||||
dbContext.Inbounds.Add(inbound);
|
||||
dbContext.VpnConfigs.AddRange(activeConfig, revokedConfig, otherUsersConfig);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var profile = new CurrentUserProfile(userId, "alice", Guid.NewGuid(), "user", true, false, 5);
|
||||
_identityService.GetProfileAsync(userId, Arg.Any<CancellationToken>()).Returns(profile);
|
||||
|
||||
var handler = new GetMyConfigsQueryHandler(dbContext, _identityService, FakeCurrentUser.Authenticated(userId));
|
||||
|
||||
var result = await handler.Handle(new GetMyConfigsQuery(), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Single(result.Value.Configs);
|
||||
Assert.Equal(activeConfig.Id, result.Value.Configs[0].Id);
|
||||
Assert.Equal(5, result.Value.MaxConfigs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenNotAuthenticated_ReturnsUnauthorized()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
|
||||
var handler = new GetMyConfigsQueryHandler(dbContext, _identityService, FakeCurrentUser.Anonymous());
|
||||
|
||||
var result = await handler.Handle(new GetMyConfigsQuery(), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(AuthErrors.Unauthorized, result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenProfileMissing_ReturnsUnauthorized()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var userId = Guid.NewGuid();
|
||||
_identityService.GetProfileAsync(userId, Arg.Any<CancellationToken>()).Returns((CurrentUserProfile?)null);
|
||||
|
||||
var handler = new GetMyConfigsQueryHandler(dbContext, _identityService, FakeCurrentUser.Authenticated(userId));
|
||||
|
||||
var result = await handler.Handle(new GetMyConfigsQuery(), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(AuthErrors.Unauthorized, result.Error);
|
||||
}
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
using NSubstitute;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Configs;
|
||||
using PnvPanel.Application.Configs.Revoke;
|
||||
using PnvPanel.Application.Tests.TestSupport;
|
||||
using PnvPanel.Domain.Configs;
|
||||
using PnvPanel.Domain.Inbounds;
|
||||
using PnvPanel.Domain.Nodes;
|
||||
using Xunit;
|
||||
|
||||
namespace PnvPanel.Application.Tests.Configs.Revoke;
|
||||
|
||||
public class RevokeVpnConfigCommandHandlerTests
|
||||
{
|
||||
private readonly IXuiPanelGateway _gateway = Substitute.For<IXuiPanelGateway>();
|
||||
private readonly IRealtimeNotifier _notifier = Substitute.For<IRealtimeNotifier>();
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenActiveConfigOwnedByUser_RevokesRemovesRemoteClientAndNotifies()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var userId = Guid.NewGuid();
|
||||
|
||||
var node = Node.Register("node-1", new Uri("https://node1.example.com"), new NodeCredentials("admin", "protected"), null);
|
||||
var inbound = Inbound.FromRemote(node.Id, "1", VpnProtocol.Vless, "remark", 443);
|
||||
var config = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, null, 0);
|
||||
config.AssignRemoteClient("external-id");
|
||||
|
||||
dbContext.Nodes.Add(node);
|
||||
dbContext.Inbounds.Add(inbound);
|
||||
dbContext.VpnConfigs.Add(config);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var handler = new RevokeVpnConfigCommandHandler(dbContext, _gateway, _notifier, FakeCurrentUser.Authenticated(userId));
|
||||
|
||||
var result = await handler.Handle(new RevokeVpnConfigCommand(config.Id), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(ConfigStatus.Revoked, config.Status);
|
||||
await _gateway.Received(1).RemoveClientAsync(
|
||||
Arg.Any<Node>(), inbound.RemoteInboundId, "external-id", config.Protocol, Arg.Any<CancellationToken>());
|
||||
await _notifier.Received(1).NotifyConfigStatusChangedAsync(userId, config.Id, ConfigStatus.Revoked, Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenAlreadyRevoked_IsIdempotentAndDoesNotCallGateway()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var userId = Guid.NewGuid();
|
||||
|
||||
var inbound = Inbound.FromRemote(Guid.NewGuid(), "1", VpnProtocol.Vless, "remark", 443);
|
||||
var config = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, null, 0);
|
||||
config.Revoke();
|
||||
|
||||
dbContext.Inbounds.Add(inbound);
|
||||
dbContext.VpnConfigs.Add(config);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var handler = new RevokeVpnConfigCommandHandler(dbContext, _gateway, _notifier, FakeCurrentUser.Authenticated(userId));
|
||||
|
||||
var result = await handler.Handle(new RevokeVpnConfigCommand(config.Id), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
await _gateway.DidNotReceive().RemoveClientAsync(
|
||||
Arg.Any<PnvPanel.Domain.Nodes.Node>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<VpnProtocol>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenConfigNotFound_ReturnsNotFound()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var userId = Guid.NewGuid();
|
||||
|
||||
var handler = new RevokeVpnConfigCommandHandler(dbContext, _gateway, _notifier, FakeCurrentUser.Authenticated(userId));
|
||||
|
||||
var result = await handler.Handle(new RevokeVpnConfigCommand(Guid.NewGuid()), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(ConfigErrors.NotFound, result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenNotAuthenticated_ReturnsUnauthorized()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
|
||||
var handler = new RevokeVpnConfigCommandHandler(dbContext, _gateway, _notifier, FakeCurrentUser.Anonymous());
|
||||
|
||||
var result = await handler.Handle(new RevokeVpnConfigCommand(Guid.NewGuid()), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(PnvPanel.Application.Auth.AuthErrors.Unauthorized, result.Error);
|
||||
}
|
||||
}
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
using NSubstitute;
|
||||
using PnvPanel.Application.Auth;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
using PnvPanel.Application.Configs;
|
||||
using PnvPanel.Application.Configs.Rotate;
|
||||
using PnvPanel.Application.Tests.TestSupport;
|
||||
using PnvPanel.Domain.Configs;
|
||||
using PnvPanel.Domain.Inbounds;
|
||||
using PnvPanel.Domain.Nodes;
|
||||
using Xunit;
|
||||
|
||||
namespace PnvPanel.Application.Tests.Configs.Rotate;
|
||||
|
||||
public class RotateVpnConfigCommandHandlerTests
|
||||
{
|
||||
private readonly IXuiPanelGateway _gateway = Substitute.For<IXuiPanelGateway>();
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenActiveConfigOwnedByUser_RotatesAndAddsNewClient()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var userId = Guid.NewGuid();
|
||||
|
||||
var node = Node.Register("node-1", new Uri("https://node1.example.com"), new NodeCredentials("admin", "protected"), null);
|
||||
var inbound = Inbound.FromRemote(node.Id, "1", VpnProtocol.Vless, "remark", 443);
|
||||
var config = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "my-config", 0);
|
||||
config.AssignRemoteClient("old-external-id");
|
||||
|
||||
dbContext.Nodes.Add(node);
|
||||
dbContext.Inbounds.Add(inbound);
|
||||
dbContext.VpnConfigs.Add(config);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
_gateway.AddClientAsync(
|
||||
Arg.Any<Node>(), inbound.RemoteInboundId, config.Protocol, Arg.Any<string>(),
|
||||
Arg.Any<string>(), config.DeviceLimit, Arg.Any<CancellationToken>())
|
||||
.Returns(Result.Success("new-external-id"));
|
||||
|
||||
var handler = new RotateVpnConfigCommandHandler(dbContext, _gateway, FakeCurrentUser.Authenticated(userId));
|
||||
|
||||
var result = await handler.Handle(new RotateVpnConfigCommand(config.Id), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal("new-external-id", config.ClientExternalId);
|
||||
await _gateway.Received(1).RemoveClientAsync(
|
||||
Arg.Any<Node>(), inbound.RemoteInboundId, "old-external-id", config.Protocol, Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenConfigNotFound_ReturnsNotFound()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var userId = Guid.NewGuid();
|
||||
|
||||
var handler = new RotateVpnConfigCommandHandler(dbContext, _gateway, FakeCurrentUser.Authenticated(userId));
|
||||
|
||||
var result = await handler.Handle(new RotateVpnConfigCommand(Guid.NewGuid()), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(ConfigErrors.NotFound, result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenConfigBelongsToAnotherUser_ReturnsNotFound()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var ownerId = Guid.NewGuid();
|
||||
var otherUserId = Guid.NewGuid();
|
||||
|
||||
var inbound = Inbound.FromRemote(Guid.NewGuid(), "1", VpnProtocol.Vless, "remark", 443);
|
||||
var config = VpnConfig.Create(ownerId, inbound.Id, VpnProtocol.Vless, null, 0);
|
||||
|
||||
dbContext.Inbounds.Add(inbound);
|
||||
dbContext.VpnConfigs.Add(config);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var handler = new RotateVpnConfigCommandHandler(dbContext, _gateway, FakeCurrentUser.Authenticated(otherUserId));
|
||||
|
||||
var result = await handler.Handle(new RotateVpnConfigCommand(config.Id), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(ConfigErrors.NotFound, result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenConfigAlreadyRevoked_ReturnsNotFound()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var userId = Guid.NewGuid();
|
||||
|
||||
var inbound = Inbound.FromRemote(Guid.NewGuid(), "1", VpnProtocol.Vless, "remark", 443);
|
||||
var config = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, null, 0);
|
||||
config.Revoke();
|
||||
|
||||
dbContext.Inbounds.Add(inbound);
|
||||
dbContext.VpnConfigs.Add(config);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var handler = new RotateVpnConfigCommandHandler(dbContext, _gateway, FakeCurrentUser.Authenticated(userId));
|
||||
|
||||
var result = await handler.Handle(new RotateVpnConfigCommand(config.Id), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(ConfigErrors.NotFound, result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenGatewayAddClientFails_ReturnsFailureWithoutMutatingConfig()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var userId = Guid.NewGuid();
|
||||
|
||||
var node = Node.Register("node-1", new Uri("https://node1.example.com"), new NodeCredentials("admin", "protected"), null);
|
||||
var inbound = Inbound.FromRemote(node.Id, "1", VpnProtocol.Vless, "remark", 443);
|
||||
var config = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, null, 0);
|
||||
config.AssignRemoteClient("old-external-id");
|
||||
|
||||
dbContext.Nodes.Add(node);
|
||||
dbContext.Inbounds.Add(inbound);
|
||||
dbContext.VpnConfigs.Add(config);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var gatewayError = Error.Failure("Xui.Unreachable", "Панель недоступна.");
|
||||
_gateway.AddClientAsync(
|
||||
Arg.Any<Node>(), inbound.RemoteInboundId, config.Protocol, Arg.Any<string>(),
|
||||
Arg.Any<string>(), config.DeviceLimit, Arg.Any<CancellationToken>())
|
||||
.Returns(Result.Failure<string>(gatewayError));
|
||||
|
||||
var handler = new RotateVpnConfigCommandHandler(dbContext, _gateway, FakeCurrentUser.Authenticated(userId));
|
||||
|
||||
var result = await handler.Handle(new RotateVpnConfigCommand(config.Id), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(gatewayError, result.Error);
|
||||
Assert.Equal("old-external-id", config.ClientExternalId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<EnforceCodeStyleInBuild>false</EnforceCodeStyleInBuild>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="NSubstitute" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\PnvPanel.Application\PnvPanel.Application.csproj" />
|
||||
<ProjectReference Include="..\..\src\PnvPanel.Infrastructure\PnvPanel.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
using NSubstitute;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Telegram;
|
||||
using PnvPanel.Application.Telegram.Bot;
|
||||
using PnvPanel.Application.Tests.TestSupport;
|
||||
using PnvPanel.Domain.Telegram;
|
||||
using Xunit;
|
||||
|
||||
namespace PnvPanel.Application.Tests.Telegram.Bot;
|
||||
|
||||
public class ApproveTelegramLoginCommandHandlerTests
|
||||
{
|
||||
private readonly IIdentityService _identityService = Substitute.For<IIdentityService>();
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenPendingRequestAndLinkedUser_ApprovesRequest()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var userId = Guid.NewGuid();
|
||||
const long telegramUserId = 123456L;
|
||||
|
||||
var request = TelegramLoginRequest.Create(TimeSpan.FromMinutes(5), null);
|
||||
dbContext.TelegramLoginRequests.Add(request);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
_identityService.FindUserIdByTelegramUserIdAsync(telegramUserId, Arg.Any<CancellationToken>()).Returns(userId);
|
||||
|
||||
var handler = new ApproveTelegramLoginCommandHandler(dbContext, _identityService);
|
||||
|
||||
var result = await handler.Handle(new ApproveTelegramLoginCommand(request.Id, telegramUserId), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(TelegramLoginStatus.Approved, request.Status);
|
||||
Assert.Equal(userId, request.UserId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenTelegramUserNotLinked_ReturnsNotLinked()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
const long telegramUserId = 123456L;
|
||||
|
||||
_identityService.FindUserIdByTelegramUserIdAsync(telegramUserId, Arg.Any<CancellationToken>())
|
||||
.Returns((Guid?)null);
|
||||
|
||||
var handler = new ApproveTelegramLoginCommandHandler(dbContext, _identityService);
|
||||
|
||||
var result = await handler.Handle(new ApproveTelegramLoginCommand(Guid.NewGuid(), telegramUserId), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(TelegramErrors.NotLinked, result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenLoginRequestNotFound_ReturnsNotFound()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var userId = Guid.NewGuid();
|
||||
const long telegramUserId = 123456L;
|
||||
|
||||
_identityService.FindUserIdByTelegramUserIdAsync(telegramUserId, Arg.Any<CancellationToken>()).Returns(userId);
|
||||
|
||||
var handler = new ApproveTelegramLoginCommandHandler(dbContext, _identityService);
|
||||
|
||||
var result = await handler.Handle(new ApproveTelegramLoginCommand(Guid.NewGuid(), telegramUserId), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(TelegramErrors.LoginRequestNotFound, result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenRequestAlreadyDecided_ReturnsConflict()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var userId = Guid.NewGuid();
|
||||
const long telegramUserId = 123456L;
|
||||
|
||||
var request = TelegramLoginRequest.Create(TimeSpan.FromMinutes(5), null);
|
||||
request.Reject();
|
||||
dbContext.TelegramLoginRequests.Add(request);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
_identityService.FindUserIdByTelegramUserIdAsync(telegramUserId, Arg.Any<CancellationToken>()).Returns(userId);
|
||||
|
||||
var handler = new ApproveTelegramLoginCommandHandler(dbContext, _identityService);
|
||||
|
||||
var result = await handler.Handle(new ApproveTelegramLoginCommand(request.Id, telegramUserId), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal("Telegram.LoginRequestInvalid", result.Error.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenRequestExpired_ReturnsConflict()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var userId = Guid.NewGuid();
|
||||
const long telegramUserId = 123456L;
|
||||
|
||||
var request = TelegramLoginRequest.Create(TimeSpan.FromMinutes(-5), null);
|
||||
dbContext.TelegramLoginRequests.Add(request);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
_identityService.FindUserIdByTelegramUserIdAsync(telegramUserId, Arg.Any<CancellationToken>()).Returns(userId);
|
||||
|
||||
var handler = new ApproveTelegramLoginCommandHandler(dbContext, _identityService);
|
||||
|
||||
var result = await handler.Handle(new ApproveTelegramLoginCommand(request.Id, telegramUserId), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal("Telegram.LoginRequestInvalid", result.Error.Code);
|
||||
}
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
using NSubstitute;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
using PnvPanel.Application.Telegram;
|
||||
using PnvPanel.Application.Telegram.Bot;
|
||||
using PnvPanel.Application.Tests.TestSupport;
|
||||
using PnvPanel.Domain.Telegram;
|
||||
using Xunit;
|
||||
|
||||
namespace PnvPanel.Application.Tests.Telegram.Bot;
|
||||
|
||||
public class LinkTelegramCommandHandlerTests
|
||||
{
|
||||
private readonly IIdentityService _identityService = Substitute.For<IIdentityService>();
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenTokenValid_LinksUserAndConsumesToken()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var userId = Guid.NewGuid();
|
||||
var token = TelegramLinkToken.Create(userId, TimeSpan.FromMinutes(5));
|
||||
dbContext.TelegramLinkTokens.Add(token);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
_identityService.LinkTelegramAsync(userId, 123456L, "alice_tg", Arg.Any<CancellationToken>())
|
||||
.Returns(Result.Success());
|
||||
|
||||
var handler = new LinkTelegramCommandHandler(dbContext, _identityService);
|
||||
|
||||
var result = await handler.Handle(new LinkTelegramCommand(token.Token, 123456L, "alice_tg"), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(userId, result.Value);
|
||||
Assert.False(token.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenTokenNotFound_ReturnsNotFound()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
|
||||
var handler = new LinkTelegramCommandHandler(dbContext, _identityService);
|
||||
|
||||
var result = await handler.Handle(new LinkTelegramCommand("missing-token", 123456L, null), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(TelegramErrors.LinkTokenNotFound, result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenTokenAlreadyConsumed_ReturnsNotFound()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var userId = Guid.NewGuid();
|
||||
var token = TelegramLinkToken.Create(userId, TimeSpan.FromMinutes(5));
|
||||
token.Consume();
|
||||
dbContext.TelegramLinkTokens.Add(token);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var handler = new LinkTelegramCommandHandler(dbContext, _identityService);
|
||||
|
||||
var result = await handler.Handle(new LinkTelegramCommand(token.Token, 123456L, null), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(TelegramErrors.LinkTokenNotFound, result.Error);
|
||||
await _identityService.DidNotReceive().LinkTelegramAsync(
|
||||
Arg.Any<Guid>(), Arg.Any<long>(), Arg.Any<string?>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenTokenExpired_ReturnsNotFound()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var userId = Guid.NewGuid();
|
||||
var token = TelegramLinkToken.Create(userId, TimeSpan.FromMinutes(-5));
|
||||
dbContext.TelegramLinkTokens.Add(token);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var handler = new LinkTelegramCommandHandler(dbContext, _identityService);
|
||||
|
||||
var result = await handler.Handle(new LinkTelegramCommand(token.Token, 123456L, null), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(TelegramErrors.LinkTokenNotFound, result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenIdentityServiceFails_ReturnsFailureWithoutConsumingToken()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var userId = Guid.NewGuid();
|
||||
var token = TelegramLinkToken.Create(userId, TimeSpan.FromMinutes(5));
|
||||
dbContext.TelegramLinkTokens.Add(token);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var error = Error.Conflict("Telegram.AlreadyLinked", "Этот Telegram уже привязан к другому аккаунту.");
|
||||
_identityService.LinkTelegramAsync(userId, 123456L, null, Arg.Any<CancellationToken>())
|
||||
.Returns(Result.Failure(error));
|
||||
|
||||
var handler = new LinkTelegramCommandHandler(dbContext, _identityService);
|
||||
|
||||
var result = await handler.Handle(new LinkTelegramCommand(token.Token, 123456L, null), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(error, result.Error);
|
||||
Assert.True(token.IsValid);
|
||||
}
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
using NSubstitute;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Telegram;
|
||||
using PnvPanel.Application.Telegram.Bot;
|
||||
using PnvPanel.Application.Tests.TestSupport;
|
||||
using PnvPanel.Domain.Telegram;
|
||||
using Xunit;
|
||||
|
||||
namespace PnvPanel.Application.Tests.Telegram.Bot;
|
||||
|
||||
public class RejectTelegramLoginCommandHandlerTests
|
||||
{
|
||||
private readonly IIdentityService _identityService = Substitute.For<IIdentityService>();
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenPendingRequestAndLinkedUser_RejectsRequest()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var userId = Guid.NewGuid();
|
||||
const long telegramUserId = 123456L;
|
||||
|
||||
var request = TelegramLoginRequest.Create(TimeSpan.FromMinutes(5), null);
|
||||
dbContext.TelegramLoginRequests.Add(request);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
_identityService.FindUserIdByTelegramUserIdAsync(telegramUserId, Arg.Any<CancellationToken>()).Returns(userId);
|
||||
|
||||
var handler = new RejectTelegramLoginCommandHandler(dbContext, _identityService);
|
||||
|
||||
var result = await handler.Handle(new RejectTelegramLoginCommand(request.Id, telegramUserId), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(TelegramLoginStatus.Rejected, request.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenTelegramUserNotLinked_ReturnsNotLinked()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
const long telegramUserId = 123456L;
|
||||
|
||||
_identityService.FindUserIdByTelegramUserIdAsync(telegramUserId, Arg.Any<CancellationToken>())
|
||||
.Returns((Guid?)null);
|
||||
|
||||
var handler = new RejectTelegramLoginCommandHandler(dbContext, _identityService);
|
||||
|
||||
var result = await handler.Handle(new RejectTelegramLoginCommand(Guid.NewGuid(), telegramUserId), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(TelegramErrors.NotLinked, result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenLoginRequestNotFound_ReturnsNotFound()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var userId = Guid.NewGuid();
|
||||
const long telegramUserId = 123456L;
|
||||
|
||||
_identityService.FindUserIdByTelegramUserIdAsync(telegramUserId, Arg.Any<CancellationToken>()).Returns(userId);
|
||||
|
||||
var handler = new RejectTelegramLoginCommandHandler(dbContext, _identityService);
|
||||
|
||||
var result = await handler.Handle(new RejectTelegramLoginCommand(Guid.NewGuid(), telegramUserId), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(TelegramErrors.LoginRequestNotFound, result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenRequestAlreadyDecided_ReturnsConflict()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var userId = Guid.NewGuid();
|
||||
const long telegramUserId = 123456L;
|
||||
|
||||
var request = TelegramLoginRequest.Create(TimeSpan.FromMinutes(5), null);
|
||||
request.Approve(userId);
|
||||
dbContext.TelegramLoginRequests.Add(request);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
_identityService.FindUserIdByTelegramUserIdAsync(telegramUserId, Arg.Any<CancellationToken>()).Returns(userId);
|
||||
|
||||
var handler = new RejectTelegramLoginCommandHandler(dbContext, _identityService);
|
||||
|
||||
var result = await handler.Handle(new RejectTelegramLoginCommand(request.Id, telegramUserId), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal("Telegram.LoginRequestInvalid", result.Error.Code);
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
using PnvPanel.Application.Auth;
|
||||
using PnvPanel.Application.Telegram;
|
||||
using PnvPanel.Application.Tests.TestSupport;
|
||||
using Xunit;
|
||||
|
||||
namespace PnvPanel.Application.Tests.Telegram;
|
||||
|
||||
public class CreateLinkTokenCommandHandlerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Handle_WhenAuthenticated_CreatesTokenAndAddsToDbContext()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var userId = Guid.NewGuid();
|
||||
|
||||
var handler = new CreateLinkTokenCommandHandler(dbContext, FakeCurrentUser.Authenticated(userId));
|
||||
|
||||
var result = await handler.Handle(new CreateLinkTokenCommand(), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.False(string.IsNullOrEmpty(result.Value.Token));
|
||||
Assert.True(result.Value.ExpiresAt > DateTimeOffset.UtcNow);
|
||||
Assert.Single(dbContext.TelegramLinkTokens.Local);
|
||||
Assert.Equal(userId, dbContext.TelegramLinkTokens.Local.Single().UserId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenNotAuthenticated_ReturnsUnauthorized()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
|
||||
var handler = new CreateLinkTokenCommandHandler(dbContext, FakeCurrentUser.Anonymous());
|
||||
|
||||
var result = await handler.Handle(new CreateLinkTokenCommand(), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(AuthErrors.Unauthorized, result.Error);
|
||||
Assert.Empty(dbContext.TelegramLinkTokens.Local);
|
||||
}
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
using NSubstitute;
|
||||
using PnvPanel.Application.Auth;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Tests.TestSupport;
|
||||
using PnvPanel.Domain.Telegram;
|
||||
using Xunit;
|
||||
using TelegramNs = PnvPanel.Application.Telegram;
|
||||
|
||||
namespace PnvPanel.Application.Tests.Telegram;
|
||||
|
||||
public class GetLoginRequestStatusQueryHandlerTests
|
||||
{
|
||||
private readonly IIdentityService _identityService = Substitute.For<IIdentityService>();
|
||||
private readonly IJwtTokenService _jwtTokenService = Substitute.For<IJwtTokenService>();
|
||||
private readonly IRefreshTokenService _refreshTokenService = Substitute.For<IRefreshTokenService>();
|
||||
|
||||
private TelegramNs.GetLoginRequestStatusQueryHandler CreateHandler(PnvPanel.Infrastructure.Persistence.AppDbContext dbContext)
|
||||
=> new(dbContext, _identityService, _jwtTokenService, _refreshTokenService);
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenRequestNotFound_ReturnsNotFound()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
|
||||
var handler = CreateHandler(dbContext);
|
||||
|
||||
var result = await handler.Handle(new TelegramNs.GetLoginRequestStatusQuery(Guid.NewGuid()), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(TelegramNs.TelegramErrors.LoginRequestNotFound, result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenPendingAndExpired_ReturnsExpiredStatusWithoutIssuingTokens()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var request = TelegramLoginRequest.Create(TimeSpan.FromMinutes(-5), null);
|
||||
dbContext.TelegramLoginRequests.Add(request);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var handler = CreateHandler(dbContext);
|
||||
|
||||
var result = await handler.Handle(new TelegramNs.GetLoginRequestStatusQuery(request.Id), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(TelegramLoginStatus.Expired, result.Value.Status);
|
||||
Assert.Null(result.Value.Auth);
|
||||
await _refreshTokenService.DidNotReceive().IssueAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenStillPending_ReturnsPendingStatusWithoutAuth()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var request = TelegramLoginRequest.Create(TimeSpan.FromMinutes(5), null);
|
||||
dbContext.TelegramLoginRequests.Add(request);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var handler = CreateHandler(dbContext);
|
||||
|
||||
var result = await handler.Handle(new TelegramNs.GetLoginRequestStatusQuery(request.Id), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(TelegramLoginStatus.Pending, result.Value.Status);
|
||||
Assert.Null(result.Value.Auth);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenApproved_ConsumesRequestAndReturnsAuthResult()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var userId = Guid.NewGuid();
|
||||
var request = TelegramLoginRequest.Create(TimeSpan.FromMinutes(5), null);
|
||||
request.Approve(userId);
|
||||
dbContext.TelegramLoginRequests.Add(request);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var profile = new CurrentUserProfile(userId, "alice", Guid.NewGuid(), "user", true, false, 3);
|
||||
_identityService.GetProfileAsync(userId, Arg.Any<CancellationToken>()).Returns(profile);
|
||||
_jwtTokenService.GenerateAccessToken(Arg.Any<AuthenticatedUser>())
|
||||
.Returns(("access-token", DateTimeOffset.UtcNow.AddMinutes(15)));
|
||||
_refreshTokenService.IssueAsync(userId, Arg.Any<CancellationToken>())
|
||||
.Returns(new IssuedRefreshToken("refresh-token", DateTimeOffset.UtcNow.AddDays(30)));
|
||||
|
||||
var handler = CreateHandler(dbContext);
|
||||
|
||||
var result = await handler.Handle(new TelegramNs.GetLoginRequestStatusQuery(request.Id), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(TelegramLoginStatus.Approved, result.Value.Status);
|
||||
Assert.NotNull(result.Value.Auth);
|
||||
Assert.Equal("access-token", result.Value.Auth!.AccessToken);
|
||||
Assert.True(result.Value.Auth.User.TelegramLinked);
|
||||
Assert.Equal(TelegramLoginStatus.Consumed, request.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenApprovedButProfileMissing_ReturnsUnauthorized()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var userId = Guid.NewGuid();
|
||||
var request = TelegramLoginRequest.Create(TimeSpan.FromMinutes(5), null);
|
||||
request.Approve(userId);
|
||||
dbContext.TelegramLoginRequests.Add(request);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
_identityService.GetProfileAsync(userId, Arg.Any<CancellationToken>()).Returns((CurrentUserProfile?)null);
|
||||
|
||||
var handler = CreateHandler(dbContext);
|
||||
|
||||
var result = await handler.Handle(new TelegramNs.GetLoginRequestStatusQuery(request.Id), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(AuthErrors.Unauthorized, result.Error);
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
using NSubstitute;
|
||||
using PnvPanel.Application.Auth;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
using PnvPanel.Application.Telegram;
|
||||
using PnvPanel.Application.Tests.TestSupport;
|
||||
using Xunit;
|
||||
|
||||
namespace PnvPanel.Application.Tests.Telegram;
|
||||
|
||||
public class UnlinkTelegramCommandHandlerTests
|
||||
{
|
||||
private readonly IIdentityService _identityService = Substitute.For<IIdentityService>();
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenAuthenticated_DelegatesToIdentityService()
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
_identityService.UnlinkTelegramAsync(userId, Arg.Any<CancellationToken>()).Returns(Result.Success());
|
||||
|
||||
var handler = new UnlinkTelegramCommandHandler(_identityService, FakeCurrentUser.Authenticated(userId));
|
||||
|
||||
var result = await handler.Handle(new UnlinkTelegramCommand(), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
await _identityService.Received(1).UnlinkTelegramAsync(userId, Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenNotAuthenticated_ReturnsUnauthorized()
|
||||
{
|
||||
var handler = new UnlinkTelegramCommandHandler(_identityService, FakeCurrentUser.Anonymous());
|
||||
|
||||
var result = await handler.Handle(new UnlinkTelegramCommand(), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(AuthErrors.Unauthorized, result.Error);
|
||||
await _identityService.DidNotReceive().UnlinkTelegramAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenIdentityServiceFails_PropagatesFailure()
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
var error = TelegramErrors.NotLinked;
|
||||
_identityService.UnlinkTelegramAsync(userId, Arg.Any<CancellationToken>()).Returns(Result.Failure(error));
|
||||
|
||||
var handler = new UnlinkTelegramCommandHandler(_identityService, FakeCurrentUser.Authenticated(userId));
|
||||
|
||||
var result = await handler.Handle(new UnlinkTelegramCommand(), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(error, result.Error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
|
||||
namespace PnvPanel.Application.Tests.TestSupport;
|
||||
|
||||
public sealed class FakeCurrentUser : ICurrentUser
|
||||
{
|
||||
public Guid? UserId { get; set; }
|
||||
|
||||
public string? UserName { get; set; }
|
||||
|
||||
public bool IsAuthenticated => UserId is not null;
|
||||
|
||||
public static FakeCurrentUser Authenticated(Guid userId, string userName = "testuser")
|
||||
=> new() { UserId = userId, UserName = userName };
|
||||
|
||||
public static FakeCurrentUser Anonymous() => new();
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PnvPanel.Infrastructure.Persistence;
|
||||
|
||||
namespace PnvPanel.Application.Tests.TestSupport;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core InMemory provider, не Sqlite/Npgsql — модель использует Postgres-специфичные типы
|
||||
/// (uuid[] на Inbound.AllowedRoleIds, jsonb на AuditLog.Metadata), не имеющие реляционных
|
||||
/// аналогов. InMemory игнорирует HasColumnType и не требует их маппинга.
|
||||
/// </summary>
|
||||
public static class InMemoryDbContextFactory
|
||||
{
|
||||
public static AppDbContext Create()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
|
||||
return new AppDbContext(options);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user