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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using PnvPanel.Domain.Activation;
|
||||
using PnvPanel.Domain.Exceptions;
|
||||
using Xunit;
|
||||
|
||||
namespace PnvPanel.Domain.Tests.Activation;
|
||||
|
||||
public class ActivationRequestTests
|
||||
{
|
||||
[Fact]
|
||||
public void Create_SetsPendingStatus()
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
|
||||
var request = ActivationRequest.Create(userId, "Please activate me");
|
||||
|
||||
Assert.Equal(userId, request.UserId);
|
||||
Assert.Equal("Please activate me", request.Comment);
|
||||
Assert.Equal(ActivationStatus.Pending, request.Status);
|
||||
Assert.Null(request.DecidedBy);
|
||||
Assert.Null(request.DecidedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Approve_WhenPending_SetsApprovedAndDecisionMetadata()
|
||||
{
|
||||
var request = ActivationRequest.Create(Guid.NewGuid(), null);
|
||||
var adminId = Guid.NewGuid();
|
||||
|
||||
request.Approve(adminId);
|
||||
|
||||
Assert.Equal(ActivationStatus.Approved, request.Status);
|
||||
Assert.Equal(adminId, request.DecidedBy);
|
||||
Assert.NotNull(request.DecidedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reject_WhenPending_SetsRejectedWithReason()
|
||||
{
|
||||
var request = ActivationRequest.Create(Guid.NewGuid(), null);
|
||||
var adminId = Guid.NewGuid();
|
||||
|
||||
request.Reject(adminId, "не хватает информации");
|
||||
|
||||
Assert.Equal(ActivationStatus.Rejected, request.Status);
|
||||
Assert.Equal(adminId, request.DecidedBy);
|
||||
Assert.Equal("не хватает информации", request.RejectionReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Approve_WhenAlreadyApproved_Throws()
|
||||
{
|
||||
var request = ActivationRequest.Create(Guid.NewGuid(), null);
|
||||
request.Approve(Guid.NewGuid());
|
||||
|
||||
Assert.Throws<DomainException>(() => request.Approve(Guid.NewGuid()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reject_WhenAlreadyRejected_Throws()
|
||||
{
|
||||
var request = ActivationRequest.Create(Guid.NewGuid(), null);
|
||||
request.Reject(Guid.NewGuid(), null);
|
||||
|
||||
Assert.Throws<DomainException>(() => request.Reject(Guid.NewGuid(), null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reject_WhenAlreadyApproved_Throws()
|
||||
{
|
||||
var request = ActivationRequest.Create(Guid.NewGuid(), null);
|
||||
request.Approve(Guid.NewGuid());
|
||||
|
||||
Assert.Throws<DomainException>(() => request.Reject(Guid.NewGuid(), null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using PnvPanel.Domain.Apps;
|
||||
using Xunit;
|
||||
|
||||
namespace PnvPanel.Domain.Tests.Apps;
|
||||
|
||||
public class ClientAppTests
|
||||
{
|
||||
[Fact]
|
||||
public void Create_SetsEnabledByDefault()
|
||||
{
|
||||
var app = ClientApp.Create("v2rayNG", new Uri("https://play.google.com/store/apps/details?id=x"), OsPlatform.Android, "desc", null, 1);
|
||||
|
||||
Assert.Equal("v2rayNG", app.Name);
|
||||
Assert.Equal(OsPlatform.Android, app.OperatingSystem);
|
||||
Assert.True(app.IsEnabled);
|
||||
Assert.Equal(1, app.SortOrder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_ReplacesAllMutableFields()
|
||||
{
|
||||
var app = ClientApp.Create("Old", new Uri("https://old.example.com"), OsPlatform.IOS, "old", "old-icon", 1);
|
||||
|
||||
app.Update("New", new Uri("https://new.example.com"), OsPlatform.MacOS, "new", "new-icon", 2, isEnabled: false);
|
||||
|
||||
Assert.Equal("New", app.Name);
|
||||
Assert.Equal(new Uri("https://new.example.com"), app.DownloadUrl);
|
||||
Assert.Equal(OsPlatform.MacOS, app.OperatingSystem);
|
||||
Assert.Equal("new", app.Description);
|
||||
Assert.Equal("new-icon", app.IconUrl);
|
||||
Assert.Equal(2, app.SortOrder);
|
||||
Assert.False(app.IsEnabled);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using PnvPanel.Domain.Audit;
|
||||
using Xunit;
|
||||
|
||||
namespace PnvPanel.Domain.Tests.Audit;
|
||||
|
||||
public class AuditLogTests
|
||||
{
|
||||
[Fact]
|
||||
public void Create_SetsAllFieldsAndCreatedAt()
|
||||
{
|
||||
var actorId = Guid.NewGuid();
|
||||
|
||||
var log = AuditLog.Create(actorId, "user.blocked", "AppUser", actorId.ToString(), "{\"reason\":\"abuse\"}", AuditSource.Web);
|
||||
|
||||
Assert.Equal(actorId, log.ActorId);
|
||||
Assert.Equal("user.blocked", log.Action);
|
||||
Assert.Equal("AppUser", log.TargetType);
|
||||
Assert.Equal(actorId.ToString(), log.TargetId);
|
||||
Assert.Equal("{\"reason\":\"abuse\"}", log.Metadata);
|
||||
Assert.Equal(AuditSource.Web, log.Source);
|
||||
Assert.True(log.CreatedAt <= DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_AllowsNullActorForSystemActions()
|
||||
{
|
||||
var log = AuditLog.Create(null, "node.healthcheck", "Node", Guid.NewGuid().ToString(), null, AuditSource.System);
|
||||
|
||||
Assert.Null(log.ActorId);
|
||||
Assert.Equal(AuditSource.System, log.Source);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using PnvPanel.Domain.Common;
|
||||
using Xunit;
|
||||
|
||||
namespace PnvPanel.Domain.Tests.Common;
|
||||
|
||||
public class EntityTests
|
||||
{
|
||||
private sealed class FakeEntityA : Entity
|
||||
{
|
||||
public FakeEntityA(Guid id) => Id = id;
|
||||
}
|
||||
|
||||
private sealed class FakeEntityB : Entity
|
||||
{
|
||||
public FakeEntityB(Guid id) => Id = id;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Equals_SameTypeAndId_ReturnsTrue()
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var a = new FakeEntityA(id);
|
||||
var b = new FakeEntityA(id);
|
||||
|
||||
Assert.Equal(a, b);
|
||||
Assert.True(a == b);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Equals_DifferentTypesSameId_ReturnsFalse()
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var a = new FakeEntityA(id);
|
||||
var b = new FakeEntityB(id);
|
||||
|
||||
Assert.False(a.Equals(b));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Equals_SameTypeDifferentId_ReturnsFalse()
|
||||
{
|
||||
var a = new FakeEntityA(Guid.NewGuid());
|
||||
var b = new FakeEntityA(Guid.NewGuid());
|
||||
|
||||
Assert.NotEqual(a, b);
|
||||
Assert.True(a != b);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetHashCode_SameTypeAndId_AreEqual()
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var a = new FakeEntityA(id);
|
||||
var b = new FakeEntityA(id);
|
||||
|
||||
Assert.Equal(a.GetHashCode(), b.GetHashCode());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using PnvPanel.Domain.Configs;
|
||||
using Xunit;
|
||||
|
||||
namespace PnvPanel.Domain.Tests.Configs;
|
||||
|
||||
public class TrafficSampleTests
|
||||
{
|
||||
[Fact]
|
||||
public void Create_SetsAllFields()
|
||||
{
|
||||
var configId = Guid.NewGuid();
|
||||
var timestamp = DateTimeOffset.UtcNow;
|
||||
|
||||
var sample = TrafficSample.Create(configId, timestamp, upBytes: 1000, downBytes: 2000);
|
||||
|
||||
Assert.Equal(configId, sample.ConfigId);
|
||||
Assert.Equal(timestamp, sample.Timestamp);
|
||||
Assert.Equal(1000, sample.UpBytes);
|
||||
Assert.Equal(2000, sample.DownBytes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
using PnvPanel.Domain.Configs;
|
||||
using PnvPanel.Domain.Exceptions;
|
||||
using PnvPanel.Domain.Inbounds;
|
||||
using Xunit;
|
||||
|
||||
namespace PnvPanel.Domain.Tests.Configs;
|
||||
|
||||
public class VpnConfigTests
|
||||
{
|
||||
[Fact]
|
||||
public void Create_SetsActiveStatusAndGeneratesEmailAndToken()
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
var inboundId = Guid.NewGuid();
|
||||
|
||||
var config = VpnConfig.Create(userId, inboundId, VpnProtocol.Vless, "My device", deviceLimit: 3);
|
||||
|
||||
Assert.Equal(userId, config.UserId);
|
||||
Assert.Equal(inboundId, config.InboundId);
|
||||
Assert.Equal(VpnProtocol.Vless, config.Protocol);
|
||||
Assert.Equal("My device", config.Label);
|
||||
Assert.Equal(3, config.DeviceLimit);
|
||||
Assert.Equal(ConfigStatus.Active, config.Status);
|
||||
Assert.Equal(string.Empty, config.ClientExternalId);
|
||||
Assert.False(string.IsNullOrWhiteSpace(config.ClientEmail));
|
||||
Assert.StartsWith("pnv_", config.ClientEmail);
|
||||
Assert.False(string.IsNullOrWhiteSpace(config.SubscriptionToken));
|
||||
Assert.NotEqual(Guid.Empty, config.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_GeneratesUniqueSubscriptionTokensAndClientEmails()
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
var a = VpnConfig.Create(userId, Guid.NewGuid(), VpnProtocol.Vless, null, 1);
|
||||
var b = VpnConfig.Create(userId, Guid.NewGuid(), VpnProtocol.Vless, null, 1);
|
||||
|
||||
Assert.NotEqual(a.SubscriptionToken, b.SubscriptionToken);
|
||||
Assert.NotEqual(a.ClientEmail, b.ClientEmail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AssignRemoteClient_SetsClientExternalId()
|
||||
{
|
||||
var config = VpnConfig.Create(Guid.NewGuid(), Guid.NewGuid(), VpnProtocol.Trojan, null, 1);
|
||||
|
||||
config.AssignRemoteClient("some-remote-password");
|
||||
|
||||
Assert.Equal("some-remote-password", config.ClientExternalId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rotate_WhenActive_ChangesEmailExternalIdAndToken()
|
||||
{
|
||||
var config = VpnConfig.Create(Guid.NewGuid(), Guid.NewGuid(), VpnProtocol.Vless, null, 1);
|
||||
config.AssignRemoteClient("old-id");
|
||||
var oldToken = config.SubscriptionToken;
|
||||
var oldEmail = config.ClientEmail;
|
||||
|
||||
config.Rotate("new-email", "new-id");
|
||||
|
||||
Assert.Equal("new-email", config.ClientEmail);
|
||||
Assert.Equal("new-id", config.ClientExternalId);
|
||||
Assert.NotEqual(oldToken, config.SubscriptionToken);
|
||||
Assert.NotEqual(oldEmail, config.ClientEmail);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(ConfigStatus.Revoked)]
|
||||
[InlineData(ConfigStatus.Disabled)]
|
||||
public void Rotate_WhenNotActive_Throws(ConfigStatus status)
|
||||
{
|
||||
var config = VpnConfig.Create(Guid.NewGuid(), Guid.NewGuid(), VpnProtocol.Vless, null, 1);
|
||||
MoveToStatus(config, status);
|
||||
|
||||
Assert.Throws<DomainException>(() => config.Rotate("e", "i"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Revoke_WhenActive_SetsRevokedStatus()
|
||||
{
|
||||
var config = VpnConfig.Create(Guid.NewGuid(), Guid.NewGuid(), VpnProtocol.Vless, null, 1);
|
||||
|
||||
config.Revoke();
|
||||
|
||||
Assert.Equal(ConfigStatus.Revoked, config.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Revoke_WhenAlreadyRevoked_Throws()
|
||||
{
|
||||
var config = VpnConfig.Create(Guid.NewGuid(), Guid.NewGuid(), VpnProtocol.Vless, null, 1);
|
||||
config.Revoke();
|
||||
|
||||
Assert.Throws<DomainException>(() => config.Revoke());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Disable_WhenActive_SetsDisabled()
|
||||
{
|
||||
var config = VpnConfig.Create(Guid.NewGuid(), Guid.NewGuid(), VpnProtocol.Vless, null, 1);
|
||||
|
||||
config.Disable();
|
||||
|
||||
Assert.Equal(ConfigStatus.Disabled, config.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Disable_WhenRevoked_DoesNotChangeStatus()
|
||||
{
|
||||
var config = VpnConfig.Create(Guid.NewGuid(), Guid.NewGuid(), VpnProtocol.Vless, null, 1);
|
||||
config.Revoke();
|
||||
|
||||
config.Disable();
|
||||
|
||||
Assert.Equal(ConfigStatus.Revoked, config.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enable_WhenDisabled_ReturnsToActive()
|
||||
{
|
||||
var config = VpnConfig.Create(Guid.NewGuid(), Guid.NewGuid(), VpnProtocol.Vless, null, 1);
|
||||
config.Disable();
|
||||
|
||||
config.Enable();
|
||||
|
||||
Assert.Equal(ConfigStatus.Active, config.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enable_WhenRevoked_DoesNotResurrect()
|
||||
{
|
||||
var config = VpnConfig.Create(Guid.NewGuid(), Guid.NewGuid(), VpnProtocol.Vless, null, 1);
|
||||
config.Revoke();
|
||||
|
||||
config.Enable();
|
||||
|
||||
Assert.Equal(ConfigStatus.Revoked, config.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateTraffic_SetsBytesAndLastSyncAt()
|
||||
{
|
||||
var config = VpnConfig.Create(Guid.NewGuid(), Guid.NewGuid(), VpnProtocol.Vless, null, 1);
|
||||
|
||||
config.UpdateTraffic(100, 200);
|
||||
|
||||
Assert.Equal(100, config.UsedUpBytes);
|
||||
Assert.Equal(200, config.UsedDownBytes);
|
||||
Assert.NotNull(config.LastSyncAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateClientEmail_IsDeterministicPrefixWithRandomSuffix()
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
var email1 = VpnConfig.GenerateClientEmail(userId);
|
||||
var email2 = VpnConfig.GenerateClientEmail(userId);
|
||||
|
||||
var expectedPrefix = $"pnv_{userId:N}"[..12];
|
||||
Assert.StartsWith(expectedPrefix, email1);
|
||||
Assert.NotEqual(email1, email2);
|
||||
}
|
||||
|
||||
private static void MoveToStatus(VpnConfig config, ConfigStatus status)
|
||||
{
|
||||
switch (status)
|
||||
{
|
||||
case ConfigStatus.Revoked:
|
||||
config.Revoke();
|
||||
break;
|
||||
case ConfigStatus.Disabled:
|
||||
config.Disable();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using PnvPanel.Domain.Inbounds;
|
||||
using Xunit;
|
||||
|
||||
namespace PnvPanel.Domain.Tests.Inbounds;
|
||||
|
||||
public class InboundTests
|
||||
{
|
||||
[Fact]
|
||||
public void FromRemote_CreatesUnpublishedInbound()
|
||||
{
|
||||
var nodeId = Guid.NewGuid();
|
||||
|
||||
var inbound = Inbound.FromRemote(nodeId, "12", VpnProtocol.Vless, "Germany", 443);
|
||||
|
||||
Assert.Equal(nodeId, inbound.NodeId);
|
||||
Assert.Equal("12", inbound.RemoteInboundId);
|
||||
Assert.Equal(VpnProtocol.Vless, inbound.Protocol);
|
||||
Assert.Equal(443, inbound.Port);
|
||||
Assert.False(inbound.IsPublished);
|
||||
Assert.Empty(inbound.AllowedRoleIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateFromRemote_UpdatesFieldsAndLastSyncAt()
|
||||
{
|
||||
var inbound = Inbound.FromRemote(Guid.NewGuid(), "12", VpnProtocol.Vless, "Old", 443);
|
||||
var before = inbound.LastSyncAt;
|
||||
|
||||
inbound.UpdateFromRemote(VpnProtocol.Trojan, "New", 8443);
|
||||
|
||||
Assert.Equal(VpnProtocol.Trojan, inbound.Protocol);
|
||||
Assert.Equal("New", inbound.Remark);
|
||||
Assert.Equal(8443, inbound.Port);
|
||||
Assert.NotNull(inbound.LastSyncAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Publish_SetsDisplayNameRolesAndMaxClients()
|
||||
{
|
||||
var inbound = Inbound.FromRemote(Guid.NewGuid(), "12", VpnProtocol.Vless, "Germany", 443);
|
||||
var roleId = Guid.NewGuid();
|
||||
|
||||
inbound.Publish("Germany (VLESS)", [roleId, roleId], 100);
|
||||
|
||||
Assert.True(inbound.IsPublished);
|
||||
Assert.Equal("Germany (VLESS)", inbound.DisplayName);
|
||||
Assert.Equal(100, inbound.MaxClients);
|
||||
Assert.Single(inbound.AllowedRoleIds);
|
||||
Assert.Contains(roleId, inbound.AllowedRoleIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unpublish_SetsIsPublishedFalseButKeepsRoles()
|
||||
{
|
||||
var inbound = Inbound.FromRemote(Guid.NewGuid(), "12", VpnProtocol.Vless, "Germany", 443);
|
||||
var roleId = Guid.NewGuid();
|
||||
inbound.Publish("Germany", [roleId], null);
|
||||
|
||||
inbound.Unpublish();
|
||||
|
||||
Assert.False(inbound.IsPublished);
|
||||
Assert.Contains(roleId, inbound.AllowedRoleIds);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using PnvPanel.Domain.Exceptions;
|
||||
using PnvPanel.Domain.Nodes;
|
||||
using Xunit;
|
||||
|
||||
namespace PnvPanel.Domain.Tests.Nodes;
|
||||
|
||||
public class NodeTests
|
||||
{
|
||||
private static NodeCredentials Credentials => new("admin", "protected-secret");
|
||||
|
||||
[Fact]
|
||||
public void Register_WithAbsoluteUri_CreatesEnabledUnknownStatusNode()
|
||||
{
|
||||
var node = Node.Register("Germany-1", new Uri("https://de1.example.com:2053"), Credentials, "Germany");
|
||||
|
||||
Assert.Equal("Germany-1", node.Name);
|
||||
Assert.Equal("Germany", node.Location);
|
||||
Assert.Equal(NodeStatus.Unknown, node.Status);
|
||||
Assert.True(node.IsEnabled);
|
||||
Assert.NotEqual(Guid.Empty, node.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Register_WithRelativeUri_Throws()
|
||||
{
|
||||
var relativeUri = new Uri("de1.example.com", UriKind.Relative);
|
||||
|
||||
Assert.Throws<DomainException>(() => Node.Register("Germany-1", relativeUri, Credentials, null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateDetails_ChangesNameAndLocation()
|
||||
{
|
||||
var node = Node.Register("Old", new Uri("https://example.com"), Credentials, "Old location");
|
||||
|
||||
node.UpdateDetails("New", "New location");
|
||||
|
||||
Assert.Equal("New", node.Name);
|
||||
Assert.Equal("New location", node.Location);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateCredentials_ReplacesCredentials()
|
||||
{
|
||||
var node = Node.Register("Node", new Uri("https://example.com"), Credentials, null);
|
||||
var newCredentials = new NodeCredentials("root", "new-protected-secret");
|
||||
|
||||
node.UpdateCredentials(newCredentials);
|
||||
|
||||
Assert.Equal(newCredentials, node.Credentials);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Disable_ThenEnable_TogglesIsEnabled()
|
||||
{
|
||||
var node = Node.Register("Node", new Uri("https://example.com"), Credentials, null);
|
||||
|
||||
node.Disable();
|
||||
Assert.False(node.IsEnabled);
|
||||
|
||||
node.Enable();
|
||||
Assert.True(node.IsEnabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateStatus_SetsStatus()
|
||||
{
|
||||
var node = Node.Register("Node", new Uri("https://example.com"), Credentials, null);
|
||||
|
||||
node.UpdateStatus(NodeStatus.Online);
|
||||
|
||||
Assert.Equal(NodeStatus.Online, node.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkSynced_SetsLastSyncAt()
|
||||
{
|
||||
var node = Node.Register("Node", new Uri("https://example.com"), Credentials, null);
|
||||
Assert.Null(node.LastSyncAt);
|
||||
|
||||
node.MarkSynced();
|
||||
|
||||
Assert.NotNull(node.LastSyncAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NodeCredentials_ToString_RedactsPassword()
|
||||
{
|
||||
var text = Credentials.ToString();
|
||||
|
||||
Assert.DoesNotContain("protected-secret", text);
|
||||
Assert.Contains("REDACTED", text);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<!-- Тестовый код: имена вида Method_Scenario_Result и т.п. не обязаны следовать
|
||||
анализаторам, рассчитанным на публичный production-код. -->
|
||||
<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>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\PnvPanel.Domain\PnvPanel.Domain.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,59 @@
|
||||
using PnvPanel.Domain.Exceptions;
|
||||
using PnvPanel.Domain.Telegram;
|
||||
using Xunit;
|
||||
|
||||
namespace PnvPanel.Domain.Tests.Telegram;
|
||||
|
||||
public class TelegramLinkTokenTests
|
||||
{
|
||||
[Fact]
|
||||
public void Create_IsValidBeforeConsumptionOrExpiry()
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
|
||||
var token = TelegramLinkToken.Create(userId, TimeSpan.FromMinutes(10));
|
||||
|
||||
Assert.Equal(userId, token.UserId);
|
||||
Assert.True(token.IsValid);
|
||||
Assert.Null(token.ConsumedAt);
|
||||
Assert.False(string.IsNullOrWhiteSpace(token.Token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_GeneratesUniqueTokens()
|
||||
{
|
||||
var a = TelegramLinkToken.Create(Guid.NewGuid(), TimeSpan.FromMinutes(10));
|
||||
var b = TelegramLinkToken.Create(Guid.NewGuid(), TimeSpan.FromMinutes(10));
|
||||
|
||||
Assert.NotEqual(a.Token, b.Token);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Consume_WhenValid_SetsConsumedAtAndInvalidates()
|
||||
{
|
||||
var token = TelegramLinkToken.Create(Guid.NewGuid(), TimeSpan.FromMinutes(10));
|
||||
|
||||
token.Consume();
|
||||
|
||||
Assert.NotNull(token.ConsumedAt);
|
||||
Assert.False(token.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Consume_WhenAlreadyConsumed_Throws()
|
||||
{
|
||||
var token = TelegramLinkToken.Create(Guid.NewGuid(), TimeSpan.FromMinutes(10));
|
||||
token.Consume();
|
||||
|
||||
Assert.Throws<DomainException>(() => token.Consume());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Consume_WhenExpired_Throws()
|
||||
{
|
||||
var token = TelegramLinkToken.Create(Guid.NewGuid(), TimeSpan.Zero);
|
||||
|
||||
Assert.False(token.IsValid);
|
||||
Assert.Throws<DomainException>(() => token.Consume());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using PnvPanel.Domain.Exceptions;
|
||||
using PnvPanel.Domain.Telegram;
|
||||
using Xunit;
|
||||
|
||||
namespace PnvPanel.Domain.Tests.Telegram;
|
||||
|
||||
public class TelegramLoginRequestTests
|
||||
{
|
||||
[Fact]
|
||||
public void Create_SetsPendingStatusAndExpiry()
|
||||
{
|
||||
var request = TelegramLoginRequest.Create(TimeSpan.FromMinutes(5), "1.2.3.4");
|
||||
|
||||
Assert.Equal(TelegramLoginStatus.Pending, request.Status);
|
||||
Assert.Equal("1.2.3.4", request.Context);
|
||||
Assert.False(request.IsExpired);
|
||||
Assert.True(request.ExpiresAt > request.CreatedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Approve_WhenPending_SetsApprovedAndUserId()
|
||||
{
|
||||
var request = TelegramLoginRequest.Create(TimeSpan.FromMinutes(5), null);
|
||||
var userId = Guid.NewGuid();
|
||||
|
||||
request.Approve(userId);
|
||||
|
||||
Assert.Equal(TelegramLoginStatus.Approved, request.Status);
|
||||
Assert.Equal(userId, request.UserId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reject_WhenPending_SetsRejected()
|
||||
{
|
||||
var request = TelegramLoginRequest.Create(TimeSpan.FromMinutes(5), null);
|
||||
|
||||
request.Reject();
|
||||
|
||||
Assert.Equal(TelegramLoginStatus.Rejected, request.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Approve_WhenExpired_ThrowsAndMarksExpired()
|
||||
{
|
||||
var request = TelegramLoginRequest.Create(TimeSpan.Zero, null);
|
||||
|
||||
Assert.Throws<DomainException>(() => request.Approve(Guid.NewGuid()));
|
||||
Assert.Equal(TelegramLoginStatus.Expired, request.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Approve_WhenAlreadyApproved_Throws()
|
||||
{
|
||||
var request = TelegramLoginRequest.Create(TimeSpan.FromMinutes(5), null);
|
||||
request.Approve(Guid.NewGuid());
|
||||
|
||||
Assert.Throws<DomainException>(() => request.Approve(Guid.NewGuid()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Consume_WhenApproved_SetsConsumed()
|
||||
{
|
||||
var request = TelegramLoginRequest.Create(TimeSpan.FromMinutes(5), null);
|
||||
request.Approve(Guid.NewGuid());
|
||||
|
||||
request.Consume();
|
||||
|
||||
Assert.Equal(TelegramLoginStatus.Consumed, request.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Consume_WhenNotApproved_Throws()
|
||||
{
|
||||
var request = TelegramLoginRequest.Create(TimeSpan.FromMinutes(5), null);
|
||||
|
||||
Assert.Throws<DomainException>(() => request.Consume());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Consume_WhenAlreadyConsumed_Throws()
|
||||
{
|
||||
var request = TelegramLoginRequest.Create(TimeSpan.FromMinutes(5), null);
|
||||
request.Approve(Guid.NewGuid());
|
||||
request.Consume();
|
||||
|
||||
Assert.Throws<DomainException>(() => request.Consume());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using PnvPanel.IntegrationTests.TestSupport;
|
||||
using Xunit;
|
||||
|
||||
namespace PnvPanel.IntegrationTests.Auth;
|
||||
|
||||
[Collection(IntegrationTestCollection.Name)]
|
||||
public class AuthFlowTests(PnvPanelWebApplicationFactory factory)
|
||||
{
|
||||
private sealed record RegisterResponse(Guid Id, string UserName);
|
||||
|
||||
private sealed record CurrentUserResponse(Guid Id, string UserName, string Role, bool IsActivated, bool TelegramLinked);
|
||||
|
||||
private sealed record LoginResponse(string AccessToken, DateTimeOffset ExpiresAt, CurrentUserResponse User);
|
||||
|
||||
[Fact]
|
||||
public async Task RegisterLoginMeRefreshLogout_FullFlow_Succeeds()
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
var userName = $"alice_{Guid.NewGuid():N}"[..20];
|
||||
const string password = "P@ssw0rd123";
|
||||
|
||||
var registerResponse = await client.PostJsonAsync("/api/auth/register", new { userName, password });
|
||||
Assert.Equal(HttpStatusCode.OK, registerResponse.StatusCode);
|
||||
var registered = await registerResponse.ReadAsAsync<RegisterResponse>();
|
||||
Assert.NotNull(registered);
|
||||
Assert.Equal(userName, registered!.UserName);
|
||||
|
||||
var loginResponse = await client.PostJsonAsync("/api/auth/login", new { userName, password });
|
||||
Assert.Equal(HttpStatusCode.OK, loginResponse.StatusCode);
|
||||
var login = await loginResponse.ReadAsAsync<LoginResponse>();
|
||||
Assert.NotNull(login);
|
||||
Assert.False(login!.User.IsActivated);
|
||||
Assert.False(login.User.TelegramLinked);
|
||||
Assert.Equal("user", login.User.Role);
|
||||
|
||||
client.UseBearerToken(login.AccessToken);
|
||||
var meResponse = await client.GetAsync("/api/auth/me");
|
||||
Assert.Equal(HttpStatusCode.OK, meResponse.StatusCode);
|
||||
var me = await meResponse.ReadAsAsync<CurrentUserResponse>();
|
||||
Assert.Equal(userName, me!.UserName);
|
||||
|
||||
var refreshResponse = await client.PostAsync("/api/auth/refresh", content: null);
|
||||
Assert.Equal(HttpStatusCode.OK, refreshResponse.StatusCode);
|
||||
var refreshed = await refreshResponse.ReadAsAsync<LoginResponse>();
|
||||
Assert.NotNull(refreshed);
|
||||
Assert.NotEqual(login.AccessToken, refreshed!.AccessToken);
|
||||
|
||||
var logoutResponse = await client.PostAsync("/api/auth/logout", content: null);
|
||||
Assert.Equal(HttpStatusCode.NoContent, logoutResponse.StatusCode);
|
||||
|
||||
// Использованный refresh-токен отозван при logout — повторный refresh должен провалиться.
|
||||
var refreshAfterLogout = await client.PostAsync("/api/auth/refresh", content: null);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, refreshAfterLogout.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Login_WithWrongPassword_ReturnsUnauthorized()
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
var userName = $"bob_{Guid.NewGuid():N}"[..20];
|
||||
|
||||
await client.PostJsonAsync("/api/auth/register", new { userName, password = "CorrectPassword123" });
|
||||
|
||||
var loginResponse = await client.PostJsonAsync("/api/auth/login", new { userName, password = "WrongPassword123" });
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, loginResponse.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Register_WithDuplicateUserName_ReturnsConflict()
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
var userName = $"carol_{Guid.NewGuid():N}"[..20];
|
||||
|
||||
var first = await client.PostJsonAsync("/api/auth/register", new { userName, password = "P@ssw0rd123" });
|
||||
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
|
||||
|
||||
var second = await client.PostJsonAsync("/api/auth/register", new { userName, password = "AnotherPass123" });
|
||||
|
||||
Assert.Equal(HttpStatusCode.Conflict, second.StatusCode);
|
||||
}
|
||||
}
|
||||
@@ -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.AspNetCore.Mvc.Testing" />
|
||||
<PackageReference Include="Testcontainers.PostgreSql" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\PnvPanel.Api\PnvPanel.Api.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.Net.Http.Json;
|
||||
|
||||
namespace PnvPanel.IntegrationTests.TestSupport;
|
||||
|
||||
public static class AuthTestHelper
|
||||
{
|
||||
public sealed record CurrentUserResponse(Guid Id, string UserName, string Role, bool IsActivated, bool TelegramLinked);
|
||||
|
||||
public sealed record LoginResponse(string AccessToken, DateTimeOffset ExpiresAt, CurrentUserResponse User);
|
||||
|
||||
public static async Task<(Guid Id, string AccessToken)> RegisterAndLoginAsync(HttpClient client, string userName, string password)
|
||||
{
|
||||
var registerResponse = await client.PostJsonAsync("/api/auth/register", new { userName, password });
|
||||
registerResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var (id, accessToken) = await LoginAsync(client, userName, password);
|
||||
return (id, accessToken);
|
||||
}
|
||||
|
||||
public static async Task<(Guid Id, string AccessToken)> LoginAsync(HttpClient client, string userName, string password)
|
||||
{
|
||||
var loginResponse = await client.PostJsonAsync("/api/auth/login", new { userName, password });
|
||||
loginResponse.EnsureSuccessStatusCode();
|
||||
var login = await loginResponse.ReadAsAsync<LoginResponse>();
|
||||
return (login!.User.Id, login.AccessToken);
|
||||
}
|
||||
|
||||
public static async Task<string> LoginAsAdminAsync(HttpClient client)
|
||||
{
|
||||
var (_, accessToken) = await LoginAsync(client, PnvPanelWebApplicationFactory.AdminUserName, PnvPanelWebApplicationFactory.AdminPassword);
|
||||
return accessToken;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
using PnvPanel.Domain.Inbounds;
|
||||
using PnvPanel.Domain.Nodes;
|
||||
|
||||
namespace PnvPanel.IntegrationTests.TestSupport;
|
||||
|
||||
/// <summary>
|
||||
/// Заглушка 3x-ui для интеграционных тестов — реальной панели нет. Возвращает успех для проб/CRUD
|
||||
/// клиентов, отдаёт один синтетический inbound на ноду для сценариев с SyncNode.
|
||||
/// </summary>
|
||||
public sealed class FakeXuiPanelGateway : IXuiPanelGateway
|
||||
{
|
||||
public Result ValidateBaseAddress(Uri baseAddress) => Result.Success();
|
||||
|
||||
public Task<NodeProbeResult> ProbeAsync(Node node, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(new NodeProbeResult(true, null));
|
||||
|
||||
public Task<Result<IReadOnlyList<RemoteInboundInfo>>> ListInboundsAsync(Node node, CancellationToken cancellationToken)
|
||||
{
|
||||
IReadOnlyList<RemoteInboundInfo> inbounds =
|
||||
[
|
||||
new RemoteInboundInfo("1", VpnProtocol.Vless, "Test inbound", 443),
|
||||
];
|
||||
|
||||
return Task.FromResult(Result.Success(inbounds));
|
||||
}
|
||||
|
||||
public void InvalidateClient(Guid nodeId)
|
||||
{
|
||||
}
|
||||
|
||||
public Task<Result<string>> AddClientAsync(
|
||||
Node node, string inboundRemoteId, VpnProtocol protocol, string clientEmail, string clientName,
|
||||
int deviceLimit, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(Result.Success(Guid.NewGuid().ToString()));
|
||||
|
||||
public Task<Result> RemoveClientAsync(
|
||||
Node node, string inboundRemoteId, string clientExternalId, VpnProtocol protocol, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(Result.Success());
|
||||
|
||||
public Task<Result> UpdateClientAsync(
|
||||
Node node, string inboundRemoteId, string clientExternalId, VpnProtocol protocol,
|
||||
string name, int deviceLimit, bool enable, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(Result.Success());
|
||||
|
||||
public Task<Result<string>> BuildConnectionStringAsync(
|
||||
Node node, Inbound inbound, string clientExternalId, string clientName, string publicHost, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(Result.Success("vless://fake-connection-string"));
|
||||
|
||||
public Task<Result<IReadOnlyDictionary<string, ClientTrafficInfo>>> GetClientTrafficAsync(
|
||||
Node node, string inboundRemoteId, CancellationToken cancellationToken)
|
||||
{
|
||||
IReadOnlyDictionary<string, ClientTrafficInfo> traffic = new Dictionary<string, ClientTrafficInfo>();
|
||||
return Task.FromResult(Result.Success(traffic));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace PnvPanel.IntegrationTests.TestSupport;
|
||||
|
||||
public static class HttpClientJsonExtensions
|
||||
{
|
||||
public static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public static void UseBearerToken(this HttpClient client, string accessToken)
|
||||
=> client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||
|
||||
public static async Task<T?> ReadAsAsync<T>(this HttpResponseMessage response)
|
||||
=> await response.Content.ReadFromJsonAsync<T>(JsonOptions);
|
||||
|
||||
public static Task<HttpResponseMessage> PostJsonAsync(this HttpClient client, string url, object body)
|
||||
=> client.PostAsJsonAsync(url, body, JsonOptions);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using Xunit;
|
||||
|
||||
namespace PnvPanel.IntegrationTests.TestSupport;
|
||||
|
||||
[CollectionDefinition(Name)]
|
||||
public sealed class IntegrationTestCollection : ICollectionFixture<PnvPanelWebApplicationFactory>
|
||||
{
|
||||
public const string Name = "Integration";
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using Testcontainers.PostgreSql;
|
||||
using Xunit;
|
||||
|
||||
namespace PnvPanel.IntegrationTests.TestSupport;
|
||||
|
||||
/// <summary>
|
||||
/// Реальный Postgres через Testcontainers (не InMemory/Sqlite — нужно проверить Postgres-специфичное
|
||||
/// поведение: pg_advisory_xact_lock для квоты конфигов, uuid[]/jsonb колонки). Program.cs сам
|
||||
/// применяет миграции и сидит роли/админа при старте хоста — свежий контейнер становится полностью
|
||||
/// готовой БД без ручных шагов.
|
||||
/// </summary>
|
||||
public sealed class PnvPanelWebApplicationFactory : WebApplicationFactory<Program>, IAsyncLifetime
|
||||
{
|
||||
public const string AdminUserName = "test-admin";
|
||||
public const string AdminPassword = "TestAdmin123!";
|
||||
|
||||
private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder()
|
||||
.WithImage("postgres:17-alpine")
|
||||
.WithDatabase("pnvpanel")
|
||||
.WithUsername("pnvpanel")
|
||||
.WithPassword("pnvpanel")
|
||||
.Build();
|
||||
|
||||
public async Task InitializeAsync() => await _postgres.StartAsync();
|
||||
|
||||
async Task IAsyncLifetime.DisposeAsync()
|
||||
{
|
||||
await _postgres.StopAsync();
|
||||
await base.DisposeAsync();
|
||||
}
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseEnvironment("Development");
|
||||
|
||||
builder.ConfigureAppConfiguration((_, config) =>
|
||||
{
|
||||
config.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["ConnectionStrings:Default"] = _postgres.GetConnectionString(),
|
||||
["AdminSeed:Username"] = AdminUserName,
|
||||
["AdminSeed:Password"] = AdminPassword,
|
||||
// Пусто — TelegramBotHostedService при пустом токене не стартует (см. Api/Telegram/TelegramBotHostedService.cs).
|
||||
["Telegram:BotToken"] = "",
|
||||
});
|
||||
});
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
// Реальной панели 3x-ui в тестах нет — подменяем гейтвей заглушкой.
|
||||
services.RemoveAll<IXuiPanelGateway>();
|
||||
services.AddSingleton<IXuiPanelGateway, FakeXuiPanelGateway>();
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user