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
@@ -38,13 +38,13 @@ public static class AuthEndpoints
return result.ToHttpResult();
}
private static async Task<IResult> Login(LoginCommand command, ISender sender, HttpResponse response, CancellationToken cancellationToken)
private static async Task<IResult> Login(LoginCommand command, ISender sender, HttpRequest request, HttpResponse response, CancellationToken cancellationToken)
{
var result = await sender.Send(command, cancellationToken);
if (!result.IsSuccess)
return result.ToHttpResult();
SetRefreshCookie(response, result.Value.RefreshToken, result.Value.RefreshTokenExpiresAt);
SetRefreshCookie(request, response, result.Value.RefreshToken, result.Value.RefreshTokenExpiresAt);
return Results.Ok(ToLoginResponse(result.Value));
}
@@ -56,11 +56,11 @@ public static class AuthEndpoints
var result = await sender.Send(new RefreshCommand(rawToken), cancellationToken);
if (!result.IsSuccess)
{
response.Cookies.Delete(RefreshCookieName, BuildCookieOptions());
response.Cookies.Delete(RefreshCookieName, BuildCookieOptions(request));
return result.ToHttpResult();
}
SetRefreshCookie(response, result.Value.RefreshToken, result.Value.RefreshTokenExpiresAt);
SetRefreshCookie(request, response, result.Value.RefreshToken, result.Value.RefreshTokenExpiresAt);
return Results.Ok(ToLoginResponse(result.Value));
}
@@ -69,7 +69,7 @@ public static class AuthEndpoints
if (request.Cookies.TryGetValue(RefreshCookieName, out var rawToken) && !string.IsNullOrEmpty(rawToken))
await sender.Send(new LogoutCommand(rawToken), cancellationToken);
response.Cookies.Delete(RefreshCookieName, BuildCookieOptions());
response.Cookies.Delete(RefreshCookieName, BuildCookieOptions(request));
return Results.NoContent();
}
@@ -85,10 +85,10 @@ public static class AuthEndpoints
return result.ToHttpResult();
}
private static async Task<IResult> DeleteMe(HttpResponse response, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> DeleteMe(HttpRequest request, HttpResponse response, ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new DeleteMyAccountCommand(), cancellationToken);
response.Cookies.Delete(RefreshCookieName, BuildCookieOptions());
response.Cookies.Delete(RefreshCookieName, BuildCookieOptions(request));
return result.ToHttpResult();
}
@@ -99,17 +99,19 @@ public static class AuthEndpoints
user = auth.User,
};
private static void SetRefreshCookie(HttpResponse response, string rawToken, DateTimeOffset expiresAt)
private static void SetRefreshCookie(HttpRequest request, HttpResponse response, string rawToken, DateTimeOffset expiresAt)
{
var options = BuildCookieOptions();
var options = BuildCookieOptions(request);
options.Expires = expiresAt;
response.Cookies.Append(RefreshCookieName, rawToken, options);
}
private static CookieOptions BuildCookieOptions() => new()
// Secure = IsHttps запроса (учитывает ForwardedHeaders за внешним TLS-прокси, см. CLAUDE.md) —
// иначе браузер/HttpClient не пришлёт cookie обратно на plain-http (локальный dev, TestServer).
private static CookieOptions BuildCookieOptions(HttpRequest request) => new()
{
HttpOnly = true,
Secure = true,
Secure = request.IsHttps,
SameSite = SameSiteMode.Strict,
Path = "/api/auth",
};
@@ -4,6 +4,7 @@ using PnvPanel.Application.Configs.Create;
using PnvPanel.Application.Configs.Edit;
using PnvPanel.Application.Configs.GetConfigLink;
using PnvPanel.Application.Configs.GetMyConfigs;
using PnvPanel.Application.Configs.GetMySubscription;
using PnvPanel.Application.Configs.ListAvailableInbounds;
using PnvPanel.Application.Configs.Revoke;
using PnvPanel.Application.Configs.Rotate;
@@ -23,6 +24,7 @@ public static class ConfigEndpoints
group.MapPost("/configs/{id:guid}/rotate", RotateConfig);
group.MapDelete("/configs/{id:guid}", RevokeConfig);
group.MapGet("/configs/{id:guid}/link", GetConfigLink);
group.MapGet("/subscription", GetMySubscription);
return app;
}
@@ -74,6 +76,16 @@ public static class ConfigEndpoints
var subscriptionUrl = $"{request.Scheme}://{request.Host}/sub/{result.Value.SubscriptionToken}";
return Results.Ok(new { connectionString = result.Value.ConnectionString, subscriptionUrl });
}
private static async Task<IResult> GetMySubscription(HttpRequest request, ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new GetMySubscriptionQuery(), cancellationToken);
if (!result.IsSuccess)
return result.ToHttpResult();
var subscriptionUrl = $"{request.Scheme}://{request.Host}/sub/{result.Value.SubscriptionToken}";
return Results.Ok(new { subscriptionUrl });
}
}
public sealed record CreateConfigBody(Guid InboundId, string? Label, int? DeviceLimit);
+17 -3
View File
@@ -1,3 +1,4 @@
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Options;
@@ -40,11 +41,17 @@ builder.Services.AddSignalR();
builder.Services.AddSingleton<IRealtimeNotifier, SignalRRealtimeNotifier>();
// Telegram-бот: presentation-адаптер, хостится в процессе Api (long polling). Клиент регистрируем
// всегда (даже с пустым токеном) — TelegramBotHostedService сам решает не стартовать без токена.
// всегда (даже с пустым токеном) — TelegramBotHostedService сам решает не стартовать без токена,
// а TelegramNotifier — не слать сообщения. TelegramBotClient(...) при этом валидирует формат токена
// и падает на пустой строке, поэтому при пустом BotToken подставляем синтаксически валидную заглушку —
// реальный HTTP-вызов через неё никогда не происходит (все вызывающие места сами проверяют BotToken).
builder.Services.AddSingleton<ITelegramBotClient>(sp =>
{
var options = sp.GetRequiredService<IOptions<TelegramOptions>>();
return new TelegramBotClient(options.Value.BotToken ?? string.Empty);
var token = options.Value.BotToken;
return new TelegramBotClient(string.IsNullOrWhiteSpace(token)
? "0:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
: token);
});
// Scoped — зависит от IIdentityService (scoped), не Singleton.
builder.Services.AddScoped<ITelegramNotifier, TelegramNotifier>();
@@ -55,13 +62,20 @@ builder.Services.AddRateLimiter(options =>
{
options.AddFixedWindowLimiter(RateLimiting.AuthPolicy, limiterOptions =>
{
limiterOptions.PermitLimit = 20;
// Настраиваемо через конфиг, чтобы интеграционные тесты (общий TestServer/host на весь
// collection, все запросы — от одного "клиента") могли поднять лимит и не ловить 429.
limiterOptions.PermitLimit = builder.Configuration.GetValue("RateLimiting:AuthPermitLimit", 20);
limiterOptions.Window = TimeSpan.FromMinutes(1);
limiterOptions.QueueLimit = 0;
});
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
});
// Энумы сериализуются строками ("Vless", "Active", ...), не числами — самодокументируемый JSON,
// корректные строковые литералы при генерации TS-типов из OpenAPI-схемы (см. docs/frontend.md).
builder.Services.ConfigureHttpJsonOptions(options =>
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()));
builder.Services.AddProblemDetails();
builder.Services.AddOpenApi();
builder.Services.AddHealthChecks()
@@ -5,6 +5,7 @@ using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Activation;
using PnvPanel.Domain.Audit;
namespace PnvPanel.Application.Admin.Activation;
@@ -33,6 +34,9 @@ public sealed class ApproveActivationCommandHandler(
if (!activateResult.IsSuccess)
return activateResult;
dbContext.AuditLogs.Add(AuditLog.Create(
adminId, "ActivationApproved", "User", request.UserId.ToString(), metadata: null, AuditSource.Web));
await notifier.NotifyUserActivatedAsync(request.UserId, cancellationToken);
await telegramNotifier.NotifyUserAsync(request.UserId, "✅ Ваш аккаунт активирован администратором.", cancellationToken);
return Result.Success();
@@ -5,6 +5,7 @@ using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Activation;
using PnvPanel.Domain.Audit;
namespace PnvPanel.Application.Admin.Activation;
@@ -26,6 +27,10 @@ public sealed class RejectActivationCommandHandler(IAppDbContext dbContext, ICur
return Result.Failure(ActivationErrors.AlreadyDecided);
request.Reject(adminId, command.Reason);
dbContext.AuditLogs.Add(AuditLog.Create(
adminId, "ActivationRejected", "User", request.UserId.ToString(), metadata: null, AuditSource.Web));
return Result.Success();
}
}
@@ -2,10 +2,11 @@ using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Audit;
namespace PnvPanel.Application.Admin.Inbounds;
public sealed class PublishInboundCommandHandler(IAppDbContext dbContext)
public sealed class PublishInboundCommandHandler(IAppDbContext dbContext, ICurrentUser currentUser)
: ICommandHandler<PublishInboundCommand, Result<InboundDto>>
{
public async Task<Result<InboundDto>> Handle(PublishInboundCommand command, CancellationToken cancellationToken)
@@ -19,6 +20,10 @@ public sealed class PublishInboundCommandHandler(IAppDbContext dbContext)
else
inbound.Unpublish();
dbContext.AuditLogs.Add(AuditLog.Create(
currentUser.UserId, command.IsPublished ? "InboundPublished" : "InboundUnpublished",
"Inbound", inbound.Id.ToString(), metadata: null, AuditSource.Web));
return Result.Success(InboundDto.FromDomain(inbound));
}
}
@@ -2,10 +2,11 @@ using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Audit;
namespace PnvPanel.Application.Admin.Nodes;
public sealed class DeleteNodeCommandHandler(IAppDbContext dbContext, IXuiPanelGateway gateway)
public sealed class DeleteNodeCommandHandler(IAppDbContext dbContext, IXuiPanelGateway gateway, ICurrentUser currentUser)
: ICommandHandler<DeleteNodeCommand, Result>
{
public async Task<Result> Handle(DeleteNodeCommand command, CancellationToken cancellationToken)
@@ -19,6 +20,9 @@ public sealed class DeleteNodeCommandHandler(IAppDbContext dbContext, IXuiPanelG
dbContext.Nodes.Remove(node);
gateway.InvalidateClient(node.Id);
dbContext.AuditLogs.Add(AuditLog.Create(
currentUser.UserId, "NodeDeleted", "Node", node.Id.ToString(), metadata: null, AuditSource.Web));
return Result.Success();
}
}
@@ -1,11 +1,13 @@
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Audit;
using PnvPanel.Domain.Nodes;
namespace PnvPanel.Application.Admin.Nodes;
public sealed class RegisterNodeCommandHandler(IAppDbContext dbContext, IXuiPanelGateway gateway, ISecretProtector secretProtector)
public sealed class RegisterNodeCommandHandler(
IAppDbContext dbContext, IXuiPanelGateway gateway, ISecretProtector secretProtector, ICurrentUser currentUser)
: ICommandHandler<RegisterNodeCommand, Result<NodeDto>>
{
public Task<Result<NodeDto>> Handle(RegisterNodeCommand command, CancellationToken cancellationToken)
@@ -21,6 +23,8 @@ public sealed class RegisterNodeCommandHandler(IAppDbContext dbContext, IXuiPane
var node = Node.Register(command.Name, baseAddress, credentials, command.Location);
dbContext.Nodes.Add(node);
dbContext.AuditLogs.Add(AuditLog.Create(
currentUser.UserId, "NodeRegistered", "Node", node.Id.ToString(), metadata: null, AuditSource.Web));
return Task.FromResult(Result.Success(NodeDto.FromDomain(node)));
}
@@ -2,11 +2,13 @@ using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Audit;
using PnvPanel.Domain.Nodes;
namespace PnvPanel.Application.Admin.Nodes;
public sealed class UpdateNodeCommandHandler(IAppDbContext dbContext, IXuiPanelGateway gateway, ISecretProtector secretProtector)
public sealed class UpdateNodeCommandHandler(
IAppDbContext dbContext, IXuiPanelGateway gateway, ISecretProtector secretProtector, ICurrentUser currentUser)
: ICommandHandler<UpdateNodeCommand, Result<NodeDto>>
{
public async Task<Result<NodeDto>> Handle(UpdateNodeCommand command, CancellationToken cancellationToken)
@@ -28,6 +30,9 @@ public sealed class UpdateNodeCommandHandler(IAppDbContext dbContext, IXuiPanelG
gateway.InvalidateClient(node.Id);
}
dbContext.AuditLogs.Add(AuditLog.Create(
currentUser.UserId, "NodeUpdated", "Node", node.Id.ToString(), metadata: null, AuditSource.Web));
return Result.Success(NodeDto.FromDomain(node));
}
}
@@ -1,11 +1,23 @@
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Audit;
namespace PnvPanel.Application.Admin.Users;
public sealed class ChangeUserRoleCommandHandler(IRoleService roleService) : ICommandHandler<ChangeUserRoleCommand, Result>
public sealed class ChangeUserRoleCommandHandler(IRoleService roleService, IAppDbContext dbContext, ICurrentUser currentUser)
: ICommandHandler<ChangeUserRoleCommand, Result>
{
public Task<Result> Handle(ChangeUserRoleCommand command, CancellationToken cancellationToken)
=> roleService.ChangeUserRoleAsync(command.UserId, command.RoleId, cancellationToken);
public async Task<Result> Handle(ChangeUserRoleCommand command, CancellationToken cancellationToken)
{
var result = await roleService.ChangeUserRoleAsync(command.UserId, command.RoleId, cancellationToken);
if (!result.IsSuccess)
return result;
dbContext.AuditLogs.Add(AuditLog.Create(
currentUser.UserId, "UserRoleChanged", "User", command.UserId.ToString(),
metadata: $"{{\"roleId\":\"{command.RoleId}\"}}", AuditSource.Web));
return result;
}
}
@@ -4,7 +4,8 @@ namespace PnvPanel.Application.Common.Interfaces;
public sealed record AuthenticatedUser(Guid Id, string UserName, string Role);
public sealed record CurrentUserProfile(Guid Id, string UserName, Guid RoleId, string Role, bool IsActivated, bool IsBlocked, int MaxConfigs);
public sealed record CurrentUserProfile(
Guid Id, string UserName, Guid RoleId, string Role, bool IsActivated, bool IsBlocked, int MaxConfigs, string SubscriptionToken);
public sealed record UserSummaryDto(Guid Id, string UserName, string Role, bool IsActivated, bool IsBlocked, DateTimeOffset? ActivatedAt);
@@ -0,0 +1,9 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Configs.GetMySubscription;
public sealed record GetMySubscriptionQuery : IQuery<Result<MySubscriptionDto>>;
/// <summary>SubscriptionToken — Api-слой строит из него абсолютный URL (знает scheme/host запроса), см. GetConfigLinkQuery.</summary>
public sealed record MySubscriptionDto(string SubscriptionToken);
@@ -0,0 +1,22 @@
using PnvPanel.Application.Auth;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Configs.GetMySubscription;
public sealed class GetMySubscriptionQueryHandler(IIdentityService identityService, ICurrentUser currentUser)
: IQueryHandler<GetMySubscriptionQuery, Result<MySubscriptionDto>>
{
public async Task<Result<MySubscriptionDto>> Handle(GetMySubscriptionQuery query, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return Result.Failure<MySubscriptionDto>(AuthErrors.Unauthorized);
var profile = await identityService.GetProfileAsync(userId, cancellationToken);
if (profile is null)
return Result.Failure<MySubscriptionDto>(AuthErrors.Unauthorized);
return Result.Success(new MySubscriptionDto(profile.SubscriptionToken));
}
}
@@ -26,10 +26,12 @@ public static class DependencyInjection
{
public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration)
{
var connectionString = configuration["ConnectionStrings:Default"]
?? throw new InvalidOperationException("Строка подключения 'ConnectionStrings:Default' не сконфигурирована.");
services.AddDbContext<AppDbContext>(options => options.UseNpgsql(connectionString));
// Строка подключения читается лениво внутри лямбды (а не в локальную переменную сразу), иначе
// в тестах WebApplicationFactory.ConfigureAppConfiguration (Testcontainers-порт) не успевает
// примениться до регистрации DbContext — окажется закэширован дефолт из appsettings.json.
services.AddDbContext<AppDbContext>(options => options.UseNpgsql(
configuration["ConnectionStrings:Default"]
?? throw new InvalidOperationException("Строка подключения 'ConnectionStrings:Default' не сконфигурирована.")));
services.AddScoped<IAppDbContext>(sp => sp.GetRequiredService<AppDbContext>());
services
@@ -59,7 +59,8 @@ internal sealed class IdentityService(UserManager<AppUser> userManager, SignInMa
return null;
var role = await GetPrimaryRoleAsync(user);
return new CurrentUserProfile(user.Id, user.UserName!, role.Id, role.Name!, user.IsActivated, user.IsBlocked, role.MaxConfigs);
return new CurrentUserProfile(
user.Id, user.UserName!, role.Id, role.Name!, user.IsActivated, user.IsBlocked, role.MaxConfigs, user.SubscriptionToken);
}
public async Task<Result> ChangePasswordAsync(Guid userId, string currentPassword, string newPassword, CancellationToken cancellationToken)
@@ -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",
});
});