Implement rate limiting and enhance authentication flow
CI / Backend (build + test) (push) Successful in 1m17s
CI / Frontend (lint + typecheck + build) (push) Successful in 35s

- Added rate limiting configuration for authentication endpoints, allowing customizable request limits via environment variables.
- Updated authentication flow to utilize HttpRequest for cookie management, ensuring secure handling of refresh tokens.
- Introduced a new endpoint to retrieve user subscription details.
- Enhanced the handling of Telegram bot token validation to prevent errors with empty tokens.
- Updated the application to serialize enums as strings for better documentation and compatibility with TypeScript.
- Improved test coverage for new features and adjustments in command handlers.
This commit is contained in:
Leonid Pershin
2026-07-02 12:40:23 +03:00
parent ed07221ca5
commit 8067be3c35
106 changed files with 8823 additions and 172 deletions
@@ -1,4 +1,6 @@
using NSubstitute;
using PnvPanel.Application.Admin.Inbounds;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Tests.TestSupport;
using PnvPanel.Domain.Inbounds;
using Xunit;
@@ -7,6 +9,8 @@ namespace PnvPanel.Application.Tests.Admin.Inbounds;
public class PublishInboundCommandHandlerTests
{
private readonly ICurrentUser _currentUser = Substitute.For<ICurrentUser>();
[Fact]
public async Task Handle_WhenPublishingExistingInbound_UpdatesPublishState()
{
@@ -16,7 +20,7 @@ public class PublishInboundCommandHandlerTests
await dbContext.SaveChangesAsync(CancellationToken.None);
var roleId = Guid.NewGuid();
var handler = new PublishInboundCommandHandler(dbContext);
var handler = new PublishInboundCommandHandler(dbContext, _currentUser);
var command = new PublishInboundCommand(inbound.Id, true, "EU Fast", [roleId], 100);
@@ -39,7 +43,7 @@ public class PublishInboundCommandHandlerTests
dbContext.Inbounds.Add(inbound);
await dbContext.SaveChangesAsync(CancellationToken.None);
var handler = new PublishInboundCommandHandler(dbContext);
var handler = new PublishInboundCommandHandler(dbContext, _currentUser);
var command = new PublishInboundCommand(inbound.Id, false, null, [], null);
@@ -54,7 +58,7 @@ public class PublishInboundCommandHandlerTests
{
using var dbContext = InMemoryDbContextFactory.Create();
var handler = new PublishInboundCommandHandler(dbContext);
var handler = new PublishInboundCommandHandler(dbContext, _currentUser);
var command = new PublishInboundCommand(Guid.NewGuid(), true, "EU Fast", [], null);
@@ -11,6 +11,7 @@ public class RegisterNodeCommandHandlerTests
{
private readonly IXuiPanelGateway _gateway = Substitute.For<IXuiPanelGateway>();
private readonly ISecretProtector _secretProtector = Substitute.For<ISecretProtector>();
private readonly ICurrentUser _currentUser = Substitute.For<ICurrentUser>();
[Fact]
public async Task Handle_WithValidAddress_RegistersNodeWithProtectedPassword()
@@ -20,7 +21,7 @@ public class RegisterNodeCommandHandlerTests
_gateway.ValidateBaseAddress(Arg.Any<Uri>()).Returns(Result.Success());
_secretProtector.Protect("secret-password").Returns("protected-secret-password");
var handler = new RegisterNodeCommandHandler(dbContext, _gateway, _secretProtector);
var handler = new RegisterNodeCommandHandler(dbContext, _gateway, _secretProtector, _currentUser);
var command = new RegisterNodeCommand("node-1", "https://node1.example.com", "admin", "secret-password", "eu-west");
@@ -38,7 +39,7 @@ public class RegisterNodeCommandHandlerTests
{
using var dbContext = InMemoryDbContextFactory.Create();
var handler = new RegisterNodeCommandHandler(dbContext, _gateway, _secretProtector);
var handler = new RegisterNodeCommandHandler(dbContext, _gateway, _secretProtector, _currentUser);
var command = new RegisterNodeCommand("node-1", "not-a-uri", "admin", "secret-password", null);
@@ -58,7 +59,7 @@ public class RegisterNodeCommandHandlerTests
var error = Error.Validation("Nodes.SchemeNotAllowed", "Разрешён только HTTPS.");
_gateway.ValidateBaseAddress(Arg.Any<Uri>()).Returns(Result.Failure(error));
var handler = new RegisterNodeCommandHandler(dbContext, _gateway, _secretProtector);
var handler = new RegisterNodeCommandHandler(dbContext, _gateway, _secretProtector, _currentUser);
var command = new RegisterNodeCommand("node-1", "http://node1.example.com", "admin", "secret-password", null);
@@ -2,6 +2,7 @@ using NSubstitute;
using PnvPanel.Application.Admin.Users;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Models;
using PnvPanel.Application.Tests.TestSupport;
using Xunit;
namespace PnvPanel.Application.Tests.Admin.Users;
@@ -9,35 +10,42 @@ namespace PnvPanel.Application.Tests.Admin.Users;
public class ChangeUserRoleCommandHandlerTests
{
private readonly IRoleService _roleService = Substitute.For<IRoleService>();
private readonly ICurrentUser _currentUser = Substitute.For<ICurrentUser>();
[Fact]
public async Task Handle_DelegatesToRoleServiceAndReturnsSuccess()
{
using var dbContext = InMemoryDbContextFactory.Create();
var userId = Guid.NewGuid();
var roleId = Guid.NewGuid();
_roleService.ChangeUserRoleAsync(userId, roleId, Arg.Any<CancellationToken>()).Returns(Result.Success());
var handler = new ChangeUserRoleCommandHandler(_roleService);
var handler = new ChangeUserRoleCommandHandler(_roleService, dbContext, _currentUser);
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>());
Assert.Single(dbContext.AuditLogs.Local);
}
[Fact]
public async Task Handle_WhenRoleServiceFails_PropagatesFailure()
{
using var dbContext = InMemoryDbContextFactory.Create();
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 handler = new ChangeUserRoleCommandHandler(_roleService, dbContext, _currentUser);
var result = await handler.Handle(new ChangeUserRoleCommand(userId, roleId), CancellationToken.None);
Assert.False(result.IsSuccess);
Assert.Equal(error, result.Error);
Assert.Empty(dbContext.AuditLogs.Local);
}
}
@@ -40,7 +40,7 @@ public class GetCurrentUserQueryHandlerTests
public async Task Handle_AuthenticatedWithProfile_ReturnsCurrentUserDto()
{
var userId = Guid.NewGuid();
var profile = new CurrentUserProfile(userId, "alice", Guid.NewGuid(), "user", true, false, 3);
var profile = new CurrentUserProfile(userId, "alice", Guid.NewGuid(), "user", true, false, 3, "sub-token");
_identityService.GetProfileAsync(userId, Arg.Any<CancellationToken>()).Returns(profile);
_identityService.GetTelegramLinkInfoAsync(userId, Arg.Any<CancellationToken>())
.Returns(new TelegramLinkInfo(true, 42, "alice_tg"));
@@ -20,7 +20,7 @@ public class LoginCommandHandlerTests
{
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);
var profile = new CurrentUserProfile(userId, "alice", Guid.NewGuid(), "user", IsActivated: true, IsBlocked: false, MaxConfigs: 3, SubscriptionToken: "sub-token");
_identityService.ValidateCredentialsAsync("alice", "P@ssw0rd", Arg.Any<CancellationToken>())
.Returns(Result.Success(authUser));
@@ -19,7 +19,7 @@ public class RefreshCommandHandlerTests
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 profile = new CurrentUserProfile(userId, "alice", Guid.NewGuid(), "user", IsActivated: true, IsBlocked: false, MaxConfigs: 3, SubscriptionToken: "sub-token");
var rotated = new RotatedRefreshToken(userId, "new-refresh-token", DateTimeOffset.UtcNow.AddDays(30));
_refreshTokenService.RotateAsync("old-token", Arg.Any<CancellationToken>()).Returns(Result.Success(rotated));
@@ -32,7 +32,7 @@ public class GetMyConfigsQueryHandlerTests
dbContext.VpnConfigs.AddRange(activeConfig, revokedConfig, otherUsersConfig);
await dbContext.SaveChangesAsync(CancellationToken.None);
var profile = new CurrentUserProfile(userId, "alice", Guid.NewGuid(), "user", true, false, 5);
var profile = new CurrentUserProfile(userId, "alice", Guid.NewGuid(), "user", true, false, 5, "sub-token");
_identityService.GetProfileAsync(userId, Arg.Any<CancellationToken>()).Returns(profile);
var handler = new GetMyConfigsQueryHandler(dbContext, _identityService, FakeCurrentUser.Authenticated(userId));
@@ -75,7 +75,7 @@ public class GetLoginRequestStatusQueryHandlerTests
dbContext.TelegramLoginRequests.Add(request);
await dbContext.SaveChangesAsync(CancellationToken.None);
var profile = new CurrentUserProfile(userId, "alice", Guid.NewGuid(), "user", true, false, 3);
var profile = new CurrentUserProfile(userId, "alice", Guid.NewGuid(), "user", true, false, 3, "sub-token");
_identityService.GetProfileAsync(userId, Arg.Any<CancellationToken>()).Returns(profile);
_jwtTokenService.GenerateAccessToken(Arg.Any<AuthenticatedUser>())
.Returns(("access-token", DateTimeOffset.UtcNow.AddMinutes(15)));
@@ -8,13 +8,13 @@ namespace PnvPanel.IntegrationTests.Admin;
[Collection(IntegrationTestCollection.Name)]
public class NodeInboundCrudTests(PnvPanelWebApplicationFactory factory)
{
private sealed record NodeResponse(Guid Id, string Name, string BaseAddress, string Username, string? Location, int Status, bool IsEnabled);
private sealed record NodeResponse(Guid Id, string Name, string BaseAddress, string Username, string? Location, string Status, bool IsEnabled);
private sealed record InboundResponse(
Guid Id, Guid NodeId, string RemoteInboundId, int Protocol, string Remark, int Port,
Guid Id, Guid NodeId, string RemoteInboundId, string Protocol, string Remark, int Port,
bool IsPublished, string? DisplayName, int? MaxClients, IReadOnlyList<Guid> AllowedRoleIds);
private sealed record SyncNodeResponse(int InboundsSynced, int Status);
private sealed record SyncNodeResponse(int InboundsSynced, string Status);
[Fact]
public async Task RegisterSyncListPublish_FullNodeInboundLifecycle_Succeeds()
@@ -77,7 +77,7 @@ public class AuthFlowTests(PnvPanelWebApplicationFactory factory)
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" });
var second = await client.PostJsonAsync("/api/auth/register", new { userName, password = "AnotherPass123!" });
Assert.Equal(HttpStatusCode.Conflict, second.StatusCode);
}
@@ -15,12 +15,14 @@ public class ConfigQuotaTests(PnvPanelWebApplicationFactory factory)
private sealed record NodeResponse(Guid Id, string Name);
private sealed record SyncNodeResponse(int InboundsSynced, int Status);
private sealed record SyncNodeResponse(int InboundsSynced, string Status);
private sealed record InboundResponse(Guid Id, Guid NodeId, string RemoteInboundId, int Protocol, string Remark, int Port, bool IsPublished);
private sealed record InboundResponse(Guid Id, Guid NodeId, string RemoteInboundId, string Protocol, string Remark, int Port, bool IsPublished);
private sealed record ActivationRequestResponse(Guid Id, string? Comment, DateTimeOffset CreatedAt);
private sealed record MyConfigsResponse(List<object> Configs, int MaxConfigs);
/// <summary>
/// Доказывает, что pg_advisory_xact_lock в CreateVpnConfigCommandHandler реально защищает
/// от гонки: при параллельных запросах ровно Quota проходят, остальные — 409 QuotaExceeded.
@@ -98,7 +100,7 @@ public class ConfigQuotaTests(PnvPanelWebApplicationFactory factory)
Assert.Equal(ConcurrentAttempts - Quota, quotaExceeded);
var myConfigsResponse = await userClient.GetAsync("/api/configs");
var myConfigs = await myConfigsResponse.ReadAsAsync<List<object>>();
Assert.Equal(Quota, myConfigs!.Count);
var myConfigs = await myConfigsResponse.ReadAsAsync<MyConfigsResponse>();
Assert.Equal(Quota, myConfigs!.Configs.Count);
}
}
@@ -27,7 +27,10 @@ public sealed class PnvPanelWebApplicationFactory : WebApplicationFactory<Progra
.WithPassword("pnvpanel")
.Build();
public async Task InitializeAsync() => await _postgres.StartAsync();
public async Task InitializeAsync()
{
await _postgres.StartAsync();
}
async Task IAsyncLifetime.DisposeAsync()
{
@@ -48,6 +51,9 @@ public sealed class PnvPanelWebApplicationFactory : WebApplicationFactory<Progra
["AdminSeed:Password"] = AdminPassword,
// Пусто — TelegramBotHostedService при пустом токене не стартует (см. Api/Telegram/TelegramBotHostedService.cs).
["Telegram:BotToken"] = "",
// Весь collection делит один TestServer/host — все запросы идут от одного "клиента",
// дефолтный лимит 20/мин быстро исчерпывается. Поднимаем для тестового окружения.
["RateLimiting:AuthPermitLimit"] = "10000",
});
});