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,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());
}
}