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:
Leonid Pershin
2026-07-02 01:01:03 +03:00
parent 1a8d33efa3
commit 7b6fe9ad78
142 changed files with 7570 additions and 22 deletions
@@ -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>());
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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>());
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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>
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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>();
});
}
}