diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props index 15e774b..e4c694a 100644 --- a/backend/Directory.Packages.props +++ b/backend/Directory.Packages.props @@ -24,6 +24,17 @@ + + + + + + + + + + \ No newline at end of file diff --git a/backend/PnvPanel.slnx b/backend/PnvPanel.slnx index 811000e..92128e6 100644 --- a/backend/PnvPanel.slnx +++ b/backend/PnvPanel.slnx @@ -5,4 +5,9 @@ + + + + + diff --git a/backend/src/PnvPanel.Api/Endpoints/AdminAppEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/AdminAppEndpoints.cs new file mode 100644 index 0000000..c7c9855 --- /dev/null +++ b/backend/src/PnvPanel.Api/Endpoints/AdminAppEndpoints.cs @@ -0,0 +1,54 @@ +using PnvPanel.Api.Common; +using PnvPanel.Application.Admin.Apps; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Domain.Apps; +using PnvPanel.Infrastructure.Identity; + +namespace PnvPanel.Api.Endpoints; + +public static class AdminAppEndpoints +{ + public static IEndpointRouteBuilder MapAdminAppEndpoints(this IEndpointRouteBuilder app) + { + var admin = app.MapGroup("/api/admin/apps") + .WithTags("Admin.Apps") + .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin)); + + admin.MapGet("", ListApps); + admin.MapPost("", CreateApp); + admin.MapPut("/{id:guid}", UpdateApp); + admin.MapDelete("/{id:guid}", DeleteApp); + + return app; + } + + private static async Task ListApps(ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new ListAdminAppsQuery(), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task CreateApp(CreateAppCommand command, ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(command, cancellationToken); + return result.ToHttpResult(); + } + + private static async Task UpdateApp(Guid id, UpdateAppBody body, ISender sender, CancellationToken cancellationToken) + { + var command = new UpdateAppCommand( + id, body.Name, body.DownloadUrl, body.OperatingSystem, body.Description, body.IconUrl, body.SortOrder, body.IsEnabled); + var result = await sender.Send(command, cancellationToken); + return result.ToHttpResult(); + } + + private static async Task DeleteApp(Guid id, ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new DeleteAppCommand(id), cancellationToken); + return result.ToHttpResult(); + } +} + +public sealed record UpdateAppBody( + string Name, string DownloadUrl, OsPlatform OperatingSystem, string? Description, string? IconUrl, + int SortOrder, bool IsEnabled); diff --git a/backend/src/PnvPanel.Api/Endpoints/AdminStatsEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/AdminStatsEndpoints.cs new file mode 100644 index 0000000..5b7fd67 --- /dev/null +++ b/backend/src/PnvPanel.Api/Endpoints/AdminStatsEndpoints.cs @@ -0,0 +1,35 @@ +using PnvPanel.Api.Common; +using PnvPanel.Application.Admin.Audit; +using PnvPanel.Application.Admin.Stats; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Infrastructure.Identity; + +namespace PnvPanel.Api.Endpoints; + +public static class AdminStatsEndpoints +{ + public static IEndpointRouteBuilder MapAdminStatsEndpoints(this IEndpointRouteBuilder app) + { + var admin = app.MapGroup("/api/admin") + .WithTags("Admin.Stats") + .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin)); + + admin.MapGet("/stats", GetStats); + admin.MapGet("/audit", GetAudit); + + return app; + } + + private static async Task GetStats(ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new GetStatsQuery(), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task GetAudit(int page, int pageSize, ISender sender, CancellationToken cancellationToken) + { + var query = new ListAuditLogsQuery(page <= 0 ? 1 : page, pageSize <= 0 ? 50 : pageSize); + var result = await sender.Send(query, cancellationToken); + return result.ToHttpResult(); + } +} diff --git a/backend/src/PnvPanel.Api/Endpoints/AdminUserEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/AdminUserEndpoints.cs new file mode 100644 index 0000000..1555c2f --- /dev/null +++ b/backend/src/PnvPanel.Api/Endpoints/AdminUserEndpoints.cs @@ -0,0 +1,65 @@ +using PnvPanel.Api.Common; +using PnvPanel.Application.Admin.Users; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Infrastructure.Identity; + +namespace PnvPanel.Api.Endpoints; + +public static class AdminUserEndpoints +{ + public static IEndpointRouteBuilder MapAdminUserEndpoints(this IEndpointRouteBuilder app) + { + var admin = app.MapGroup("/api/admin") + .WithTags("Admin.Users") + .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin)); + + admin.MapGet("/users", ListUsers); + admin.MapPatch("/users/{id:guid}/block", BlockUser); + admin.MapPatch("/users/{id:guid}/unblock", UnblockUser); + admin.MapPost("/users/{id:guid}/reset-password", ResetPassword); + admin.MapGet("/users/{id:guid}/configs", GetUserConfigs); + admin.MapDelete("/configs/{id:guid}", ForceRevokeConfig); + + return app; + } + + private static async Task ListUsers( + int page, int pageSize, string? search, ISender sender, CancellationToken cancellationToken) + { + var query = new ListUsersQuery(page <= 0 ? 1 : page, pageSize <= 0 ? 20 : pageSize, search); + var result = await sender.Send(query, cancellationToken); + return result.ToHttpResult(); + } + + private static async Task BlockUser(Guid id, ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new BlockUserCommand(id), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task UnblockUser(Guid id, ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new UnblockUserCommand(id), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task ResetPassword(Guid id, ResetPasswordBody body, ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new ResetUserPasswordCommand(id, body.NewPassword), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task GetUserConfigs(Guid id, ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new GetUserConfigsQuery(id), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task ForceRevokeConfig(Guid id, ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new ForceRevokeConfigCommand(id), cancellationToken); + return result.ToHttpResult(); + } +} + +public sealed record ResetPasswordBody(string NewPassword); diff --git a/backend/src/PnvPanel.Api/Endpoints/TelegramEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/TelegramEndpoints.cs new file mode 100644 index 0000000..af629c1 --- /dev/null +++ b/backend/src/PnvPanel.Api/Endpoints/TelegramEndpoints.cs @@ -0,0 +1,91 @@ +using Microsoft.Extensions.Options; +using PnvPanel.Api.Common; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Telegram; +using PnvPanel.Infrastructure.Telegram; + +namespace PnvPanel.Api.Endpoints; + +public static class TelegramEndpoints +{ + public static IEndpointRouteBuilder MapTelegramEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/auth/telegram") + .WithTags("Auth.Telegram") + .RequireRateLimiting(RateLimiting.AuthPolicy); + + group.MapPost("/link-token", CreateLinkToken).RequireAuthorization(); + group.MapPost("/unlink", Unlink).RequireAuthorization(); + group.MapPost("/login-request", CreateLoginRequest); + group.MapGet("/login-request/{id:guid}", GetLoginRequestStatus); + + return app; + } + + private static async Task CreateLinkToken( + ISender sender, IOptions options, CancellationToken cancellationToken) + { + var result = await sender.Send(new CreateLinkTokenCommand(), cancellationToken); + if (!result.IsSuccess) + return result.ToHttpResult(); + + var botUsername = options.Value.BotUsername; + var deepLink = string.IsNullOrWhiteSpace(botUsername) + ? null + : $"https://t.me/{botUsername}?start=link_{result.Value.Token}"; + + return Results.Ok(new { deepLink, expiresAt = result.Value.ExpiresAt }); + } + + private static async Task Unlink(ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new UnlinkTelegramCommand(), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task CreateLoginRequest( + HttpRequest request, ISender sender, IOptions options, CancellationToken cancellationToken) + { + var context = request.HttpContext.Connection.RemoteIpAddress?.ToString(); + var result = await sender.Send(new CreateLoginRequestCommand(context), cancellationToken); + if (!result.IsSuccess) + return result.ToHttpResult(); + + var botUsername = options.Value.BotUsername; + var deepLink = string.IsNullOrWhiteSpace(botUsername) + ? null + : $"https://t.me/{botUsername}?start=login_{result.Value.RequestId}"; + + return Results.Ok(new { requestId = result.Value.RequestId, deepLink, expiresAt = result.Value.ExpiresAt }); + } + + private static async Task GetLoginRequestStatus(Guid id, HttpResponse response, ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new GetLoginRequestStatusQuery(id), cancellationToken); + if (!result.IsSuccess) + return result.ToHttpResult(); + + if (result.Value.Auth is { } auth) + { + var cookieOptions = new CookieOptions + { + HttpOnly = true, + Secure = true, + SameSite = SameSiteMode.Strict, + Path = "/api/auth", + Expires = auth.RefreshTokenExpiresAt, + }; + response.Cookies.Append("pnv_refresh_token", auth.RefreshToken, cookieOptions); + + return Results.Ok(new + { + status = result.Value.Status.ToString(), + accessToken = auth.AccessToken, + expiresAt = auth.AccessTokenExpiresAt, + user = auth.User, + }); + } + + return Results.Ok(new { status = result.Value.Status.ToString() }); + } +} diff --git a/backend/src/PnvPanel.Api/Hubs/PanelHub.cs b/backend/src/PnvPanel.Api/Hubs/PanelHub.cs new file mode 100644 index 0000000..d6b9bcd --- /dev/null +++ b/backend/src/PnvPanel.Api/Hubs/PanelHub.cs @@ -0,0 +1,33 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.SignalR; +using PnvPanel.Infrastructure.Identity; + +namespace PnvPanel.Api.Hubs; + +/// +/// Группы: user:{userId} (личные события — трафик/статус конфига), admins (статусы нод, +/// запросы активации). UserIdentifier берётся из claim NameIdentifier — того же, что кладём в JWT. +/// +[Authorize] +public sealed class PanelHub : Hub +{ + public override async Task OnConnectedAsync() + { + if (Context.UserIdentifier is { } userId) + await Groups.AddToGroupAsync(Context.ConnectionId, GroupNames.User(userId)); + + if (Context.User?.IsInRole(RoleNames.Admin) == true) + await Groups.AddToGroupAsync(Context.ConnectionId, GroupNames.Admins); + + await base.OnConnectedAsync(); + } +} + +public static class GroupNames +{ + public const string Admins = "admins"; + + public static string User(string userId) => $"user:{userId}"; + + public static string User(Guid userId) => User(userId.ToString()); +} diff --git a/backend/src/PnvPanel.Api/Hubs/SignalRRealtimeNotifier.cs b/backend/src/PnvPanel.Api/Hubs/SignalRRealtimeNotifier.cs new file mode 100644 index 0000000..876244e --- /dev/null +++ b/backend/src/PnvPanel.Api/Hubs/SignalRRealtimeNotifier.cs @@ -0,0 +1,51 @@ +using Microsoft.AspNetCore.SignalR; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Domain.Configs; +using PnvPanel.Domain.Nodes; + +namespace PnvPanel.Api.Hubs; + +internal sealed class SignalRRealtimeNotifier(IHubContext hubContext) : IRealtimeNotifier +{ + public Task NotifyConfigTrafficUpdatedAsync( + Guid userId, Guid configId, long usedUpBytes, long usedDownBytes, CancellationToken cancellationToken) + { + return hubContext.Clients.Group(GroupNames.User(userId)).SendAsync( + "configTrafficUpdated", + new { configId, usedUpBytes, usedDownBytes }, + cancellationToken); + } + + public Task NotifyConfigStatusChangedAsync(Guid userId, Guid configId, ConfigStatus status, CancellationToken cancellationToken) + { + return hubContext.Clients.Group(GroupNames.User(userId)).SendAsync( + "configStatusChanged", + new { configId, status = status.ToString() }, + cancellationToken); + } + + public Task NotifyNodeStatusChangedAsync(Guid nodeId, NodeStatus status, DateTimeOffset? lastSyncAt, CancellationToken cancellationToken) + { + return hubContext.Clients.Group(GroupNames.Admins).SendAsync( + "nodeStatusChanged", + new { nodeId, status = status.ToString(), lastSyncAt }, + cancellationToken); + } + + public Task NotifyActivationRequestedAsync( + Guid requestId, Guid userId, string userName, string? comment, DateTimeOffset createdAt, CancellationToken cancellationToken) + { + return hubContext.Clients.Group(GroupNames.Admins).SendAsync( + "activationRequested", + new { requestId, userId, userName, comment, createdAt }, + cancellationToken); + } + + public Task NotifyUserActivatedAsync(Guid userId, CancellationToken cancellationToken) + { + return hubContext.Clients.Group(GroupNames.User(userId)).SendAsync( + "userActivated", + new { userId }, + cancellationToken); + } +} diff --git a/backend/src/PnvPanel.Api/PnvPanel.Api.csproj b/backend/src/PnvPanel.Api/PnvPanel.Api.csproj index e0fcc93..c00562a 100644 --- a/backend/src/PnvPanel.Api/PnvPanel.Api.csproj +++ b/backend/src/PnvPanel.Api/PnvPanel.Api.csproj @@ -19,6 +19,7 @@ + diff --git a/backend/src/PnvPanel.Api/Program.cs b/backend/src/PnvPanel.Api/Program.cs index 87917b1..f89a532 100644 --- a/backend/src/PnvPanel.Api/Program.cs +++ b/backend/src/PnvPanel.Api/Program.cs @@ -1,13 +1,19 @@ using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.RateLimiting; +using Microsoft.Extensions.Options; using PnvPanel.Api.Common; using PnvPanel.Api.Endpoints; +using PnvPanel.Api.Hubs; +using PnvPanel.Api.Telegram; using PnvPanel.Application; +using PnvPanel.Application.Common.Interfaces; using PnvPanel.Infrastructure; using PnvPanel.Infrastructure.Identity; using PnvPanel.Infrastructure.Persistence; +using PnvPanel.Infrastructure.Telegram; using Scalar.AspNetCore; using Serilog; +using Telegram.Bot; var builder = WebApplication.CreateBuilder(args); @@ -29,6 +35,22 @@ builder.Services.AddHttpContextAccessor(); builder.Services.AddApplication(); builder.Services.AddInfrastructure(builder.Configuration); +builder.Services.AddSignalR(); +// В Api, не в Infrastructure — реализации нужен IHubContext, а Hub определён здесь же. +builder.Services.AddSingleton(); + +// Telegram-бот: presentation-адаптер, хостится в процессе Api (long polling). Клиент регистрируем +// всегда (даже с пустым токеном) — TelegramBotHostedService сам решает не стартовать без токена. +builder.Services.AddSingleton(sp => +{ + var options = sp.GetRequiredService>(); + return new TelegramBotClient(options.Value.BotToken ?? string.Empty); +}); +// Scoped — зависит от IIdentityService (scoped), не Singleton. +builder.Services.AddScoped(); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); + builder.Services.AddRateLimiter(options => { options.AddFixedWindowLimiter(RateLimiting.AuthPolicy, limiterOptions => @@ -74,6 +96,12 @@ app.MapInboundEndpoints(); app.MapConfigEndpoints(); app.MapSubscriptionEndpoints(); app.MapAppEndpoints(); +app.MapAdminUserEndpoints(); +app.MapAdminStatsEndpoints(); +app.MapAdminAppEndpoints(); +app.MapTelegramEndpoints(); + +app.MapHub("/hubs/panel"); // Раздача статики SPA из wwwroot + fallback на index.html для клиентских маршрутов. app.UseDefaultFiles(); @@ -81,3 +109,6 @@ app.UseStaticFiles(); app.MapFallbackToFile("index.html"); app.Run(); + +/// Делает неявный класс Program доступным для WebApplicationFactory<Program> в интеграционных тестах. +public partial class Program; diff --git a/backend/src/PnvPanel.Api/Telegram/PnvBotUpdateHandler.cs b/backend/src/PnvPanel.Api/Telegram/PnvBotUpdateHandler.cs new file mode 100644 index 0000000..b5d4997 --- /dev/null +++ b/backend/src/PnvPanel.Api/Telegram/PnvBotUpdateHandler.cs @@ -0,0 +1,293 @@ +using Microsoft.Extensions.Options; +using PnvPanel.Application.Admin.Activation; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Configs.GetMyConfigs; +using PnvPanel.Application.Telegram; +using PnvPanel.Application.Telegram.Bot; +using PnvPanel.Domain.Activation; +using PnvPanel.Infrastructure.Telegram; +using Telegram.Bot; +using Telegram.Bot.Polling; +using Telegram.Bot.Types; +using Telegram.Bot.Types.Enums; +using Telegram.Bot.Types.ReplyMarkups; + +namespace PnvPanel.Api.Telegram; + +/// +/// Обрабатывает апдейты бота. Каждый апдейт — свой DI-scope (как HTTP-запрос), чтобы получить +/// свежие scoped-сервисы (ISender, ICurrentUserSetter, ...). Бот — read-only по конфигам в MVP. +/// +public sealed class PnvBotUpdateHandler( + IServiceScopeFactory scopeFactory, IOptions options, ILogger logger) + : IUpdateHandler +{ + public async Task HandleUpdateAsync(ITelegramBotClient botClient, Update update, CancellationToken cancellationToken) + { + await using var scope = scopeFactory.CreateAsyncScope(); + + try + { + if (update.Message is { Text: { } text } message) + await HandleMessageAsync(botClient, scope.ServiceProvider, message, text, cancellationToken); + else if (update.CallbackQuery is { } callback) + await HandleCallbackAsync(botClient, scope.ServiceProvider, callback, cancellationToken); + } + catch (Exception ex) + { + logger.LogError(ex, "Ошибка обработки Telegram-апдейта {UpdateId}", update.Id); + } + } + + public Task HandleErrorAsync(ITelegramBotClient botClient, Exception exception, HandleErrorSource source, CancellationToken cancellationToken) + { + logger.LogError(exception, "Ошибка Telegram-бота (источник {Source})", source); + return Task.CompletedTask; + } + + private async Task HandleMessageAsync( + ITelegramBotClient botClient, IServiceProvider services, Message message, string text, CancellationToken cancellationToken) + { + var chatId = message.Chat.Id; + var fromId = message.From?.Id; + if (fromId is null) + return; + + if (text.StartsWith("/start", StringComparison.Ordinal)) + { + var payload = text.Length > 7 ? text[7..].Trim() : string.Empty; + + if (payload.StartsWith("link_", StringComparison.Ordinal)) + await HandleLinkAsync(botClient, services, chatId, fromId.Value, message.From?.Username, payload[5..], cancellationToken); + else if (payload.StartsWith("login_", StringComparison.Ordinal)) + await HandleLoginPromptAsync(botClient, services, chatId, fromId.Value, payload[6..], cancellationToken); + else + await SendWelcomeAsync(botClient, chatId, cancellationToken); + + return; + } + + switch (text) + { + case "/configs": + await HandleConfigsAsync(botClient, services, chatId, fromId.Value, cancellationToken); + break; + case "/unlink": + await HandleUnlinkAsync(botClient, services, chatId, fromId.Value, cancellationToken); + break; + case "/requests": + await HandleRequestsAsync(botClient, services, chatId, fromId.Value, cancellationToken); + break; + case "/help": + await SendWelcomeAsync(botClient, chatId, cancellationToken); + break; + default: + await botClient.SendMessage(chatId, "Не понимаю эту команду. /help — список команд.", cancellationToken: cancellationToken); + break; + } + } + + private async Task HandleCallbackAsync( + ITelegramBotClient botClient, IServiceProvider services, CallbackQuery callback, CancellationToken cancellationToken) + { + var data = callback.Data; + var chatId = callback.Message?.Chat.Id; + var fromId = callback.From.Id; + if (data is null || chatId is null) + return; + + var parts = data.Split(':'); + if (parts.Length != 3 || !Guid.TryParse(parts[2], out var requestId)) + return; + + var sender = services.GetRequiredService(); + + switch (parts[0]) + { + case "login": + { + var result = parts[1] == "approve" + ? await sender.Send(new ApproveTelegramLoginCommand(requestId, fromId), cancellationToken) + : await sender.Send(new RejectTelegramLoginCommand(requestId, fromId), cancellationToken); + + await botClient.AnswerCallbackQuery(callback.Id, result.IsSuccess ? "Готово" : result.Error.Message, cancellationToken: cancellationToken); + if (result.IsSuccess) + { + var text = parts[1] == "approve" ? "✅ Вход подтверждён." : "❌ Вход отклонён."; + await botClient.SendMessage(chatId.Value, text, cancellationToken: cancellationToken); + } + + break; + } + case "act": + { + if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken)) + { + await botClient.AnswerCallbackQuery(callback.Id, "Недостаточно прав.", cancellationToken: cancellationToken); + return; + } + + var result = parts[1] == "approve" + ? await sender.Send(new ApproveActivationCommand(requestId), cancellationToken) + : await sender.Send(new RejectActivationCommand(requestId, Reason: null), cancellationToken); + + await botClient.AnswerCallbackQuery(callback.Id, result.IsSuccess ? "Готово" : result.Error.Message, cancellationToken: cancellationToken); + if (result.IsSuccess) + { + var text = parts[1] == "approve" ? "✅ Пользователь активирован." : "❌ Запрос отклонён."; + await botClient.SendMessage(chatId.Value, text, cancellationToken: cancellationToken); + } + + break; + } + } + } + + private async Task HandleLinkAsync( + ITelegramBotClient botClient, IServiceProvider services, long chatId, long fromId, string? username, string token, + CancellationToken cancellationToken) + { + var sender = services.GetRequiredService(); + var result = await sender.Send(new LinkTelegramCommand(token, fromId, username), cancellationToken); + + var text = result.IsSuccess + ? "✅ Telegram успешно привязан к вашему аккаунту." + : $"❌ Не удалось привязать: {result.Error.Message}"; + + await botClient.SendMessage(chatId, text, cancellationToken: cancellationToken); + } + + private async Task HandleLoginPromptAsync( + ITelegramBotClient botClient, IServiceProvider services, long chatId, long fromId, string requestIdRaw, CancellationToken cancellationToken) + { + if (!Guid.TryParse(requestIdRaw, out var requestId)) + { + await botClient.SendMessage(chatId, "Некорректная ссылка входа.", cancellationToken: cancellationToken); + return; + } + + var identityService = services.GetRequiredService(); + var userId = await identityService.FindUserIdByTelegramUserIdAsync(fromId, cancellationToken); + if (userId is null) + { + await botClient.SendMessage(chatId, "Сначала привяжите Telegram к аккаунту на сайте.", cancellationToken: cancellationToken); + return; + } + + var keyboard = new InlineKeyboardMarkup(new[] + { + InlineKeyboardButton.WithCallbackData("✅ Подтвердить вход", $"login:approve:{requestId}"), + InlineKeyboardButton.WithCallbackData("❌ Отклонить", $"login:reject:{requestId}"), + }); + + await botClient.SendMessage( + chatId, "Кто-то пытается войти в PnvPanel через ваш аккаунт. Подтвердить вход?", + replyMarkup: keyboard, cancellationToken: cancellationToken); + } + + private async Task HandleConfigsAsync( + ITelegramBotClient botClient, IServiceProvider services, long chatId, long fromId, CancellationToken cancellationToken) + { + if (!await TrySetCurrentUserAsync(services, fromId, cancellationToken)) + { + await botClient.SendMessage( + chatId, "Сначала привяжите Telegram к аккаунту на сайте (Настройки → Привязать Telegram).", + cancellationToken: cancellationToken); + return; + } + + var sender = services.GetRequiredService(); + var result = await sender.Send(new GetMyConfigsQuery(), cancellationToken); + + if (!result.IsSuccess || result.Value.Configs.Count == 0) + { + await botClient.SendMessage(chatId, "У вас пока нет конфигов.", cancellationToken: cancellationToken); + return; + } + + var lines = result.Value.Configs.Select(c => + $"• {c.Label ?? c.Location} ({c.Protocol}) — {c.Status}"); + await botClient.SendMessage(chatId, "Ваши конфиги:\n" + string.Join('\n', lines), cancellationToken: cancellationToken); + } + + private async Task HandleUnlinkAsync( + ITelegramBotClient botClient, IServiceProvider services, long chatId, long fromId, CancellationToken cancellationToken) + { + if (!await TrySetCurrentUserAsync(services, fromId, cancellationToken)) + { + await botClient.SendMessage(chatId, "Telegram не привязан.", cancellationToken: cancellationToken); + return; + } + + var sender = services.GetRequiredService(); + var result = await sender.Send(new UnlinkTelegramCommand(), cancellationToken); + + await botClient.SendMessage( + chatId, result.IsSuccess ? "Telegram отвязан от аккаунта." : $"Ошибка: {result.Error.Message}", + cancellationToken: cancellationToken); + } + + private async Task HandleRequestsAsync( + ITelegramBotClient botClient, IServiceProvider services, long chatId, long fromId, CancellationToken cancellationToken) + { + if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken)) + { + await botClient.SendMessage(chatId, "Недостаточно прав.", cancellationToken: cancellationToken); + return; + } + + var sender = services.GetRequiredService(); + var result = await sender.Send(new ListActivationRequestsQuery(ActivationStatus.Pending, 1, 10), cancellationToken); + + if (!result.IsSuccess || result.Value.Items.Count == 0) + { + await botClient.SendMessage(chatId, "Нет ожидающих запросов на активацию.", cancellationToken: cancellationToken); + return; + } + + foreach (var item in result.Value.Items) + { + var text = $"Запрос от {item.UserName}" + (string.IsNullOrWhiteSpace(item.Comment) ? "" : $"\n{item.Comment}"); + var keyboard = new InlineKeyboardMarkup(new[] + { + InlineKeyboardButton.WithCallbackData("✅ Активировать", $"act:approve:{item.Id}"), + InlineKeyboardButton.WithCallbackData("❌ Отклонить", $"act:reject:{item.Id}"), + }); + await botClient.SendMessage(chatId, text, parseMode: ParseMode.Html, replyMarkup: keyboard, cancellationToken: cancellationToken); + } + } + + private static async Task SendWelcomeAsync(ITelegramBotClient botClient, long chatId, CancellationToken cancellationToken) + { + const string text = "Привет! Это бот PnvPanel.\n\n" + + "/configs — мои конфиги\n" + + "/login — войти на сайт без пароля\n" + + "/unlink — отвязать Telegram\n" + + "/help — эта справка"; + await botClient.SendMessage(chatId, text, cancellationToken: cancellationToken); + } + + private static async Task TrySetCurrentUserAsync(IServiceProvider services, long telegramUserId, CancellationToken cancellationToken) + { + var identityService = services.GetRequiredService(); + var userId = await identityService.FindUserIdByTelegramUserIdAsync(telegramUserId, cancellationToken); + if (userId is null) + return false; + + var profile = await identityService.GetProfileAsync(userId.Value, cancellationToken); + if (profile is null) + return false; + + services.GetRequiredService().SetUser(profile.Id, profile.UserName); + return true; + } + + private async Task TrySetAdminCurrentUserAsync(IServiceProvider services, long telegramUserId, CancellationToken cancellationToken) + { + if (!options.Value.ParseAdminTelegramUserIds().Contains(telegramUserId)) + return false; + + return await TrySetCurrentUserAsync(services, telegramUserId, cancellationToken); + } +} diff --git a/backend/src/PnvPanel.Api/Telegram/TelegramBotHostedService.cs b/backend/src/PnvPanel.Api/Telegram/TelegramBotHostedService.cs new file mode 100644 index 0000000..18499b8 --- /dev/null +++ b/backend/src/PnvPanel.Api/Telegram/TelegramBotHostedService.cs @@ -0,0 +1,45 @@ +using Microsoft.Extensions.Options; +using PnvPanel.Infrastructure.Telegram; +using Telegram.Bot; +using Telegram.Bot.Polling; +using Telegram.Bot.Types; +using Telegram.Bot.Types.Enums; + +namespace PnvPanel.Api.Telegram; + +/// +/// Бот — presentation-адаптер, хостится в процессе Api (long polling). Если BotToken не задан, +/// не стартует — панель работает без бота. Апдейты обрабатывает PnvBotUpdateHandler, который +/// вызывает те же CQRS-команды, что и веб, через собственный ISender. +/// +public sealed class TelegramBotHostedService( + ITelegramBotClient botClient, PnvBotUpdateHandler updateHandler, IOptions options, + ILogger logger) + : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + if (string.IsNullOrWhiteSpace(options.Value.BotToken)) + { + logger.LogWarning("Telegram__BotToken не задан — бот не стартует."); + return; + } + + var receiverOptions = new ReceiverOptions + { + AllowedUpdates = [UpdateType.Message, UpdateType.CallbackQuery], + DropPendingUpdates = true, + }; + + logger.LogInformation("Telegram-бот запускается (long polling)..."); + + try + { + await botClient.ReceiveAsync(updateHandler, receiverOptions, stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + // Штатная остановка вместе с приложением. + } + } +} diff --git a/backend/src/PnvPanel.Api/Telegram/TelegramNotifier.cs b/backend/src/PnvPanel.Api/Telegram/TelegramNotifier.cs new file mode 100644 index 0000000..a33b1cb --- /dev/null +++ b/backend/src/PnvPanel.Api/Telegram/TelegramNotifier.cs @@ -0,0 +1,62 @@ +using Microsoft.Extensions.Options; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Infrastructure.Telegram; +using Telegram.Bot; +using Telegram.Bot.Types.Enums; +using Telegram.Bot.Types.ReplyMarkups; + +namespace PnvPanel.Api.Telegram; + +internal sealed class TelegramNotifier(ITelegramBotClient botClient, IIdentityService identityService, IOptions options) + : ITelegramNotifier +{ + public async Task NotifyAdminsActivationRequestedAsync( + Guid requestId, string userName, string? comment, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(options.Value.BotToken)) + return; + + var text = $"🆕 Запрос на активацию от {Escape(userName)}" + + (string.IsNullOrWhiteSpace(comment) ? string.Empty : $"\nКомментарий: {Escape(comment)}"); + + var keyboard = new InlineKeyboardMarkup(new[] + { + InlineKeyboardButton.WithCallbackData("✅ Активировать", $"act:approve:{requestId}"), + InlineKeyboardButton.WithCallbackData("❌ Отклонить", $"act:reject:{requestId}"), + }); + + foreach (var adminId in options.Value.ParseAdminTelegramUserIds()) + { + try + { + await botClient.SendMessage( + adminId, text, parseMode: ParseMode.Html, replyMarkup: keyboard, cancellationToken: cancellationToken); + } + catch + { + // Админ мог не запускать бота (нет чата с ботом) — пропускаем, не валим команду. + } + } + } + + public async Task NotifyUserAsync(Guid userId, string message, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(options.Value.BotToken)) + return; + + var link = await identityService.GetTelegramLinkInfoAsync(userId, cancellationToken); + if (!link.IsLinked || link.TelegramUserId is not { } telegramUserId) + return; + + try + { + await botClient.SendMessage(telegramUserId, message, cancellationToken: cancellationToken); + } + catch + { + // Пользователь мог заблокировать бота — не критично для основной операции. + } + } + + private static string Escape(string text) => text.Replace("&", "&").Replace("<", "<").Replace(">", ">"); +} diff --git a/backend/src/PnvPanel.Application/Activation/RequestActivationCommandHandler.cs b/backend/src/PnvPanel.Application/Activation/RequestActivationCommandHandler.cs index ebc960c..200ec3d 100644 --- a/backend/src/PnvPanel.Application/Activation/RequestActivationCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Activation/RequestActivationCommandHandler.cs @@ -7,7 +7,8 @@ using PnvPanel.Domain.Activation; namespace PnvPanel.Application.Activation; -public sealed class RequestActivationCommandHandler(IAppDbContext dbContext, ICurrentUser currentUser) +public sealed class RequestActivationCommandHandler( + IAppDbContext dbContext, IRealtimeNotifier notifier, ITelegramNotifier telegramNotifier, ICurrentUser currentUser) : ICommandHandler> { public async Task> Handle(RequestActivationCommand command, CancellationToken cancellationToken) @@ -24,6 +25,11 @@ public sealed class RequestActivationCommandHandler(IAppDbContext dbContext, ICu var request = ActivationRequest.Create(userId, command.Comment); dbContext.ActivationRequests.Add(request); + var userName = currentUser.UserName ?? userId.ToString(); + + await notifier.NotifyActivationRequestedAsync(request.Id, userId, userName, request.Comment, request.CreatedAt, cancellationToken); + await telegramNotifier.NotifyAdminsActivationRequestedAsync(request.Id, userName, request.Comment, cancellationToken); + return Result.Success(new ActivationRequestDto(request.Id, request.Comment, request.CreatedAt)); } } diff --git a/backend/src/PnvPanel.Application/Admin/Activation/ApproveActivationCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Activation/ApproveActivationCommandHandler.cs index 1cbbffd..3535332 100644 --- a/backend/src/PnvPanel.Application/Admin/Activation/ApproveActivationCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Admin/Activation/ApproveActivationCommandHandler.cs @@ -8,7 +8,8 @@ using PnvPanel.Domain.Activation; namespace PnvPanel.Application.Admin.Activation; -public sealed class ApproveActivationCommandHandler(IAppDbContext dbContext, IIdentityService identityService, ICurrentUser currentUser) +public sealed class ApproveActivationCommandHandler( + IAppDbContext dbContext, IIdentityService identityService, IRealtimeNotifier notifier, ICurrentUser currentUser) : ICommandHandler { public async Task Handle(ApproveActivationCommand command, CancellationToken cancellationToken) @@ -27,6 +28,11 @@ public sealed class ApproveActivationCommandHandler(IAppDbContext dbContext, IId request.Approve(adminId); - return await identityService.ActivateUserAsync(request.UserId, adminId, cancellationToken); + var activateResult = await identityService.ActivateUserAsync(request.UserId, adminId, cancellationToken); + if (!activateResult.IsSuccess) + return activateResult; + + await notifier.NotifyUserActivatedAsync(request.UserId, cancellationToken); + return Result.Success(); } } diff --git a/backend/src/PnvPanel.Application/Admin/Apps/AdminAppDto.cs b/backend/src/PnvPanel.Application/Admin/Apps/AdminAppDto.cs new file mode 100644 index 0000000..e430043 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Apps/AdminAppDto.cs @@ -0,0 +1,12 @@ +using PnvPanel.Domain.Apps; + +namespace PnvPanel.Application.Admin.Apps; + +public sealed record AdminAppDto( + Guid Id, string Name, string DownloadUrl, OsPlatform OperatingSystem, string? Description, + string? IconUrl, int SortOrder, bool IsEnabled) +{ + public static AdminAppDto FromDomain(ClientApp app) => new( + app.Id, app.Name, app.DownloadUrl.ToString(), app.OperatingSystem, app.Description, + app.IconUrl, app.SortOrder, app.IsEnabled); +} diff --git a/backend/src/PnvPanel.Application/Admin/Apps/AppErrors.cs b/backend/src/PnvPanel.Application/Admin/Apps/AppErrors.cs new file mode 100644 index 0000000..7e16574 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Apps/AppErrors.cs @@ -0,0 +1,8 @@ +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Apps; + +public static class AppErrors +{ + public static readonly Error NotFound = Error.NotFound("Apps.NotFound", "Приложение не найдено."); +} diff --git a/backend/src/PnvPanel.Application/Admin/Apps/CreateAppCommand.cs b/backend/src/PnvPanel.Application/Admin/Apps/CreateAppCommand.cs new file mode 100644 index 0000000..bb55ff9 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Apps/CreateAppCommand.cs @@ -0,0 +1,9 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Domain.Apps; + +namespace PnvPanel.Application.Admin.Apps; + +public sealed record CreateAppCommand( + string Name, string DownloadUrl, OsPlatform OperatingSystem, string? Description, string? IconUrl, int SortOrder) + : ICommand>; diff --git a/backend/src/PnvPanel.Application/Admin/Apps/CreateAppCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Apps/CreateAppCommandHandler.cs new file mode 100644 index 0000000..434fbae --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Apps/CreateAppCommandHandler.cs @@ -0,0 +1,20 @@ +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Domain.Apps; + +namespace PnvPanel.Application.Admin.Apps; + +public sealed class CreateAppCommandHandler(IAppDbContext dbContext) : ICommandHandler> +{ + public Task> Handle(CreateAppCommand command, CancellationToken cancellationToken) + { + var app = ClientApp.Create( + command.Name, new Uri(command.DownloadUrl, UriKind.Absolute), command.OperatingSystem, + command.Description, command.IconUrl, command.SortOrder); + + dbContext.ClientApps.Add(app); + + return Task.FromResult(Result.Success(AdminAppDto.FromDomain(app))); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Apps/CreateAppCommandValidator.cs b/backend/src/PnvPanel.Application/Admin/Apps/CreateAppCommandValidator.cs new file mode 100644 index 0000000..3b4f1ef --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Apps/CreateAppCommandValidator.cs @@ -0,0 +1,15 @@ +using FluentValidation; + +namespace PnvPanel.Application.Admin.Apps; + +public sealed class CreateAppCommandValidator : AbstractValidator +{ + public CreateAppCommandValidator() + { + RuleFor(x => x.Name).NotEmpty().MaximumLength(100); + RuleFor(x => x.DownloadUrl).NotEmpty().MaximumLength(500); + RuleFor(x => x.OperatingSystem).IsInEnum(); + RuleFor(x => x.Description).MaximumLength(300); + RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Apps/DeleteAppCommand.cs b/backend/src/PnvPanel.Application/Admin/Apps/DeleteAppCommand.cs new file mode 100644 index 0000000..2d68d6c --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Apps/DeleteAppCommand.cs @@ -0,0 +1,6 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Apps; + +public sealed record DeleteAppCommand(Guid AppId) : ICommand; diff --git a/backend/src/PnvPanel.Application/Admin/Apps/DeleteAppCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Apps/DeleteAppCommandHandler.cs new file mode 100644 index 0000000..2d6d1a1 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Apps/DeleteAppCommandHandler.cs @@ -0,0 +1,19 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Apps; + +public sealed class DeleteAppCommandHandler(IAppDbContext dbContext) : ICommandHandler +{ + public async Task Handle(DeleteAppCommand command, CancellationToken cancellationToken) + { + var app = await dbContext.ClientApps.FirstOrDefaultAsync(a => a.Id == command.AppId, cancellationToken); + if (app is null) + return Result.Failure(AppErrors.NotFound); + + dbContext.ClientApps.Remove(app); + return Result.Success(); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Apps/ListAdminAppsQuery.cs b/backend/src/PnvPanel.Application/Admin/Apps/ListAdminAppsQuery.cs new file mode 100644 index 0000000..19c4e83 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Apps/ListAdminAppsQuery.cs @@ -0,0 +1,6 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Apps; + +public sealed record ListAdminAppsQuery : IQuery>>; diff --git a/backend/src/PnvPanel.Application/Admin/Apps/ListAdminAppsQueryHandler.cs b/backend/src/PnvPanel.Application/Admin/Apps/ListAdminAppsQueryHandler.cs new file mode 100644 index 0000000..95b390f --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Apps/ListAdminAppsQueryHandler.cs @@ -0,0 +1,18 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Apps; + +public sealed class ListAdminAppsQueryHandler(IAppDbContext dbContext) : IQueryHandler>> +{ + public async Task>> Handle(ListAdminAppsQuery query, CancellationToken cancellationToken) + { + var apps = await dbContext.ClientApps.AsNoTracking() + .OrderBy(a => a.OperatingSystem).ThenBy(a => a.SortOrder) + .ToListAsync(cancellationToken); + + return Result.Success>(apps.Select(AdminAppDto.FromDomain).ToList()); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Apps/UpdateAppCommand.cs b/backend/src/PnvPanel.Application/Admin/Apps/UpdateAppCommand.cs new file mode 100644 index 0000000..cf5d089 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Apps/UpdateAppCommand.cs @@ -0,0 +1,10 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Domain.Apps; + +namespace PnvPanel.Application.Admin.Apps; + +public sealed record UpdateAppCommand( + Guid AppId, string Name, string DownloadUrl, OsPlatform OperatingSystem, string? Description, + string? IconUrl, int SortOrder, bool IsEnabled) + : ICommand>; diff --git a/backend/src/PnvPanel.Application/Admin/Apps/UpdateAppCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Apps/UpdateAppCommandHandler.cs new file mode 100644 index 0000000..2ad1f2b --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Apps/UpdateAppCommandHandler.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Apps; + +public sealed class UpdateAppCommandHandler(IAppDbContext dbContext) : ICommandHandler> +{ + public async Task> Handle(UpdateAppCommand command, CancellationToken cancellationToken) + { + var app = await dbContext.ClientApps.FirstOrDefaultAsync(a => a.Id == command.AppId, cancellationToken); + if (app is null) + return Result.Failure(AppErrors.NotFound); + + app.Update( + command.Name, new Uri(command.DownloadUrl, UriKind.Absolute), command.OperatingSystem, + command.Description, command.IconUrl, command.SortOrder, command.IsEnabled); + + return Result.Success(AdminAppDto.FromDomain(app)); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Apps/UpdateAppCommandValidator.cs b/backend/src/PnvPanel.Application/Admin/Apps/UpdateAppCommandValidator.cs new file mode 100644 index 0000000..9856890 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Apps/UpdateAppCommandValidator.cs @@ -0,0 +1,15 @@ +using FluentValidation; + +namespace PnvPanel.Application.Admin.Apps; + +public sealed class UpdateAppCommandValidator : AbstractValidator +{ + public UpdateAppCommandValidator() + { + RuleFor(x => x.Name).NotEmpty().MaximumLength(100); + RuleFor(x => x.DownloadUrl).NotEmpty().MaximumLength(500); + RuleFor(x => x.OperatingSystem).IsInEnum(); + RuleFor(x => x.Description).MaximumLength(300); + RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Audit/ListAuditLogsQuery.cs b/backend/src/PnvPanel.Application/Admin/Audit/ListAuditLogsQuery.cs new file mode 100644 index 0000000..e33a781 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Audit/ListAuditLogsQuery.cs @@ -0,0 +1,11 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Domain.Audit; + +namespace PnvPanel.Application.Admin.Audit; + +public sealed record ListAuditLogsQuery(int Page, int PageSize) : IQuery>>; + +public sealed record AuditLogDto( + long Id, Guid? ActorId, string Action, string TargetType, string TargetId, string? Metadata, + AuditSource Source, DateTimeOffset CreatedAt); diff --git a/backend/src/PnvPanel.Application/Admin/Audit/ListAuditLogsQueryHandler.cs b/backend/src/PnvPanel.Application/Admin/Audit/ListAuditLogsQueryHandler.cs new file mode 100644 index 0000000..27aca6e --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Audit/ListAuditLogsQueryHandler.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Audit; + +public sealed class ListAuditLogsQueryHandler(IAppDbContext dbContext) : IQueryHandler>> +{ + public async Task>> Handle(ListAuditLogsQuery query, CancellationToken cancellationToken) + { + var page = query.Page <= 0 ? 1 : query.Page; + var pageSize = query.PageSize is <= 0 or > 200 ? 50 : query.PageSize; + + var result = await dbContext.AuditLogs.AsNoTracking() + .OrderByDescending(a => a.CreatedAt) + .Select(a => new AuditLogDto(a.Id, a.ActorId, a.Action, a.TargetType, a.TargetId, a.Metadata, a.Source, a.CreatedAt)) + .ToPagedListAsync(page, pageSize, cancellationToken); + + return Result.Success(result); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Stats/GetStatsQuery.cs b/backend/src/PnvPanel.Application/Admin/Stats/GetStatsQuery.cs new file mode 100644 index 0000000..3cd6b5e --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Stats/GetStatsQuery.cs @@ -0,0 +1,11 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Stats; + +public sealed record GetStatsQuery : IQuery>; + +public sealed record StatsDto( + int TotalUsers, int ActivatedUsers, int PendingActivationRequests, + int TotalNodes, int OnlineNodes, int TotalConfigs, int ActiveConfigs, + long TotalUsedUpBytes, long TotalUsedDownBytes); diff --git a/backend/src/PnvPanel.Application/Admin/Stats/GetStatsQueryHandler.cs b/backend/src/PnvPanel.Application/Admin/Stats/GetStatsQueryHandler.cs new file mode 100644 index 0000000..e881f30 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Stats/GetStatsQueryHandler.cs @@ -0,0 +1,37 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Domain.Activation; +using PnvPanel.Domain.Configs; +using PnvPanel.Domain.Nodes; + +namespace PnvPanel.Application.Admin.Stats; + +public sealed class GetStatsQueryHandler(IAppDbContext dbContext, IIdentityService identityService) + : IQueryHandler> +{ + public async Task> Handle(GetStatsQuery query, CancellationToken cancellationToken) + { + var userStats = await identityService.GetUserStatsAsync(cancellationToken); + + var pendingActivations = await dbContext.ActivationRequests + .CountAsync(r => r.Status == ActivationStatus.Pending, cancellationToken); + + var totalNodes = await dbContext.Nodes.CountAsync(cancellationToken); + var onlineNodes = await dbContext.Nodes.CountAsync(n => n.Status == NodeStatus.Online, cancellationToken); + + var totalConfigs = await dbContext.VpnConfigs.CountAsync(cancellationToken); + var activeConfigs = await dbContext.VpnConfigs.CountAsync(c => c.Status == ConfigStatus.Active, cancellationToken); + + var trafficTotals = await dbContext.VpnConfigs + .GroupBy(_ => 1) + .Select(g => new { Up = g.Sum(c => c.UsedUpBytes), Down = g.Sum(c => c.UsedDownBytes) }) + .FirstOrDefaultAsync(cancellationToken); + + return Result.Success(new StatsDto( + userStats.Total, userStats.Activated, pendingActivations, + totalNodes, onlineNodes, totalConfigs, activeConfigs, + trafficTotals?.Up ?? 0, trafficTotals?.Down ?? 0)); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Users/BlockUserCommand.cs b/backend/src/PnvPanel.Application/Admin/Users/BlockUserCommand.cs new file mode 100644 index 0000000..7641dc6 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Users/BlockUserCommand.cs @@ -0,0 +1,6 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Users; + +public sealed record BlockUserCommand(Guid UserId) : ICommand; diff --git a/backend/src/PnvPanel.Application/Admin/Users/BlockUserCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Users/BlockUserCommandHandler.cs new file mode 100644 index 0000000..81a160c --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Users/BlockUserCommandHandler.cs @@ -0,0 +1,49 @@ +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.Configs; + +namespace PnvPanel.Application.Admin.Users; + +/// Блокировка гасит все активные конфиги в 3x-ui (см. architecture.md). +public sealed class BlockUserCommandHandler( + IAppDbContext dbContext, IIdentityService identityService, IXuiPanelGateway gateway, + IRealtimeNotifier notifier, ICurrentUser currentUser) + : ICommandHandler +{ + public async Task Handle(BlockUserCommand command, CancellationToken cancellationToken) + { + var blockResult = await identityService.BlockUserAsync(command.UserId, cancellationToken); + if (!blockResult.IsSuccess) + return blockResult; + + var configs = await dbContext.VpnConfigs + .Where(c => c.UserId == command.UserId && c.Status == ConfigStatus.Active) + .ToListAsync(cancellationToken); + + foreach (var config in configs) + { + var inbound = await dbContext.Inbounds.AsNoTracking().FirstOrDefaultAsync(i => i.Id == config.InboundId, cancellationToken); + var node = inbound is null + ? null + : await dbContext.Nodes.AsNoTracking().FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken); + + if (inbound is not null && node is not null) + { + await gateway.UpdateClientAsync( + node, inbound.RemoteInboundId, config.ClientExternalId, config.Protocol, + config.Label ?? config.ClientEmail, config.DeviceLimit, enable: false, cancellationToken); + } + + config.Disable(); + await notifier.NotifyConfigStatusChangedAsync(config.UserId, config.Id, config.Status, cancellationToken); + } + + dbContext.AuditLogs.Add(AuditLog.Create( + currentUser.UserId, "UserBlocked", "User", command.UserId.ToString(), metadata: null, AuditSource.Web)); + + return Result.Success(); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Users/ForceRevokeConfigCommand.cs b/backend/src/PnvPanel.Application/Admin/Users/ForceRevokeConfigCommand.cs new file mode 100644 index 0000000..bcb05f5 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Users/ForceRevokeConfigCommand.cs @@ -0,0 +1,6 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Users; + +public sealed record ForceRevokeConfigCommand(Guid ConfigId) : ICommand; diff --git a/backend/src/PnvPanel.Application/Admin/Users/ForceRevokeConfigCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Users/ForceRevokeConfigCommandHandler.cs new file mode 100644 index 0000000..3f8ffd2 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Users/ForceRevokeConfigCommandHandler.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Application.Configs; +using PnvPanel.Domain.Audit; +using PnvPanel.Domain.Configs; + +namespace PnvPanel.Application.Admin.Users; + +public sealed class ForceRevokeConfigCommandHandler( + IAppDbContext dbContext, IXuiPanelGateway gateway, IRealtimeNotifier notifier, ICurrentUser currentUser) + : ICommandHandler +{ + public async Task Handle(ForceRevokeConfigCommand command, CancellationToken cancellationToken) + { + var config = await dbContext.VpnConfigs.FirstOrDefaultAsync(c => c.Id == command.ConfigId, cancellationToken); + if (config is null) + return Result.Failure(ConfigErrors.NotFound); + + if (config.Status == ConfigStatus.Revoked) + return Result.Success(); + + var inbound = await dbContext.Inbounds.AsNoTracking().FirstOrDefaultAsync(i => i.Id == config.InboundId, cancellationToken); + var node = inbound is null + ? null + : await dbContext.Nodes.AsNoTracking().FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken); + + if (inbound is not null && node is not null) + await gateway.RemoveClientAsync(node, inbound.RemoteInboundId, config.ClientExternalId, config.Protocol, cancellationToken); + + config.Revoke(); + await notifier.NotifyConfigStatusChangedAsync(config.UserId, config.Id, config.Status, cancellationToken); + + dbContext.AuditLogs.Add(AuditLog.Create( + currentUser.UserId, "ConfigForceRevoked", "VpnConfig", config.Id.ToString(), metadata: null, AuditSource.Web)); + + return Result.Success(); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Users/GetUserConfigsQuery.cs b/backend/src/PnvPanel.Application/Admin/Users/GetUserConfigsQuery.cs new file mode 100644 index 0000000..ca8ef9d --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Users/GetUserConfigsQuery.cs @@ -0,0 +1,7 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Application.Configs; + +namespace PnvPanel.Application.Admin.Users; + +public sealed record GetUserConfigsQuery(Guid UserId) : IQuery>>; diff --git a/backend/src/PnvPanel.Application/Admin/Users/GetUserConfigsQueryHandler.cs b/backend/src/PnvPanel.Application/Admin/Users/GetUserConfigsQueryHandler.cs new file mode 100644 index 0000000..853dbaa --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Users/GetUserConfigsQueryHandler.cs @@ -0,0 +1,24 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Application.Configs; +using PnvPanel.Domain.Configs; + +namespace PnvPanel.Application.Admin.Users; + +public sealed class GetUserConfigsQueryHandler(IAppDbContext dbContext) + : IQueryHandler>> +{ + public async Task>> Handle(GetUserConfigsQuery query, CancellationToken cancellationToken) + { + var rows = await dbContext.VpnConfigs.AsNoTracking() + .Where(c => c.UserId == query.UserId && c.Status != ConfigStatus.Revoked) + .Join(dbContext.Inbounds.AsNoTracking(), c => c.InboundId, i => i.Id, (c, i) => new { Config = c, Inbound = i }) + .OrderByDescending(x => x.Config.CreatedAt) + .ToListAsync(cancellationToken); + + var dtos = rows.Select(x => VpnConfigDto.FromDomain(x.Config, x.Inbound)).ToList(); + return Result.Success>(dtos); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Users/ListUsersQuery.cs b/backend/src/PnvPanel.Application/Admin/Users/ListUsersQuery.cs new file mode 100644 index 0000000..a2d88ef --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Users/ListUsersQuery.cs @@ -0,0 +1,7 @@ +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Users; + +public sealed record ListUsersQuery(int Page, int PageSize, string? Search) : IQuery>>; diff --git a/backend/src/PnvPanel.Application/Admin/Users/ListUsersQueryHandler.cs b/backend/src/PnvPanel.Application/Admin/Users/ListUsersQueryHandler.cs new file mode 100644 index 0000000..16b0588 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Users/ListUsersQueryHandler.cs @@ -0,0 +1,18 @@ +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Users; + +public sealed class ListUsersQueryHandler(IIdentityService identityService) + : IQueryHandler>> +{ + public async Task>> Handle(ListUsersQuery query, CancellationToken cancellationToken) + { + var page = query.Page <= 0 ? 1 : query.Page; + var pageSize = query.PageSize is <= 0 or > 100 ? 20 : query.PageSize; + + var result = await identityService.ListUsersAsync(page, pageSize, query.Search, cancellationToken); + return Result.Success(result); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Users/ResetUserPasswordCommand.cs b/backend/src/PnvPanel.Application/Admin/Users/ResetUserPasswordCommand.cs new file mode 100644 index 0000000..48a7f69 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Users/ResetUserPasswordCommand.cs @@ -0,0 +1,6 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Users; + +public sealed record ResetUserPasswordCommand(Guid UserId, string NewPassword) : ICommand; diff --git a/backend/src/PnvPanel.Application/Admin/Users/ResetUserPasswordCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Users/ResetUserPasswordCommandHandler.cs new file mode 100644 index 0000000..7403333 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Users/ResetUserPasswordCommandHandler.cs @@ -0,0 +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 ResetUserPasswordCommandHandler(IAppDbContext dbContext, IIdentityService identityService, ICurrentUser currentUser) + : ICommandHandler +{ + public async Task Handle(ResetUserPasswordCommand command, CancellationToken cancellationToken) + { + var result = await identityService.ResetPasswordAsync(command.UserId, command.NewPassword, cancellationToken); + if (!result.IsSuccess) + return result; + + dbContext.AuditLogs.Add(AuditLog.Create( + currentUser.UserId, "UserPasswordReset", "User", command.UserId.ToString(), metadata: null, AuditSource.Web)); + await dbContext.SaveChangesAsync(cancellationToken); + + return Result.Success(); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Users/UnblockUserCommand.cs b/backend/src/PnvPanel.Application/Admin/Users/UnblockUserCommand.cs new file mode 100644 index 0000000..9905e39 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Users/UnblockUserCommand.cs @@ -0,0 +1,6 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Users; + +public sealed record UnblockUserCommand(Guid UserId) : ICommand; diff --git a/backend/src/PnvPanel.Application/Admin/Users/UnblockUserCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Users/UnblockUserCommandHandler.cs new file mode 100644 index 0000000..2d798f1 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Users/UnblockUserCommandHandler.cs @@ -0,0 +1,49 @@ +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.Configs; + +namespace PnvPanel.Application.Admin.Users; + +/// Разблокировка возвращает в 3x-ui только конфиги, погашенные блокировкой (Disabled). +public sealed class UnblockUserCommandHandler( + IAppDbContext dbContext, IIdentityService identityService, IXuiPanelGateway gateway, + IRealtimeNotifier notifier, ICurrentUser currentUser) + : ICommandHandler +{ + public async Task Handle(UnblockUserCommand command, CancellationToken cancellationToken) + { + var unblockResult = await identityService.UnblockUserAsync(command.UserId, cancellationToken); + if (!unblockResult.IsSuccess) + return unblockResult; + + var configs = await dbContext.VpnConfigs + .Where(c => c.UserId == command.UserId && c.Status == ConfigStatus.Disabled) + .ToListAsync(cancellationToken); + + foreach (var config in configs) + { + var inbound = await dbContext.Inbounds.AsNoTracking().FirstOrDefaultAsync(i => i.Id == config.InboundId, cancellationToken); + var node = inbound is null + ? null + : await dbContext.Nodes.AsNoTracking().FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken); + + if (inbound is not null && node is not null) + { + await gateway.UpdateClientAsync( + node, inbound.RemoteInboundId, config.ClientExternalId, config.Protocol, + config.Label ?? config.ClientEmail, config.DeviceLimit, enable: true, cancellationToken); + } + + config.Enable(); + await notifier.NotifyConfigStatusChangedAsync(config.UserId, config.Id, config.Status, cancellationToken); + } + + dbContext.AuditLogs.Add(AuditLog.Create( + currentUser.UserId, "UserUnblocked", "User", command.UserId.ToString(), metadata: null, AuditSource.Web)); + + return Result.Success(); + } +} diff --git a/backend/src/PnvPanel.Application/Auth/AuthErrors.cs b/backend/src/PnvPanel.Application/Auth/AuthErrors.cs index 3e4afc8..afc0a5f 100644 --- a/backend/src/PnvPanel.Application/Auth/AuthErrors.cs +++ b/backend/src/PnvPanel.Application/Auth/AuthErrors.cs @@ -18,4 +18,7 @@ public static class AuthErrors public static readonly Error Unauthorized = Error.Unauthorized("Auth.Unauthorized", "Требуется аутентификация."); + + public static readonly Error UserBlocked = + Error.Forbidden("Auth.UserBlocked", "Аккаунт заблокирован администратором."); } diff --git a/backend/src/PnvPanel.Application/Auth/CurrentUserDto.cs b/backend/src/PnvPanel.Application/Auth/CurrentUserDto.cs index 5d7276c..4a91132 100644 --- a/backend/src/PnvPanel.Application/Auth/CurrentUserDto.cs +++ b/backend/src/PnvPanel.Application/Auth/CurrentUserDto.cs @@ -1,3 +1,3 @@ namespace PnvPanel.Application.Auth; -public sealed record CurrentUserDto(Guid Id, string UserName, string Role, bool IsActivated); +public sealed record CurrentUserDto(Guid Id, string UserName, string Role, bool IsActivated, bool TelegramLinked); diff --git a/backend/src/PnvPanel.Application/Auth/Login/LoginCommandHandler.cs b/backend/src/PnvPanel.Application/Auth/Login/LoginCommandHandler.cs index 1210a40..748577b 100644 --- a/backend/src/PnvPanel.Application/Auth/Login/LoginCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Auth/Login/LoginCommandHandler.cs @@ -23,7 +23,8 @@ public sealed class LoginCommandHandler( var (accessToken, accessExpiresAt) = jwtTokenService.GenerateAccessToken(user); var refreshToken = await refreshTokenService.IssueAsync(user.Id, cancellationToken); - var dto = new CurrentUserDto(profile.Id, profile.UserName, profile.Role, profile.IsActivated); + var telegramInfo = await identityService.GetTelegramLinkInfoAsync(profile.Id, cancellationToken); + var dto = new CurrentUserDto(profile.Id, profile.UserName, profile.Role, profile.IsActivated, telegramInfo.IsLinked); return Result.Success(new AuthResult( accessToken, diff --git a/backend/src/PnvPanel.Application/Auth/Me/GetCurrentUserQueryHandler.cs b/backend/src/PnvPanel.Application/Auth/Me/GetCurrentUserQueryHandler.cs index 6c39d19..b3c02e7 100644 --- a/backend/src/PnvPanel.Application/Auth/Me/GetCurrentUserQueryHandler.cs +++ b/backend/src/PnvPanel.Application/Auth/Me/GetCurrentUserQueryHandler.cs @@ -16,6 +16,8 @@ public sealed class GetCurrentUserQueryHandler(IIdentityService identityService, if (profile is null) return Result.Failure(AuthErrors.Unauthorized); - return Result.Success(new CurrentUserDto(profile.Id, profile.UserName, profile.Role, profile.IsActivated)); + var telegramInfo = await identityService.GetTelegramLinkInfoAsync(userId, cancellationToken); + + return Result.Success(new CurrentUserDto(profile.Id, profile.UserName, profile.Role, profile.IsActivated, telegramInfo.IsLinked)); } } diff --git a/backend/src/PnvPanel.Application/Auth/Refresh/RefreshCommandHandler.cs b/backend/src/PnvPanel.Application/Auth/Refresh/RefreshCommandHandler.cs index a4ddc64..3501728 100644 --- a/backend/src/PnvPanel.Application/Auth/Refresh/RefreshCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Auth/Refresh/RefreshCommandHandler.cs @@ -22,7 +22,8 @@ public sealed class RefreshCommandHandler( var authUser = new AuthenticatedUser(profile.Id, profile.UserName, profile.Role); var (accessToken, accessExpiresAt) = jwtTokenService.GenerateAccessToken(authUser); - var dto = new CurrentUserDto(profile.Id, profile.UserName, profile.Role, profile.IsActivated); + var telegramInfo = await identityService.GetTelegramLinkInfoAsync(profile.Id, cancellationToken); + var dto = new CurrentUserDto(profile.Id, profile.UserName, profile.Role, profile.IsActivated, telegramInfo.IsLinked); return Result.Success(new AuthResult( accessToken, diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/IAppDbContext.cs b/backend/src/PnvPanel.Application/Common/Interfaces/IAppDbContext.cs index 2380c90..760e5e7 100644 --- a/backend/src/PnvPanel.Application/Common/Interfaces/IAppDbContext.cs +++ b/backend/src/PnvPanel.Application/Common/Interfaces/IAppDbContext.cs @@ -2,9 +2,11 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using PnvPanel.Domain.Activation; using PnvPanel.Domain.Apps; +using PnvPanel.Domain.Audit; using PnvPanel.Domain.Configs; using PnvPanel.Domain.Inbounds; using PnvPanel.Domain.Nodes; +using PnvPanel.Domain.Telegram; namespace PnvPanel.Application.Common.Interfaces; @@ -12,12 +14,20 @@ public interface IAppDbContext { DbSet ActivationRequests { get; } + DbSet AuditLogs { get; } + + DbSet TelegramLinkTokens { get; } + + DbSet TelegramLoginRequests { get; } + DbSet Nodes { get; } DbSet Inbounds { get; } DbSet VpnConfigs { get; } + DbSet TrafficSamples { get; } + DbSet ClientApps { get; } /// Нужен для advisory-lock при проверке квоты конфигов (см. CreateVpnConfigCommandHandler). diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/ICurrentUser.cs b/backend/src/PnvPanel.Application/Common/Interfaces/ICurrentUser.cs index 5918a9b..a46f14c 100644 --- a/backend/src/PnvPanel.Application/Common/Interfaces/ICurrentUser.cs +++ b/backend/src/PnvPanel.Application/Common/Interfaces/ICurrentUser.cs @@ -6,3 +6,13 @@ public interface ICurrentUser string? UserName { get; } bool IsAuthenticated { get; } } + +/// +/// Только для фоновых/не-HTTP контекстов (Telegram-бот): задаёт "текущего" пользователя в рамках +/// одного DI-scope, чтобы переиспользовать те же команды/запросы, что и веб (которые читают +/// ICurrentUser). Реализуется тем же классом, что и ICurrentUser — см. CurrentUser (Infrastructure). +/// +public interface ICurrentUserSetter +{ + void SetUser(Guid userId, string userName); +} diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/IIdentityService.cs b/backend/src/PnvPanel.Application/Common/Interfaces/IIdentityService.cs index 2822b31..ae45518 100644 --- a/backend/src/PnvPanel.Application/Common/Interfaces/IIdentityService.cs +++ b/backend/src/PnvPanel.Application/Common/Interfaces/IIdentityService.cs @@ -4,7 +4,13 @@ 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, int MaxConfigs); +public sealed record CurrentUserProfile(Guid Id, string UserName, Guid RoleId, string Role, bool IsActivated, bool IsBlocked, int MaxConfigs); + +public sealed record UserSummaryDto(Guid Id, string UserName, string Role, bool IsActivated, bool IsBlocked, DateTimeOffset? ActivatedAt); + +public sealed record UserStatsDto(int Total, int Activated); + +public sealed record TelegramLinkInfo(bool IsLinked, long? TelegramUserId, string? Username); public interface IIdentityService { @@ -30,4 +36,24 @@ public interface IIdentityService /// Для агрегированной подписки /sub/{userToken} (все активные конфиги пользователя). Task FindUserIdBySubscriptionTokenAsync(string token, CancellationToken cancellationToken); + + /// Блокировка: вход запрещён (см. ValidateCredentialsAsync). Конфиги гасит вызывающая сторона. + Task BlockUserAsync(Guid userId, CancellationToken cancellationToken); + + Task UnblockUserAsync(Guid userId, CancellationToken cancellationToken); + + /// Сброс пароля админом — для пользователей без привязанного Telegram (M7). + Task ResetPasswordAsync(Guid userId, string newPassword, CancellationToken cancellationToken); + + Task> ListUsersAsync(int page, int pageSize, string? search, CancellationToken cancellationToken); + + Task GetUserStatsAsync(CancellationToken cancellationToken); + + Task LinkTelegramAsync(Guid userId, long telegramUserId, string? telegramUsername, CancellationToken cancellationToken); + + Task UnlinkTelegramAsync(Guid userId, CancellationToken cancellationToken); + + Task FindUserIdByTelegramUserIdAsync(long telegramUserId, CancellationToken cancellationToken); + + Task GetTelegramLinkInfoAsync(Guid userId, CancellationToken cancellationToken); } diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/IRealtimeNotifier.cs b/backend/src/PnvPanel.Application/Common/Interfaces/IRealtimeNotifier.cs new file mode 100644 index 0000000..50f2529 --- /dev/null +++ b/backend/src/PnvPanel.Application/Common/Interfaces/IRealtimeNotifier.cs @@ -0,0 +1,24 @@ +using PnvPanel.Domain.Configs; +using PnvPanel.Domain.Nodes; + +namespace PnvPanel.Application.Common.Interfaces; + +/// +/// Пуш событий через SignalR. Реализация (SignalRRealtimeNotifier) живёт в Api (не в Infrastructure) — +/// ей нужен IHubContext<PanelHub>, а Hub, будучи транспортным механизмом, определён в Api; +/// Infrastructure не может ссылаться на Api (обратное направление зависимостей). +/// +public interface IRealtimeNotifier +{ + Task NotifyConfigTrafficUpdatedAsync( + Guid userId, Guid configId, long usedUpBytes, long usedDownBytes, CancellationToken cancellationToken); + + Task NotifyConfigStatusChangedAsync(Guid userId, Guid configId, ConfigStatus status, CancellationToken cancellationToken); + + Task NotifyNodeStatusChangedAsync(Guid nodeId, NodeStatus status, DateTimeOffset? lastSyncAt, CancellationToken cancellationToken); + + Task NotifyActivationRequestedAsync( + Guid requestId, Guid userId, string userName, string? comment, DateTimeOffset createdAt, CancellationToken cancellationToken); + + Task NotifyUserActivatedAsync(Guid userId, CancellationToken cancellationToken); +} diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/ITelegramNotifier.cs b/backend/src/PnvPanel.Application/Common/Interfaces/ITelegramNotifier.cs new file mode 100644 index 0000000..e582420 --- /dev/null +++ b/backend/src/PnvPanel.Application/Common/Interfaces/ITelegramNotifier.cs @@ -0,0 +1,15 @@ +namespace PnvPanel.Application.Common.Interfaces; + +/// +/// Проактивные DM-уведомления через бота. Реализация — в Api (нужен ITelegramBotClient, +/// который там же и регистрируется), аналогично IRealtimeNotifier/PanelHub. Если BotToken не +/// задан — реализация тихо не отправляет ничего (бот работает без Telegram). +/// +public interface ITelegramNotifier +{ + Task NotifyAdminsActivationRequestedAsync( + Guid requestId, string userName, string? comment, CancellationToken cancellationToken); + + /// Личное сообщение пользователю, если у него привязан Telegram (иначе no-op). + Task NotifyUserAsync(Guid userId, string message, CancellationToken cancellationToken); +} diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/IXuiPanelGateway.cs b/backend/src/PnvPanel.Application/Common/Interfaces/IXuiPanelGateway.cs index 5cef6c3..23bb164 100644 --- a/backend/src/PnvPanel.Application/Common/Interfaces/IXuiPanelGateway.cs +++ b/backend/src/PnvPanel.Application/Common/Interfaces/IXuiPanelGateway.cs @@ -8,6 +8,8 @@ public sealed record RemoteInboundInfo(string RemoteInboundId, VpnProtocol Proto public sealed record NodeProbeResult(bool IsReachable, string? ErrorMessage); +public sealed record ClientTrafficInfo(long UpBytes, long DownBytes); + /// /// Оркестрация панелей 3x-ui через ThreeXui.Net. Один BaseAddress в библиотеке, но нод много — /// реализация держит клиента per-node (кэш по NodeId), см. XuiPanelGateway. @@ -38,4 +40,11 @@ public interface IXuiPanelGateway Task> BuildConnectionStringAsync( Node node, Inbound inbound, string clientExternalId, string clientName, string publicHost, CancellationToken cancellationToken); + + /// + /// Трафик по клиентам инбаунда, ключ — ClientEmail. ThreeXui.Net не даёт типизированного метода + /// для этого — извлекается из сырого clientStats[] в RawInboundJson (стандартное поле 3x-ui API). + /// + Task>> GetClientTrafficAsync( + Node node, string inboundRemoteId, CancellationToken cancellationToken); } diff --git a/backend/src/PnvPanel.Application/Configs/Revoke/RevokeVpnConfigCommandHandler.cs b/backend/src/PnvPanel.Application/Configs/Revoke/RevokeVpnConfigCommandHandler.cs index 4e1270a..26cb40f 100644 --- a/backend/src/PnvPanel.Application/Configs/Revoke/RevokeVpnConfigCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Configs/Revoke/RevokeVpnConfigCommandHandler.cs @@ -7,7 +7,8 @@ using PnvPanel.Domain.Configs; namespace PnvPanel.Application.Configs.Revoke; -public sealed class RevokeVpnConfigCommandHandler(IAppDbContext dbContext, IXuiPanelGateway gateway, ICurrentUser currentUser) +public sealed class RevokeVpnConfigCommandHandler( + IAppDbContext dbContext, IXuiPanelGateway gateway, IRealtimeNotifier notifier, ICurrentUser currentUser) : ICommandHandler { public async Task Handle(RevokeVpnConfigCommand command, CancellationToken cancellationToken) @@ -32,6 +33,8 @@ public sealed class RevokeVpnConfigCommandHandler(IAppDbContext dbContext, IXuiP await gateway.RemoveClientAsync(node, inbound.RemoteInboundId, config.ClientExternalId, config.Protocol, cancellationToken); config.Revoke(); + await notifier.NotifyConfigStatusChangedAsync(userId, config.Id, config.Status, cancellationToken); + return Result.Success(); } } diff --git a/backend/src/PnvPanel.Application/Telegram/Bot/ApproveTelegramLoginCommand.cs b/backend/src/PnvPanel.Application/Telegram/Bot/ApproveTelegramLoginCommand.cs new file mode 100644 index 0000000..92f7313 --- /dev/null +++ b/backend/src/PnvPanel.Application/Telegram/Bot/ApproveTelegramLoginCommand.cs @@ -0,0 +1,6 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Telegram.Bot; + +public sealed record ApproveTelegramLoginCommand(Guid RequestId, long TelegramUserId) : ICommand; diff --git a/backend/src/PnvPanel.Application/Telegram/Bot/ApproveTelegramLoginCommandHandler.cs b/backend/src/PnvPanel.Application/Telegram/Bot/ApproveTelegramLoginCommandHandler.cs new file mode 100644 index 0000000..f62ccdb --- /dev/null +++ b/backend/src/PnvPanel.Application/Telegram/Bot/ApproveTelegramLoginCommandHandler.cs @@ -0,0 +1,33 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Domain.Exceptions; + +namespace PnvPanel.Application.Telegram.Bot; + +public sealed class ApproveTelegramLoginCommandHandler(IAppDbContext dbContext, IIdentityService identityService) + : ICommandHandler +{ + public async Task Handle(ApproveTelegramLoginCommand command, CancellationToken cancellationToken) + { + var userId = await identityService.FindUserIdByTelegramUserIdAsync(command.TelegramUserId, cancellationToken); + if (userId is null) + return Result.Failure(TelegramErrors.NotLinked); + + var request = await dbContext.TelegramLoginRequests.FirstOrDefaultAsync(r => r.Id == command.RequestId, cancellationToken); + if (request is null) + return Result.Failure(TelegramErrors.LoginRequestNotFound); + + try + { + request.Approve(userId.Value); + } + catch (DomainException ex) + { + return Result.Failure(Error.Conflict("Telegram.LoginRequestInvalid", ex.Message)); + } + + return Result.Success(); + } +} diff --git a/backend/src/PnvPanel.Application/Telegram/Bot/LinkTelegramCommand.cs b/backend/src/PnvPanel.Application/Telegram/Bot/LinkTelegramCommand.cs new file mode 100644 index 0000000..44d72be --- /dev/null +++ b/backend/src/PnvPanel.Application/Telegram/Bot/LinkTelegramCommand.cs @@ -0,0 +1,7 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Telegram.Bot; + +/// Вызывается только из TelegramBotHostedService (обработка "/start link_<token>"). +public sealed record LinkTelegramCommand(string Token, long TelegramUserId, string? TelegramUsername) : ICommand>; diff --git a/backend/src/PnvPanel.Application/Telegram/Bot/LinkTelegramCommandHandler.cs b/backend/src/PnvPanel.Application/Telegram/Bot/LinkTelegramCommandHandler.cs new file mode 100644 index 0000000..42f6e2f --- /dev/null +++ b/backend/src/PnvPanel.Application/Telegram/Bot/LinkTelegramCommandHandler.cs @@ -0,0 +1,26 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Telegram.Bot; + +public sealed class LinkTelegramCommandHandler(IAppDbContext dbContext, IIdentityService identityService) + : ICommandHandler> +{ + public async Task> Handle(LinkTelegramCommand command, CancellationToken cancellationToken) + { + var linkToken = await dbContext.TelegramLinkTokens.FirstOrDefaultAsync(t => t.Token == command.Token, cancellationToken); + if (linkToken is null || !linkToken.IsValid) + return Result.Failure(TelegramErrors.LinkTokenNotFound); + + var linkResult = await identityService.LinkTelegramAsync( + linkToken.UserId, command.TelegramUserId, command.TelegramUsername, cancellationToken); + if (!linkResult.IsSuccess) + return Result.Failure(linkResult.Error); + + linkToken.Consume(); + + return Result.Success(linkToken.UserId); + } +} diff --git a/backend/src/PnvPanel.Application/Telegram/Bot/RejectTelegramLoginCommand.cs b/backend/src/PnvPanel.Application/Telegram/Bot/RejectTelegramLoginCommand.cs new file mode 100644 index 0000000..273af20 --- /dev/null +++ b/backend/src/PnvPanel.Application/Telegram/Bot/RejectTelegramLoginCommand.cs @@ -0,0 +1,6 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Telegram.Bot; + +public sealed record RejectTelegramLoginCommand(Guid RequestId, long TelegramUserId) : ICommand; diff --git a/backend/src/PnvPanel.Application/Telegram/Bot/RejectTelegramLoginCommandHandler.cs b/backend/src/PnvPanel.Application/Telegram/Bot/RejectTelegramLoginCommandHandler.cs new file mode 100644 index 0000000..e8506a3 --- /dev/null +++ b/backend/src/PnvPanel.Application/Telegram/Bot/RejectTelegramLoginCommandHandler.cs @@ -0,0 +1,33 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Domain.Exceptions; + +namespace PnvPanel.Application.Telegram.Bot; + +public sealed class RejectTelegramLoginCommandHandler(IAppDbContext dbContext, IIdentityService identityService) + : ICommandHandler +{ + public async Task Handle(RejectTelegramLoginCommand command, CancellationToken cancellationToken) + { + var userId = await identityService.FindUserIdByTelegramUserIdAsync(command.TelegramUserId, cancellationToken); + if (userId is null) + return Result.Failure(TelegramErrors.NotLinked); + + var request = await dbContext.TelegramLoginRequests.FirstOrDefaultAsync(r => r.Id == command.RequestId, cancellationToken); + if (request is null) + return Result.Failure(TelegramErrors.LoginRequestNotFound); + + try + { + request.Reject(); + } + catch (DomainException ex) + { + return Result.Failure(Error.Conflict("Telegram.LoginRequestInvalid", ex.Message)); + } + + return Result.Success(); + } +} diff --git a/backend/src/PnvPanel.Application/Telegram/CreateLinkTokenCommand.cs b/backend/src/PnvPanel.Application/Telegram/CreateLinkTokenCommand.cs new file mode 100644 index 0000000..8ede874 --- /dev/null +++ b/backend/src/PnvPanel.Application/Telegram/CreateLinkTokenCommand.cs @@ -0,0 +1,8 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Telegram; + +public sealed record CreateLinkTokenCommand : ICommand>; + +public sealed record LinkTokenDto(string Token, DateTimeOffset ExpiresAt); diff --git a/backend/src/PnvPanel.Application/Telegram/CreateLinkTokenCommandHandler.cs b/backend/src/PnvPanel.Application/Telegram/CreateLinkTokenCommandHandler.cs new file mode 100644 index 0000000..5c26dfd --- /dev/null +++ b/backend/src/PnvPanel.Application/Telegram/CreateLinkTokenCommandHandler.cs @@ -0,0 +1,24 @@ +using PnvPanel.Application.Auth; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Domain.Telegram; + +namespace PnvPanel.Application.Telegram; + +public sealed class CreateLinkTokenCommandHandler(IAppDbContext dbContext, ICurrentUser currentUser) + : ICommandHandler> +{ + private static readonly TimeSpan Ttl = TimeSpan.FromMinutes(5); + + public Task> Handle(CreateLinkTokenCommand command, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return Task.FromResult(Result.Failure(AuthErrors.Unauthorized)); + + var token = TelegramLinkToken.Create(userId, Ttl); + dbContext.TelegramLinkTokens.Add(token); + + return Task.FromResult(Result.Success(new LinkTokenDto(token.Token, token.ExpiresAt))); + } +} diff --git a/backend/src/PnvPanel.Application/Telegram/CreateLoginRequestCommand.cs b/backend/src/PnvPanel.Application/Telegram/CreateLoginRequestCommand.cs new file mode 100644 index 0000000..987af32 --- /dev/null +++ b/backend/src/PnvPanel.Application/Telegram/CreateLoginRequestCommand.cs @@ -0,0 +1,9 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Telegram; + +/// Публичная команда (пользователь ещё не залогинен) — начинает passwordless-вход. +public sealed record CreateLoginRequestCommand(string? Context) : ICommand>; + +public sealed record LoginRequestDto(Guid RequestId, DateTimeOffset ExpiresAt); diff --git a/backend/src/PnvPanel.Application/Telegram/CreateLoginRequestCommandHandler.cs b/backend/src/PnvPanel.Application/Telegram/CreateLoginRequestCommandHandler.cs new file mode 100644 index 0000000..fe9810c --- /dev/null +++ b/backend/src/PnvPanel.Application/Telegram/CreateLoginRequestCommandHandler.cs @@ -0,0 +1,20 @@ +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Domain.Telegram; + +namespace PnvPanel.Application.Telegram; + +public sealed class CreateLoginRequestCommandHandler(IAppDbContext dbContext) + : ICommandHandler> +{ + private static readonly TimeSpan Ttl = TimeSpan.FromMinutes(5); + + public Task> Handle(CreateLoginRequestCommand command, CancellationToken cancellationToken) + { + var request = TelegramLoginRequest.Create(Ttl, command.Context); + dbContext.TelegramLoginRequests.Add(request); + + return Task.FromResult(Result.Success(new LoginRequestDto(request.Id, request.ExpiresAt))); + } +} diff --git a/backend/src/PnvPanel.Application/Telegram/GetLoginRequestStatusQuery.cs b/backend/src/PnvPanel.Application/Telegram/GetLoginRequestStatusQuery.cs new file mode 100644 index 0000000..2d77ab2 --- /dev/null +++ b/backend/src/PnvPanel.Application/Telegram/GetLoginRequestStatusQuery.cs @@ -0,0 +1,10 @@ +using PnvPanel.Application.Auth; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Domain.Telegram; + +namespace PnvPanel.Application.Telegram; + +public sealed record GetLoginRequestStatusQuery(Guid RequestId) : IQuery>; + +public sealed record LoginRequestStatusDto(TelegramLoginStatus Status, AuthResult? Auth); diff --git a/backend/src/PnvPanel.Application/Telegram/GetLoginRequestStatusQueryHandler.cs b/backend/src/PnvPanel.Application/Telegram/GetLoginRequestStatusQueryHandler.cs new file mode 100644 index 0000000..0d79dba --- /dev/null +++ b/backend/src/PnvPanel.Application/Telegram/GetLoginRequestStatusQueryHandler.cs @@ -0,0 +1,48 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Auth; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Domain.Telegram; + +namespace PnvPanel.Application.Telegram; + +/// +/// Формально Query, но при первом наблюдении Approved-статуса атомарно "забирает" вход: +/// выпускает JWT и переводит запрос в Consumed (одноразовый claim), см. api-design.md. +/// Осознанное отступление от чистого CQRS ради простого поллинга без отдельного claim-эндпоинта. +/// +public sealed class GetLoginRequestStatusQueryHandler( + IAppDbContext dbContext, IIdentityService identityService, IJwtTokenService jwtTokenService, IRefreshTokenService refreshTokenService) + : IQueryHandler> +{ + public async Task> Handle(GetLoginRequestStatusQuery query, CancellationToken cancellationToken) + { + var request = await dbContext.TelegramLoginRequests.FirstOrDefaultAsync(r => r.Id == query.RequestId, cancellationToken); + if (request is null) + return Result.Failure(TelegramErrors.LoginRequestNotFound); + + if (request.Status == TelegramLoginStatus.Pending && request.IsExpired) + return Result.Success(new LoginRequestStatusDto(TelegramLoginStatus.Expired, null)); + + if (request.Status != TelegramLoginStatus.Approved) + return Result.Success(new LoginRequestStatusDto(request.Status, null)); + + var profile = await identityService.GetProfileAsync(request.UserId!.Value, cancellationToken); + if (profile is null) + return Result.Failure(AuthErrors.Unauthorized); + + var authUser = new AuthenticatedUser(profile.Id, profile.UserName, profile.Role); + var (accessToken, accessExpiresAt) = jwtTokenService.GenerateAccessToken(authUser); + + request.Consume(); + // IssueAsync сохраняет весь SaveChanges (в т.ч. Consume() выше) — см. RefreshTokenService. + var refreshToken = await refreshTokenService.IssueAsync(profile.Id, cancellationToken); + + // Пользователь только что подтвердил вход через бота — Telegram точно привязан. + var dto = new CurrentUserDto(profile.Id, profile.UserName, profile.Role, profile.IsActivated, TelegramLinked: true); + var auth = new AuthResult(accessToken, accessExpiresAt, refreshToken.RawToken, refreshToken.ExpiresAt, dto); + + return Result.Success(new LoginRequestStatusDto(TelegramLoginStatus.Approved, auth)); + } +} diff --git a/backend/src/PnvPanel.Application/Telegram/TelegramErrors.cs b/backend/src/PnvPanel.Application/Telegram/TelegramErrors.cs new file mode 100644 index 0000000..7570912 --- /dev/null +++ b/backend/src/PnvPanel.Application/Telegram/TelegramErrors.cs @@ -0,0 +1,15 @@ +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Telegram; + +public static class TelegramErrors +{ + public static readonly Error LinkTokenNotFound = + Error.NotFound("Telegram.LinkTokenNotFound", "Токен привязки не найден или истёк."); + + public static readonly Error LoginRequestNotFound = + Error.NotFound("Telegram.LoginRequestNotFound", "Запрос на вход не найден."); + + public static readonly Error NotLinked = + Error.Conflict("Telegram.NotLinked", "Telegram не привязан ни к одному аккаунту."); +} diff --git a/backend/src/PnvPanel.Application/Telegram/UnlinkTelegramCommand.cs b/backend/src/PnvPanel.Application/Telegram/UnlinkTelegramCommand.cs new file mode 100644 index 0000000..ef6e0d7 --- /dev/null +++ b/backend/src/PnvPanel.Application/Telegram/UnlinkTelegramCommand.cs @@ -0,0 +1,6 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Telegram; + +public sealed record UnlinkTelegramCommand : ICommand; diff --git a/backend/src/PnvPanel.Application/Telegram/UnlinkTelegramCommandHandler.cs b/backend/src/PnvPanel.Application/Telegram/UnlinkTelegramCommandHandler.cs new file mode 100644 index 0000000..7dd4fb8 --- /dev/null +++ b/backend/src/PnvPanel.Application/Telegram/UnlinkTelegramCommandHandler.cs @@ -0,0 +1,18 @@ +using PnvPanel.Application.Auth; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Telegram; + +public sealed class UnlinkTelegramCommandHandler(IIdentityService identityService, ICurrentUser currentUser) + : ICommandHandler +{ + public Task Handle(UnlinkTelegramCommand command, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return Task.FromResult(Result.Failure(AuthErrors.Unauthorized)); + + return identityService.UnlinkTelegramAsync(userId, cancellationToken); + } +} diff --git a/backend/src/PnvPanel.Domain/Audit/AuditLog.cs b/backend/src/PnvPanel.Domain/Audit/AuditLog.cs new file mode 100644 index 0000000..b5d5177 --- /dev/null +++ b/backend/src/PnvPanel.Domain/Audit/AuditLog.cs @@ -0,0 +1,33 @@ +namespace PnvPanel.Domain.Audit; + +/// Append-only журнал значимых действий. Id — long (не Guid), см. TrafficSample. +public sealed class AuditLog +{ + public long Id { get; private set; } + public Guid? ActorId { get; private set; } + public string Action { get; private set; } = string.Empty; + public string TargetType { get; private set; } = string.Empty; + public string TargetId { get; private set; } = string.Empty; + public string? Metadata { get; private set; } + public AuditSource Source { get; private set; } + public DateTimeOffset CreatedAt { get; private set; } + + private AuditLog() + { + } + + public static AuditLog Create( + Guid? actorId, string action, string targetType, string targetId, string? metadata, AuditSource source) + { + return new AuditLog + { + ActorId = actorId, + Action = action, + TargetType = targetType, + TargetId = targetId, + Metadata = metadata, + Source = source, + CreatedAt = DateTimeOffset.UtcNow, + }; + } +} diff --git a/backend/src/PnvPanel.Domain/Audit/AuditSource.cs b/backend/src/PnvPanel.Domain/Audit/AuditSource.cs new file mode 100644 index 0000000..ad78fa8 --- /dev/null +++ b/backend/src/PnvPanel.Domain/Audit/AuditSource.cs @@ -0,0 +1,8 @@ +namespace PnvPanel.Domain.Audit; + +public enum AuditSource +{ + Web, + Telegram, + System, +} diff --git a/backend/src/PnvPanel.Domain/Configs/TrafficSample.cs b/backend/src/PnvPanel.Domain/Configs/TrafficSample.cs new file mode 100644 index 0000000..a19000e --- /dev/null +++ b/backend/src/PnvPanel.Domain/Configs/TrafficSample.cs @@ -0,0 +1,29 @@ +namespace PnvPanel.Domain.Configs; + +/// +/// Точка истории трафика. Id — long (не Guid, как у Entity) — таблица растёт быстро, +/// авто-инкремент компактнее для высокочастотной записи. TTL-ретеншн — см. TrafficRetentionService. +/// +public sealed class TrafficSample +{ + public long Id { get; private set; } + public Guid ConfigId { get; private set; } + public DateTimeOffset Timestamp { get; private set; } + public long UpBytes { get; private set; } + public long DownBytes { get; private set; } + + private TrafficSample() + { + } + + public static TrafficSample Create(Guid configId, DateTimeOffset timestamp, long upBytes, long downBytes) + { + return new TrafficSample + { + ConfigId = configId, + Timestamp = timestamp, + UpBytes = upBytes, + DownBytes = downBytes, + }; + } +} diff --git a/backend/src/PnvPanel.Domain/Configs/VpnConfig.cs b/backend/src/PnvPanel.Domain/Configs/VpnConfig.cs index 1bee4f9..93ca401 100644 --- a/backend/src/PnvPanel.Domain/Configs/VpnConfig.cs +++ b/backend/src/PnvPanel.Domain/Configs/VpnConfig.cs @@ -72,6 +72,28 @@ public sealed class VpnConfig : Entity Status = ConfigStatus.Revoked; } + /// Синхронизация из 3x-ui (см. TrafficSyncService). + public void UpdateTraffic(long usedUpBytes, long usedDownBytes) + { + UsedUpBytes = usedUpBytes; + UsedDownBytes = usedDownBytes; + LastSyncAt = DateTimeOffset.UtcNow; + } + + /// Блокировка пользователя админом — гасит клиента в 3x-ui, но не отзывает запись. + public void Disable() + { + if (Status == ConfigStatus.Active) + Status = ConfigStatus.Disabled; + } + + /// Разблокировка — возвращает в Active только то, что было погашено блокировкой. + public void Enable() + { + if (Status == ConfigStatus.Disabled) + Status = ConfigStatus.Active; + } + private void EnsureActive(string action) { if (Status != ConfigStatus.Active) diff --git a/backend/src/PnvPanel.Domain/Telegram/TelegramLinkToken.cs b/backend/src/PnvPanel.Domain/Telegram/TelegramLinkToken.cs new file mode 100644 index 0000000..72aad1f --- /dev/null +++ b/backend/src/PnvPanel.Domain/Telegram/TelegramLinkToken.cs @@ -0,0 +1,41 @@ +using System.Security.Cryptography; +using PnvPanel.Domain.Common; +using PnvPanel.Domain.Exceptions; + +namespace PnvPanel.Domain.Telegram; + +/// Короткоживущий одноразовый токен для флоу привязки Telegram (deep-link в бота). +public sealed class TelegramLinkToken : Entity +{ + public string Token { get; private set; } = string.Empty; + public Guid UserId { get; private set; } + public DateTimeOffset ExpiresAt { get; private set; } + public DateTimeOffset? ConsumedAt { get; private set; } + + private TelegramLinkToken() + { + } + + public static TelegramLinkToken Create(Guid userId, TimeSpan ttl) + { + return new TelegramLinkToken + { + Id = Guid.NewGuid(), + Token = GenerateToken(), + UserId = userId, + ExpiresAt = DateTimeOffset.UtcNow.Add(ttl), + }; + } + + public bool IsValid => ConsumedAt is null && DateTimeOffset.UtcNow < ExpiresAt; + + public void Consume() + { + if (!IsValid) + throw new DomainException("Токен привязки недействителен или уже использован."); + + ConsumedAt = DateTimeOffset.UtcNow; + } + + private static string GenerateToken() => Convert.ToHexString(RandomNumberGenerator.GetBytes(24)).ToLowerInvariant(); +} diff --git a/backend/src/PnvPanel.Domain/Telegram/TelegramLoginRequest.cs b/backend/src/PnvPanel.Domain/Telegram/TelegramLoginRequest.cs new file mode 100644 index 0000000..7f40253 --- /dev/null +++ b/backend/src/PnvPanel.Domain/Telegram/TelegramLoginRequest.cs @@ -0,0 +1,69 @@ +using PnvPanel.Domain.Common; +using PnvPanel.Domain.Exceptions; + +namespace PnvPanel.Domain.Telegram; + +/// +/// Passwordless-вход: сайт создаёт запрос (Id = nonce в deep-link), пользователь подтверждает +/// в боте. Context — IP/устройство инициатора, показывается при подтверждении (защита от фишинга). +/// +public sealed class TelegramLoginRequest : Entity +{ + public TelegramLoginStatus Status { get; private set; } + public Guid? UserId { get; private set; } + public string? Context { get; private set; } + public DateTimeOffset CreatedAt { get; private set; } + public DateTimeOffset ExpiresAt { get; private set; } + + private TelegramLoginRequest() + { + } + + public static TelegramLoginRequest Create(TimeSpan ttl, string? context) + { + return new TelegramLoginRequest + { + Id = Guid.NewGuid(), + Status = TelegramLoginStatus.Pending, + Context = context, + CreatedAt = DateTimeOffset.UtcNow, + ExpiresAt = DateTimeOffset.UtcNow.Add(ttl), + }; + } + + public bool IsExpired => DateTimeOffset.UtcNow >= ExpiresAt; + + public void Approve(Guid userId) + { + EnsurePending(); + Status = TelegramLoginStatus.Approved; + UserId = userId; + } + + public void Reject() + { + EnsurePending(); + Status = TelegramLoginStatus.Rejected; + } + + /// Помечает выданным (после того как сайт забрал JWT по этому запросу). + public void Consume() + { + if (Status != TelegramLoginStatus.Approved) + throw new DomainException("Запрос ещё не подтверждён."); + + Status = TelegramLoginStatus.Consumed; + } + + private void EnsurePending() + { + if (IsExpired) + { + Status = TelegramLoginStatus.Expired; + throw new DomainException("Запрос на вход истёк."); + } + + if (Status != TelegramLoginStatus.Pending) + throw new DomainException("Запрос на вход уже обработан."); + } +} diff --git a/backend/src/PnvPanel.Domain/Telegram/TelegramLoginStatus.cs b/backend/src/PnvPanel.Domain/Telegram/TelegramLoginStatus.cs new file mode 100644 index 0000000..3adccb7 --- /dev/null +++ b/backend/src/PnvPanel.Domain/Telegram/TelegramLoginStatus.cs @@ -0,0 +1,10 @@ +namespace PnvPanel.Domain.Telegram; + +public enum TelegramLoginStatus +{ + Pending, + Approved, + Rejected, + Expired, + Consumed, +} diff --git a/backend/src/PnvPanel.Infrastructure/BackgroundJobs/NodeHealthCheckService.cs b/backend/src/PnvPanel.Infrastructure/BackgroundJobs/NodeHealthCheckService.cs new file mode 100644 index 0000000..a91087a --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/BackgroundJobs/NodeHealthCheckService.cs @@ -0,0 +1,55 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Domain.Nodes; +using PnvPanel.Infrastructure.Persistence; + +namespace PnvPanel.Infrastructure.BackgroundJobs; + +public sealed class NodeHealthCheckService(IServiceScopeFactory scopeFactory, ILogger logger) : BackgroundService +{ + private static readonly TimeSpan Interval = TimeSpan.FromMinutes(2); + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + using var timer = new PeriodicTimer(Interval); + do + { + try + { + await CheckAllAsync(stoppingToken); + } + catch (Exception ex) + { + logger.LogError(ex, "Ошибка health-check нод"); + } + } + while (await timer.WaitForNextTickAsync(stoppingToken)); + } + + private async Task CheckAllAsync(CancellationToken cancellationToken) + { + await using var scope = scopeFactory.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var gateway = scope.ServiceProvider.GetRequiredService(); + var notifier = scope.ServiceProvider.GetRequiredService(); + + var nodes = await dbContext.Nodes.Where(n => n.IsEnabled).ToListAsync(cancellationToken); + + foreach (var node in nodes) + { + var probe = await gateway.ProbeAsync(node, cancellationToken); + var newStatus = probe.IsReachable ? NodeStatus.Online : NodeStatus.Offline; + + if (node.Status != newStatus) + { + node.UpdateStatus(newStatus); + await notifier.NotifyNodeStatusChangedAsync(node.Id, newStatus, node.LastSyncAt, cancellationToken); + } + } + + await dbContext.SaveChangesAsync(cancellationToken); + } +} diff --git a/backend/src/PnvPanel.Infrastructure/BackgroundJobs/TrafficRetentionOptions.cs b/backend/src/PnvPanel.Infrastructure/BackgroundJobs/TrafficRetentionOptions.cs new file mode 100644 index 0000000..ddbe260 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/BackgroundJobs/TrafficRetentionOptions.cs @@ -0,0 +1,8 @@ +namespace PnvPanel.Infrastructure.BackgroundJobs; + +public sealed class TrafficRetentionOptions +{ + public const string SectionName = "TrafficRetention"; + + public int RetentionDays { get; init; } = 30; +} diff --git a/backend/src/PnvPanel.Infrastructure/BackgroundJobs/TrafficRetentionService.cs b/backend/src/PnvPanel.Infrastructure/BackgroundJobs/TrafficRetentionService.cs new file mode 100644 index 0000000..7707cc1 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/BackgroundJobs/TrafficRetentionService.cs @@ -0,0 +1,45 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using PnvPanel.Infrastructure.Persistence; + +namespace PnvPanel.Infrastructure.BackgroundJobs; + +/// TTL-чистка истории трафика (TrafficRetention__RetentionDays, по умолчанию 30 дней). +public sealed class TrafficRetentionService( + IServiceScopeFactory scopeFactory, IOptions options, ILogger logger) + : BackgroundService +{ + private static readonly TimeSpan Interval = TimeSpan.FromHours(24); + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + using var timer = new PeriodicTimer(Interval); + do + { + try + { + await CleanupAsync(stoppingToken); + } + catch (Exception ex) + { + logger.LogError(ex, "Ошибка ретеншна истории трафика"); + } + } + while (await timer.WaitForNextTickAsync(stoppingToken)); + } + + private async Task CleanupAsync(CancellationToken cancellationToken) + { + await using var scope = scopeFactory.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + var cutoff = DateTimeOffset.UtcNow.AddDays(-options.Value.RetentionDays); + var deleted = await dbContext.TrafficSamples.Where(s => s.Timestamp < cutoff).ExecuteDeleteAsync(cancellationToken); + + if (deleted > 0) + logger.LogInformation("Удалено {Count} устаревших записей истории трафика", deleted); + } +} diff --git a/backend/src/PnvPanel.Infrastructure/BackgroundJobs/TrafficSyncService.cs b/backend/src/PnvPanel.Infrastructure/BackgroundJobs/TrafficSyncService.cs new file mode 100644 index 0000000..1de9581 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/BackgroundJobs/TrafficSyncService.cs @@ -0,0 +1,79 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Domain.Configs; +using PnvPanel.Infrastructure.Persistence; + +namespace PnvPanel.Infrastructure.BackgroundJobs; + +/// +/// Обходит включённые ноды → инбаунды → активные конфиги, тянет клиентский трафик и обновляет +/// VpnConfig + пишет TrafficSample. Реконсиляция дрейфа: если панель недоступна — просто пропускаем +/// эту ноду в этом цикле, не роняем весь сервис и не трогаем локальные данные. +/// +public sealed class TrafficSyncService(IServiceScopeFactory scopeFactory, ILogger logger) : BackgroundService +{ + private static readonly TimeSpan Interval = TimeSpan.FromMinutes(5); + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + using var timer = new PeriodicTimer(Interval); + do + { + try + { + await SyncAllAsync(stoppingToken); + } + catch (Exception ex) + { + logger.LogError(ex, "Ошибка синхронизации трафика"); + } + } + while (await timer.WaitForNextTickAsync(stoppingToken)); + } + + private async Task SyncAllAsync(CancellationToken cancellationToken) + { + await using var scope = scopeFactory.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var gateway = scope.ServiceProvider.GetRequiredService(); + var notifier = scope.ServiceProvider.GetRequiredService(); + + var nodes = await dbContext.Nodes.Where(n => n.IsEnabled).ToListAsync(cancellationToken); + + foreach (var node in nodes) + { + var inbounds = await dbContext.Inbounds.Where(i => i.NodeId == node.Id).ToListAsync(cancellationToken); + + foreach (var inbound in inbounds) + { + var configs = await dbContext.VpnConfigs + .Where(c => c.InboundId == inbound.Id && c.Status == ConfigStatus.Active) + .ToListAsync(cancellationToken); + + if (configs.Count == 0) + continue; + + var trafficResult = await gateway.GetClientTrafficAsync(node, inbound.RemoteInboundId, cancellationToken); + if (!trafficResult.IsSuccess) + continue; + + foreach (var config in configs) + { + if (!trafficResult.Value.TryGetValue(config.ClientEmail, out var traffic)) + continue; + + config.UpdateTraffic(traffic.UpBytes, traffic.DownBytes); + dbContext.TrafficSamples.Add(TrafficSample.Create(config.Id, DateTimeOffset.UtcNow, traffic.UpBytes, traffic.DownBytes)); + + await notifier.NotifyConfigTrafficUpdatedAsync( + config.UserId, config.Id, traffic.UpBytes, traffic.DownBytes, cancellationToken); + } + } + } + + await dbContext.SaveChangesAsync(cancellationToken); + } +} diff --git a/backend/src/PnvPanel.Infrastructure/DependencyInjection.cs b/backend/src/PnvPanel.Infrastructure/DependencyInjection.cs index 73fb77e..180339d 100644 --- a/backend/src/PnvPanel.Infrastructure/DependencyInjection.cs +++ b/backend/src/PnvPanel.Infrastructure/DependencyInjection.cs @@ -7,9 +7,11 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.IdentityModel.Tokens; using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Infrastructure.BackgroundJobs; using PnvPanel.Infrastructure.Identity; using PnvPanel.Infrastructure.Persistence; using PnvPanel.Infrastructure.Security; +using PnvPanel.Infrastructure.Telegram; using PnvPanel.Infrastructure.Xui; using ThreeXui.ConnectionStrings; using ThreeXui.Http; @@ -66,6 +68,20 @@ public static class DependencyInjection ValidateLifetime = true, ClockSkew = TimeSpan.FromSeconds(30), }; + + // SignalR JS-клиент не может выставить заголовок Authorization на WebSocket-хендшейке — + // передаёт токен через query-string (?access_token=...), только для /hubs. + options.Events = new JwtBearerEvents + { + OnMessageReceived = context => + { + var accessToken = context.Request.Query["access_token"]; + if (!string.IsNullOrEmpty(accessToken) && context.HttpContext.Request.Path.StartsWithSegments("/hubs")) + context.Token = accessToken; + + return Task.CompletedTask; + }, + }; }); services.AddAuthorization(); @@ -94,9 +110,20 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); + // Один и тот же экземпляр CurrentUser на scope — и как ICurrentUser (чтение), и как + // ICurrentUserSetter (запись, только для Telegram-бота, см. TelegramBotHostedService). + services.AddScoped(); + services.AddScoped(sp => sp.GetRequiredService()); + services.AddScoped(sp => sp.GetRequiredService()); services.AddScoped(); + services.Configure(configuration.GetSection(TrafficRetentionOptions.SectionName)); + services.AddHostedService(); + services.AddHostedService(); + services.AddHostedService(); + + services.Configure(configuration.GetSection(TelegramOptions.SectionName)); + return services; } } diff --git a/backend/src/PnvPanel.Infrastructure/Identity/AppUser.cs b/backend/src/PnvPanel.Infrastructure/Identity/AppUser.cs index cd9f566..faf597e 100644 --- a/backend/src/PnvPanel.Infrastructure/Identity/AppUser.cs +++ b/backend/src/PnvPanel.Infrastructure/Identity/AppUser.cs @@ -12,6 +12,16 @@ public class AppUser : IdentityUser public DateTimeOffset? ActivatedAt { get; set; } public Guid? ActivatedBy { get; set; } + /// Блокировка админом: вход запрещён, все конфиги отключаются в 3x-ui (см. BlockUserCommandHandler). + public bool IsBlocked { get; set; } + /// Секрет для агрегированной подписки /sub/{token} (все активные конфиги пользователя). public string SubscriptionToken { get; set; } = string.Empty; + + /// Id пользователя Telegram; уникален; null до привязки. + public long? TelegramUserId { get; set; } + + public string? TelegramUsername { get; set; } + + public DateTimeOffset? TelegramLinkedAt { get; set; } } diff --git a/backend/src/PnvPanel.Infrastructure/Identity/CurrentUser.cs b/backend/src/PnvPanel.Infrastructure/Identity/CurrentUser.cs index e0e85d1..dea9fbc 100644 --- a/backend/src/PnvPanel.Infrastructure/Identity/CurrentUser.cs +++ b/backend/src/PnvPanel.Infrastructure/Identity/CurrentUser.cs @@ -4,20 +4,27 @@ using PnvPanel.Application.Common.Interfaces; namespace PnvPanel.Infrastructure.Identity; -internal sealed class CurrentUser(IHttpContextAccessor httpContextAccessor) : ICurrentUser +/// +/// В HTTP-запросах читает JWT-claims. В Telegram-боте (нет HttpContext) вызывающая сторона +/// заранее задаёт пользователя через ICurrentUserSetter.SetUser(...) в рамках DI-scope апдейта. +/// +internal sealed class CurrentUser(IHttpContextAccessor httpContextAccessor) : ICurrentUser, ICurrentUserSetter { + private (Guid Id, string Name)? _override; + private ClaimsPrincipal? Principal => httpContextAccessor.HttpContext?.User; - public Guid? UserId + public Guid? UserId => _override?.Id ?? ParseHttpUserId(); + + public string? UserName => _override?.Name ?? Principal?.FindFirstValue(ClaimTypes.Name); + + public bool IsAuthenticated => _override is not null || (Principal?.Identity?.IsAuthenticated ?? false); + + public void SetUser(Guid userId, string userName) => _override = (userId, userName); + + private Guid? ParseHttpUserId() { - get - { - var value = Principal?.FindFirstValue(ClaimTypes.NameIdentifier); - return Guid.TryParse(value, out var id) ? id : null; - } + var value = Principal?.FindFirstValue(ClaimTypes.NameIdentifier); + return Guid.TryParse(value, out var id) ? id : null; } - - public string? UserName => Principal?.FindFirstValue(ClaimTypes.Name); - - public bool IsAuthenticated => Principal?.Identity?.IsAuthenticated ?? false; } diff --git a/backend/src/PnvPanel.Infrastructure/Identity/IdentityService.cs b/backend/src/PnvPanel.Infrastructure/Identity/IdentityService.cs index ff9a8f3..4c6f8dd 100644 --- a/backend/src/PnvPanel.Infrastructure/Identity/IdentityService.cs +++ b/backend/src/PnvPanel.Infrastructure/Identity/IdentityService.cs @@ -39,6 +39,9 @@ internal sealed class IdentityService(UserManager userManager, SignInMa if (user is null) return Result.Failure(AuthErrors.InvalidCredentials); + if (user.IsBlocked) + return Result.Failure(AuthErrors.UserBlocked); + var checkResult = await signInManager.CheckPasswordSignInAsync(user, password, lockoutOnFailure: true); if (checkResult.IsLockedOut) return Result.Failure(AuthErrors.LockedOut); @@ -56,7 +59,7 @@ internal sealed class IdentityService(UserManager userManager, SignInMa return null; var role = await GetPrimaryRoleAsync(user); - return new CurrentUserProfile(user.Id, user.UserName!, role.Id, role.Name!, user.IsActivated, role.MaxConfigs); + return new CurrentUserProfile(user.Id, user.UserName!, role.Id, role.Name!, user.IsActivated, user.IsBlocked, role.MaxConfigs); } public async Task ChangePasswordAsync(Guid userId, string currentPassword, string newPassword, CancellationToken cancellationToken) @@ -118,6 +121,118 @@ internal sealed class IdentityService(UserManager userManager, SignInMa return user?.Id; } + public async Task BlockUserAsync(Guid userId, CancellationToken cancellationToken) + { + var user = await userManager.FindByIdAsync(userId.ToString()); + if (user is null) + return Result.Failure(AuthErrors.Unauthorized); + + user.IsBlocked = true; + await userManager.UpdateSecurityStampAsync(user); // гасит уже выданные refresh-токены де-факто — токен привязан к пользователю, не к stamp; отзыв делает вызывающий handler явно + return Result.Success(); + } + + public async Task UnblockUserAsync(Guid userId, CancellationToken cancellationToken) + { + var user = await userManager.FindByIdAsync(userId.ToString()); + if (user is null) + return Result.Failure(AuthErrors.Unauthorized); + + user.IsBlocked = false; + return Result.Success(); + } + + public async Task ResetPasswordAsync(Guid userId, string newPassword, CancellationToken cancellationToken) + { + var user = await userManager.FindByIdAsync(userId.ToString()); + if (user is null) + return Result.Failure(AuthErrors.Unauthorized); + + var token = await userManager.GeneratePasswordResetTokenAsync(user); + var result = await userManager.ResetPasswordAsync(user, token, newPassword); + + return result.Succeeded + ? Result.Success() + : Result.Failure(Error.Validation( + "Auth.PasswordResetFailed", string.Join("; ", result.Errors.Select(e => e.Description)))); + } + + public async Task> ListUsersAsync(int page, int pageSize, string? search, CancellationToken cancellationToken) + { + var query = userManager.Users.AsNoTracking(); + if (!string.IsNullOrWhiteSpace(search)) + query = query.Where(u => u.UserName!.Contains(search)); + + var total = await query.CountAsync(cancellationToken); + var users = await query + .OrderBy(u => u.UserName) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(cancellationToken); + + var items = new List(users.Count); + foreach (var user in users) + { + var roleName = await GetPrimaryRoleNameAsync(user); + items.Add(new UserSummaryDto(user.Id, user.UserName!, roleName, user.IsActivated, user.IsBlocked, user.ActivatedAt)); + } + + return new PagedList(items, total, page, pageSize); + } + + public async Task GetUserStatsAsync(CancellationToken cancellationToken) + { + var total = await userManager.Users.CountAsync(cancellationToken); + var activated = await userManager.Users.CountAsync(u => u.IsActivated, cancellationToken); + return new UserStatsDto(total, activated); + } + + public async Task LinkTelegramAsync(Guid userId, long telegramUserId, string? telegramUsername, CancellationToken cancellationToken) + { + var user = await userManager.FindByIdAsync(userId.ToString()); + if (user is null) + return Result.Failure(AuthErrors.Unauthorized); + + var alreadyLinked = await userManager.Users.AsNoTracking() + .AnyAsync(u => u.TelegramUserId == telegramUserId && u.Id != userId, cancellationToken); + if (alreadyLinked) + return Result.Failure(Error.Conflict("Telegram.AlreadyLinked", "Этот Telegram уже привязан к другому аккаунту.")); + + user.TelegramUserId = telegramUserId; + user.TelegramUsername = telegramUsername; + user.TelegramLinkedAt = DateTimeOffset.UtcNow; + + return Result.Success(); + } + + public async Task UnlinkTelegramAsync(Guid userId, CancellationToken cancellationToken) + { + var user = await userManager.FindByIdAsync(userId.ToString()); + if (user is null) + return Result.Failure(AuthErrors.Unauthorized); + + user.TelegramUserId = null; + user.TelegramUsername = null; + user.TelegramLinkedAt = null; + + return Result.Success(); + } + + public async Task FindUserIdByTelegramUserIdAsync(long telegramUserId, CancellationToken cancellationToken) + { + var user = await userManager.Users.AsNoTracking() + .FirstOrDefaultAsync(u => u.TelegramUserId == telegramUserId, cancellationToken); + return user?.Id; + } + + public async Task GetTelegramLinkInfoAsync(Guid userId, CancellationToken cancellationToken) + { + var user = await userManager.FindByIdAsync(userId.ToString()); + return user?.TelegramUserId is not null + ? new TelegramLinkInfo(true, user.TelegramUserId, user.TelegramUsername) + : new TelegramLinkInfo(false, null, null); + } + private async Task GetPrimaryRoleNameAsync(AppUser user) { var roles = await userManager.GetRolesAsync(user); diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/AppDbContext.cs b/backend/src/PnvPanel.Infrastructure/Persistence/AppDbContext.cs index 4e18d9a..f7006d1 100644 --- a/backend/src/PnvPanel.Infrastructure/Persistence/AppDbContext.cs +++ b/backend/src/PnvPanel.Infrastructure/Persistence/AppDbContext.cs @@ -3,9 +3,11 @@ using Microsoft.EntityFrameworkCore; using PnvPanel.Application.Common.Interfaces; using PnvPanel.Domain.Activation; using PnvPanel.Domain.Apps; +using PnvPanel.Domain.Audit; using PnvPanel.Domain.Configs; using PnvPanel.Domain.Inbounds; using PnvPanel.Domain.Nodes; +using PnvPanel.Domain.Telegram; using PnvPanel.Infrastructure.Identity; namespace PnvPanel.Infrastructure.Persistence; @@ -21,12 +23,20 @@ public class AppDbContext(DbContextOptions options) public DbSet ActivationRequests => Set(); + public DbSet AuditLogs => Set(); + + public DbSet TelegramLinkTokens => Set(); + + public DbSet TelegramLoginRequests => Set(); + public DbSet Nodes => Set(); public DbSet Inbounds => Set(); public DbSet VpnConfigs => Set(); + public DbSet TrafficSamples => Set(); + public DbSet ClientApps => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/AppUserConfiguration.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/AppUserConfiguration.cs new file mode 100644 index 0000000..f573a39 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/AppUserConfiguration.cs @@ -0,0 +1,15 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using PnvPanel.Infrastructure.Identity; + +namespace PnvPanel.Infrastructure.Persistence.Configurations; + +public class AppUserConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + // Уникален среди привязанных (Postgres не считает NULL равным NULL — обычный unique подходит). + builder.HasIndex(x => x.TelegramUserId).IsUnique(); + builder.HasIndex(x => x.SubscriptionToken).IsUnique(); + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/AuditLogConfiguration.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/AuditLogConfiguration.cs new file mode 100644 index 0000000..cf51f16 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/AuditLogConfiguration.cs @@ -0,0 +1,23 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using PnvPanel.Domain.Audit; + +namespace PnvPanel.Infrastructure.Persistence.Configurations; + +public class AuditLogConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("AuditLogs"); + builder.HasKey(x => x.Id); + builder.Property(x => x.Id).ValueGeneratedOnAdd(); + + builder.Property(x => x.Action).IsRequired().HasMaxLength(100); + builder.Property(x => x.TargetType).IsRequired().HasMaxLength(50); + builder.Property(x => x.TargetId).IsRequired().HasMaxLength(100); + builder.Property(x => x.Metadata).HasColumnType("jsonb"); + builder.Property(x => x.Source).HasConversion().HasMaxLength(32); + + builder.HasIndex(x => x.CreatedAt); + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/TelegramLinkTokenConfiguration.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/TelegramLinkTokenConfiguration.cs new file mode 100644 index 0000000..88858f8 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/TelegramLinkTokenConfiguration.cs @@ -0,0 +1,17 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using PnvPanel.Domain.Telegram; + +namespace PnvPanel.Infrastructure.Persistence.Configurations; + +public class TelegramLinkTokenConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("TelegramLinkTokens"); + builder.HasKey(x => x.Id); + + builder.Property(x => x.Token).IsRequired().HasMaxLength(64); + builder.HasIndex(x => x.Token).IsUnique(); + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/TelegramLoginRequestConfiguration.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/TelegramLoginRequestConfiguration.cs new file mode 100644 index 0000000..637505f --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/TelegramLoginRequestConfiguration.cs @@ -0,0 +1,17 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using PnvPanel.Domain.Telegram; + +namespace PnvPanel.Infrastructure.Persistence.Configurations; + +public class TelegramLoginRequestConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("TelegramLoginRequests"); + builder.HasKey(x => x.Id); + + builder.Property(x => x.Status).HasConversion().HasMaxLength(32); + builder.Property(x => x.Context).HasMaxLength(200); + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/TrafficSampleConfiguration.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/TrafficSampleConfiguration.cs new file mode 100644 index 0000000..f48d81d --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/TrafficSampleConfiguration.cs @@ -0,0 +1,17 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using PnvPanel.Domain.Configs; + +namespace PnvPanel.Infrastructure.Persistence.Configurations; + +public class TrafficSampleConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("TrafficSamples"); + builder.HasKey(x => x.Id); + builder.Property(x => x.Id).ValueGeneratedOnAdd(); + + builder.HasIndex(x => new { x.ConfigId, x.Timestamp }); + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701194500_AddTrafficSamples.Designer.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701194500_AddTrafficSamples.Designer.cs new file mode 100644 index 0000000..42f61fc --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701194500_AddTrafficSamples.Designer.cs @@ -0,0 +1,628 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using PnvPanel.Infrastructure.Persistence; + +#nullable disable + +namespace PnvPanel.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260701194500_AddTrafficSamples")] + partial class AddTrafficSamples + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Activation.ActivationRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Comment") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecidedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecidedBy") + .HasColumnType("uuid"); + + b.Property("RejectionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Status"); + + b.ToTable("ActivationRequests", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Apps.ClientApp", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Description") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("DownloadUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IconUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OperatingSystem") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("ClientApps", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Configs.TrafficSample", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConfigId") + .HasColumnType("uuid"); + + b.Property("DownBytes") + .HasColumnType("bigint"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UpBytes") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("ConfigId", "Timestamp"); + + b.ToTable("TrafficSamples", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Configs.VpnConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClientEmail") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ClientExternalId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeviceLimit") + .HasColumnType("integer"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InboundId") + .HasColumnType("uuid"); + + b.Property("Label") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SubscriptionToken") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UsedDownBytes") + .HasColumnType("bigint"); + + b.Property("UsedUpBytes") + .HasColumnType("bigint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("InboundId"); + + b.HasIndex("SubscriptionToken") + .IsUnique(); + + b.HasIndex("UserId", "Status"); + + b.ToTable("VpnConfigs", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Inbounds.Inbound", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.PrimitiveCollection("AllowedRoleIds") + .IsRequired() + .HasColumnType("uuid[]"); + + b.Property("DisplayName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsPublished") + .HasColumnType("boolean"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MaxClients") + .HasColumnType("integer"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.Property("Port") + .HasColumnType("integer"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Remark") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RemoteInboundId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("NodeId", "RemoteInboundId") + .IsUnique(); + + b.ToTable("Inbounds", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Nodes.Node", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BaseAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Location") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.ToTable("Nodes", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("MaxConfigs") + .HasColumnType("integer"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ActivatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ActivatedBy") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsActivated") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("SubscriptionToken") + .IsRequired() + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PnvPanel.Domain.Nodes.Node", b => + { + b.OwnsOne("PnvPanel.Domain.Nodes.NodeCredentials", "Credentials", b1 => + { + b1.Property("NodeId") + .HasColumnType("uuid"); + + b1.Property("ProtectedPassword") + .IsRequired() + .HasColumnType("text") + .HasColumnName("CredentialsProtectedPassword"); + + b1.Property("Username") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("CredentialsUsername"); + + b1.HasKey("NodeId"); + + b1.ToTable("Nodes"); + + b1.WithOwner() + .HasForeignKey("NodeId"); + }); + + b.Navigation("Credentials") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701194500_AddTrafficSamples.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701194500_AddTrafficSamples.cs new file mode 100644 index 0000000..f59b935 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701194500_AddTrafficSamples.cs @@ -0,0 +1,44 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace PnvPanel.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddTrafficSamples : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "TrafficSamples", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + ConfigId = table.Column(type: "uuid", nullable: false), + Timestamp = table.Column(type: "timestamp with time zone", nullable: false), + UpBytes = table.Column(type: "bigint", nullable: false), + DownBytes = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TrafficSamples", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_TrafficSamples_ConfigId_Timestamp", + table: "TrafficSamples", + columns: new[] { "ConfigId", "Timestamp" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "TrafficSamples"); + } + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701195456_AddAuditAndBlocking.Designer.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701195456_AddAuditAndBlocking.Designer.cs new file mode 100644 index 0000000..ea865f1 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701195456_AddAuditAndBlocking.Designer.cs @@ -0,0 +1,675 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using PnvPanel.Infrastructure.Persistence; + +#nullable disable + +namespace PnvPanel.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260701195456_AddAuditAndBlocking")] + partial class AddAuditAndBlocking + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Activation.ActivationRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Comment") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecidedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecidedBy") + .HasColumnType("uuid"); + + b.Property("RejectionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Status"); + + b.ToTable("ActivationRequests", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Apps.ClientApp", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Description") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("DownloadUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IconUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OperatingSystem") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("ClientApps", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Audit.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ActorId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Metadata") + .HasColumnType("jsonb"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TargetId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.ToTable("AuditLogs", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Configs.TrafficSample", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConfigId") + .HasColumnType("uuid"); + + b.Property("DownBytes") + .HasColumnType("bigint"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UpBytes") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("ConfigId", "Timestamp"); + + b.ToTable("TrafficSamples", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Configs.VpnConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClientEmail") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ClientExternalId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeviceLimit") + .HasColumnType("integer"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InboundId") + .HasColumnType("uuid"); + + b.Property("Label") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SubscriptionToken") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UsedDownBytes") + .HasColumnType("bigint"); + + b.Property("UsedUpBytes") + .HasColumnType("bigint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("InboundId"); + + b.HasIndex("SubscriptionToken") + .IsUnique(); + + b.HasIndex("UserId", "Status"); + + b.ToTable("VpnConfigs", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Inbounds.Inbound", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.PrimitiveCollection("AllowedRoleIds") + .IsRequired() + .HasColumnType("uuid[]"); + + b.Property("DisplayName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsPublished") + .HasColumnType("boolean"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MaxClients") + .HasColumnType("integer"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.Property("Port") + .HasColumnType("integer"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Remark") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RemoteInboundId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("NodeId", "RemoteInboundId") + .IsUnique(); + + b.ToTable("Inbounds", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Nodes.Node", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BaseAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Location") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.ToTable("Nodes", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("MaxConfigs") + .HasColumnType("integer"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ActivatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ActivatedBy") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsActivated") + .HasColumnType("boolean"); + + b.Property("IsBlocked") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("SubscriptionToken") + .IsRequired() + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PnvPanel.Domain.Nodes.Node", b => + { + b.OwnsOne("PnvPanel.Domain.Nodes.NodeCredentials", "Credentials", b1 => + { + b1.Property("NodeId") + .HasColumnType("uuid"); + + b1.Property("ProtectedPassword") + .IsRequired() + .HasColumnType("text") + .HasColumnName("CredentialsProtectedPassword"); + + b1.Property("Username") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("CredentialsUsername"); + + b1.HasKey("NodeId"); + + b1.ToTable("Nodes"); + + b1.WithOwner() + .HasForeignKey("NodeId"); + }); + + b.Navigation("Credentials") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701195456_AddAuditAndBlocking.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701195456_AddAuditAndBlocking.cs new file mode 100644 index 0000000..915f6e8 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701195456_AddAuditAndBlocking.cs @@ -0,0 +1,58 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace PnvPanel.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddAuditAndBlocking : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsBlocked", + table: "AspNetUsers", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.CreateTable( + name: "AuditLogs", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + ActorId = table.Column(type: "uuid", nullable: true), + Action = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + TargetType = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + TargetId = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Metadata = table.Column(type: "jsonb", nullable: true), + Source = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AuditLogs", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_AuditLogs_CreatedAt", + table: "AuditLogs", + column: "CreatedAt"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AuditLogs"); + + migrationBuilder.DropColumn( + name: "IsBlocked", + table: "AspNetUsers"); + } + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701205836_AddTelegram.Designer.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701205836_AddTelegram.Designer.cs new file mode 100644 index 0000000..523c8e8 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701205836_AddTelegram.Designer.cs @@ -0,0 +1,747 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using PnvPanel.Infrastructure.Persistence; + +#nullable disable + +namespace PnvPanel.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260701205836_AddTelegram")] + partial class AddTelegram + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Activation.ActivationRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Comment") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecidedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecidedBy") + .HasColumnType("uuid"); + + b.Property("RejectionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Status"); + + b.ToTable("ActivationRequests", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Apps.ClientApp", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Description") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("DownloadUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IconUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OperatingSystem") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("ClientApps", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Audit.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ActorId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Metadata") + .HasColumnType("jsonb"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TargetId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.ToTable("AuditLogs", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Configs.TrafficSample", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConfigId") + .HasColumnType("uuid"); + + b.Property("DownBytes") + .HasColumnType("bigint"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UpBytes") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("ConfigId", "Timestamp"); + + b.ToTable("TrafficSamples", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Configs.VpnConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClientEmail") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ClientExternalId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeviceLimit") + .HasColumnType("integer"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InboundId") + .HasColumnType("uuid"); + + b.Property("Label") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SubscriptionToken") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UsedDownBytes") + .HasColumnType("bigint"); + + b.Property("UsedUpBytes") + .HasColumnType("bigint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("InboundId"); + + b.HasIndex("SubscriptionToken") + .IsUnique(); + + b.HasIndex("UserId", "Status"); + + b.ToTable("VpnConfigs", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Inbounds.Inbound", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.PrimitiveCollection("AllowedRoleIds") + .IsRequired() + .HasColumnType("uuid[]"); + + b.Property("DisplayName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsPublished") + .HasColumnType("boolean"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MaxClients") + .HasColumnType("integer"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.Property("Port") + .HasColumnType("integer"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Remark") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RemoteInboundId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("NodeId", "RemoteInboundId") + .IsUnique(); + + b.ToTable("Inbounds", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Nodes.Node", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BaseAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Location") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.ToTable("Nodes", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Telegram.TelegramLinkToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.ToTable("TelegramLinkTokens", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Telegram.TelegramLoginRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Context") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("TelegramLoginRequests", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("MaxConfigs") + .HasColumnType("integer"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ActivatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ActivatedBy") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsActivated") + .HasColumnType("boolean"); + + b.Property("IsBlocked") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("SubscriptionToken") + .IsRequired() + .HasColumnType("text"); + + b.Property("TelegramLinkedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TelegramUserId") + .HasColumnType("bigint"); + + b.Property("TelegramUsername") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.HasIndex("SubscriptionToken") + .IsUnique(); + + b.HasIndex("TelegramUserId") + .IsUnique(); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PnvPanel.Domain.Nodes.Node", b => + { + b.OwnsOne("PnvPanel.Domain.Nodes.NodeCredentials", "Credentials", b1 => + { + b1.Property("NodeId") + .HasColumnType("uuid"); + + b1.Property("ProtectedPassword") + .IsRequired() + .HasColumnType("text") + .HasColumnName("CredentialsProtectedPassword"); + + b1.Property("Username") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("CredentialsUsername"); + + b1.HasKey("NodeId"); + + b1.ToTable("Nodes"); + + b1.WithOwner() + .HasForeignKey("NodeId"); + }); + + b.Navigation("Credentials") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701205836_AddTelegram.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701205836_AddTelegram.cs new file mode 100644 index 0000000..a6a8e14 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701205836_AddTelegram.cs @@ -0,0 +1,112 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PnvPanel.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddTelegram : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "TelegramLinkedAt", + table: "AspNetUsers", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "TelegramUserId", + table: "AspNetUsers", + type: "bigint", + nullable: true); + + migrationBuilder.AddColumn( + name: "TelegramUsername", + table: "AspNetUsers", + type: "text", + nullable: true); + + migrationBuilder.CreateTable( + name: "TelegramLinkTokens", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Token = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + ExpiresAt = table.Column(type: "timestamp with time zone", nullable: false), + ConsumedAt = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_TelegramLinkTokens", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "TelegramLoginRequests", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Status = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + UserId = table.Column(type: "uuid", nullable: true), + Context = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + ExpiresAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TelegramLoginRequests", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUsers_SubscriptionToken", + table: "AspNetUsers", + column: "SubscriptionToken", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUsers_TelegramUserId", + table: "AspNetUsers", + column: "TelegramUserId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_TelegramLinkTokens_Token", + table: "TelegramLinkTokens", + column: "Token", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "TelegramLinkTokens"); + + migrationBuilder.DropTable( + name: "TelegramLoginRequests"); + + migrationBuilder.DropIndex( + name: "IX_AspNetUsers_SubscriptionToken", + table: "AspNetUsers"); + + migrationBuilder.DropIndex( + name: "IX_AspNetUsers_TelegramUserId", + table: "AspNetUsers"); + + migrationBuilder.DropColumn( + name: "TelegramLinkedAt", + table: "AspNetUsers"); + + migrationBuilder.DropColumn( + name: "TelegramUserId", + table: "AspNetUsers"); + + migrationBuilder.DropColumn( + name: "TelegramUsername", + table: "AspNetUsers"); + } + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs index 6bb2827..cb12a07 100644 --- a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs @@ -203,6 +203,77 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations b.ToTable("ClientApps", (string)null); }); + modelBuilder.Entity("PnvPanel.Domain.Audit.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ActorId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Metadata") + .HasColumnType("jsonb"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TargetId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.ToTable("AuditLogs", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Configs.TrafficSample", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConfigId") + .HasColumnType("uuid"); + + b.Property("DownBytes") + .HasColumnType("bigint"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UpBytes") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("ConfigId", "Timestamp"); + + b.ToTable("TrafficSamples", (string)null); + }); + modelBuilder.Entity("PnvPanel.Domain.Configs.VpnConfig", b => { b.Property("Id") @@ -365,6 +436,63 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations b.ToTable("Nodes", (string)null); }); + modelBuilder.Entity("PnvPanel.Domain.Telegram.TelegramLinkToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.ToTable("TelegramLinkTokens", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Telegram.TelegramLoginRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Context") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("TelegramLoginRequests", (string)null); + }); + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppRole", b => { b.Property("Id") @@ -427,6 +555,9 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations b.Property("IsActivated") .HasColumnType("boolean"); + b.Property("IsBlocked") + .HasColumnType("boolean"); + b.Property("LockoutEnabled") .HasColumnType("boolean"); @@ -457,6 +588,15 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations .IsRequired() .HasColumnType("text"); + b.Property("TelegramLinkedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TelegramUserId") + .HasColumnType("bigint"); + + b.Property("TelegramUsername") + .HasColumnType("text"); + b.Property("TwoFactorEnabled") .HasColumnType("boolean"); @@ -473,6 +613,12 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations .IsUnique() .HasDatabaseName("UserNameIndex"); + b.HasIndex("SubscriptionToken") + .IsUnique(); + + b.HasIndex("TelegramUserId") + .IsUnique(); + b.ToTable("AspNetUsers", (string)null); }); diff --git a/backend/src/PnvPanel.Infrastructure/Telegram/TelegramOptions.cs b/backend/src/PnvPanel.Infrastructure/Telegram/TelegramOptions.cs new file mode 100644 index 0000000..3d9e490 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Telegram/TelegramOptions.cs @@ -0,0 +1,26 @@ +namespace PnvPanel.Infrastructure.Telegram; + +public sealed class TelegramOptions +{ + public const string SectionName = "Telegram"; + + public string? BotToken { get; init; } + public string? BotUsername { get; init; } + public string PublicSiteUrl { get; init; } = string.Empty; + + /// Telegram id админов (через запятую) — авторизуют админ-кнопки в боте. + public string? AdminTelegramUserIds { get; init; } + + public IReadOnlyCollection ParseAdminTelegramUserIds() + { + if (string.IsNullOrWhiteSpace(AdminTelegramUserIds)) + return []; + + return AdminTelegramUserIds + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(s => long.TryParse(s, out var id) ? id : (long?)null) + .Where(id => id.HasValue) + .Select(id => id!.Value) + .ToList(); + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Xui/XuiPanelGateway.cs b/backend/src/PnvPanel.Infrastructure/Xui/XuiPanelGateway.cs index 808247c..afbf834 100644 --- a/backend/src/PnvPanel.Infrastructure/Xui/XuiPanelGateway.cs +++ b/backend/src/PnvPanel.Infrastructure/Xui/XuiPanelGateway.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using System.Text.Json; using Microsoft.Extensions.Logging; using PnvPanel.Application.Common.Interfaces; using PnvPanel.Application.Common.Models; @@ -149,6 +150,67 @@ internal sealed class XuiPanelGateway( } } + public async Task>> GetClientTrafficAsync( + Node node, string inboundRemoteId, CancellationToken cancellationToken) + { + try + { + var client = GetClient(node); + var remoteInbound = await client.GetInboundAsync(inboundRemoteId, cancellationToken); + if (remoteInbound is null) + { + return Result.Failure>( + Error.Failure("Xui.InboundNotFound", "Inbound не найден на панели.")); + } + + return Result.Success(ParseClientStats(remoteInbound.RawInboundJson)); + } + catch (Exception ex) + { + return Result.Failure>( + Error.Failure("Xui.TrafficFetchFailed", $"Не удалось получить трафик: {ex.Message}")); + } + } + + /// + /// ThreeXui.Net типизированно не отдаёт трафик по клиентам — достаём его из сырого JSON инбаунда: + /// стандартное поле 3x-ui API "clientStats": [{ "email": "...", "up": N, "down": N }, ...]. + /// Формат форка может отличаться — при ошибке парсинга просто возвращаем пусто, не валим синхронизацию. + /// + private static IReadOnlyDictionary ParseClientStats(string? rawInboundJson) + { + var result = new Dictionary(); + if (string.IsNullOrWhiteSpace(rawInboundJson)) + return result; + + try + { + using var doc = JsonDocument.Parse(rawInboundJson); + if (!doc.RootElement.TryGetProperty("clientStats", out var clientStats) || clientStats.ValueKind != JsonValueKind.Array) + return result; + + foreach (var stat in clientStats.EnumerateArray()) + { + if (!stat.TryGetProperty("email", out var emailProp) || emailProp.ValueKind != JsonValueKind.String) + continue; + + var email = emailProp.GetString(); + if (string.IsNullOrEmpty(email)) + continue; + + var up = stat.TryGetProperty("up", out var upProp) ? upProp.GetInt64() : 0; + var down = stat.TryGetProperty("down", out var downProp) ? downProp.GetInt64() : 0; + result[email] = new ClientTrafficInfo(up, down); + } + } + catch (JsonException) + { + // Не удалось распарсить — возвращаем пусто, вызывающий код просто пропустит синк для этого инбаунда. + } + + return result; + } + public void InvalidateClient(Guid nodeId) { if (_clients.TryRemove(nodeId, out var lazy) && lazy.IsValueCreated) diff --git a/backend/tests/PnvPanel.Application.Tests/Activation/ApproveActivationCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Activation/ApproveActivationCommandHandlerTests.cs new file mode 100644 index 0000000..b8612a5 --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/Activation/ApproveActivationCommandHandlerTests.cs @@ -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(); + private readonly IRealtimeNotifier _notifier = Substitute.For(); + private readonly ICurrentUser _currentUser = Substitute.For(); + + [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()).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()); + } + + [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()).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(), Arg.Any()); + } +} diff --git a/backend/tests/PnvPanel.Application.Tests/Activation/RejectActivationCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Activation/RejectActivationCommandHandlerTests.cs new file mode 100644 index 0000000..c5c5d81 --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/Activation/RejectActivationCommandHandlerTests.cs @@ -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(); + + [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); + } +} diff --git a/backend/tests/PnvPanel.Application.Tests/Activation/RequestActivationCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Activation/RequestActivationCommandHandlerTests.cs new file mode 100644 index 0000000..e8d8680 --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/Activation/RequestActivationCommandHandlerTests.cs @@ -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(); + private readonly ITelegramNotifier _telegramNotifier = Substitute.For(); + private readonly ICurrentUser _currentUser = Substitute.For(); + + [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(), userId, "alice", "Please activate", Arg.Any(), Arg.Any()); + await _telegramNotifier.Received(1).NotifyAdminsActivationRequestedAsync( + Arg.Any(), "alice", "Please activate", Arg.Any()); + } + + [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(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [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); + } +} diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Inbounds/PublishInboundCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Inbounds/PublishInboundCommandHandlerTests.cs new file mode 100644 index 0000000..0f443a8 --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Inbounds/PublishInboundCommandHandlerTests.cs @@ -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); + } +} diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Nodes/RegisterNodeCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Nodes/RegisterNodeCommandHandlerTests.cs new file mode 100644 index 0000000..d3b9400 --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Nodes/RegisterNodeCommandHandlerTests.cs @@ -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(); + private readonly ISecretProtector _secretProtector = Substitute.For(); + + [Fact] + public async Task Handle_WithValidAddress_RegistersNodeWithProtectedPassword() + { + using var dbContext = InMemoryDbContextFactory.Create(); + + _gateway.ValidateBaseAddress(Arg.Any()).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()); + 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()).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); + } +} diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Users/BlockUserCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Users/BlockUserCommandHandlerTests.cs new file mode 100644 index 0000000..9ab50f3 --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Users/BlockUserCommandHandlerTests.cs @@ -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(); + private readonly IXuiPanelGateway _gateway = Substitute.For(); + private readonly IRealtimeNotifier _notifier = Substitute.For(); + private readonly ICurrentUser _currentUser = Substitute.For(); + + [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()).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(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [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()).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(n => n.Id == node.Id), inbound.RemoteInboundId, config.ClientExternalId, config.Protocol, + "my-config", config.DeviceLimit, enable: false, Arg.Any()); + await _notifier.Received(1).NotifyConfigStatusChangedAsync(userId, config.Id, ConfigStatus.Disabled, Arg.Any()); + + 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()).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(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } +} diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Users/ChangeUserRoleCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Users/ChangeUserRoleCommandHandlerTests.cs new file mode 100644 index 0000000..73aac58 --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Users/ChangeUserRoleCommandHandlerTests.cs @@ -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(); + + [Fact] + public async Task Handle_DelegatesToRoleServiceAndReturnsSuccess() + { + var userId = Guid.NewGuid(); + var roleId = Guid.NewGuid(); + _roleService.ChangeUserRoleAsync(userId, roleId, Arg.Any()).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()); + } + + [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()).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); + } +} diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Users/UnblockUserCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Users/UnblockUserCommandHandlerTests.cs new file mode 100644 index 0000000..47b651d --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Users/UnblockUserCommandHandlerTests.cs @@ -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(); + private readonly IXuiPanelGateway _gateway = Substitute.For(); + private readonly IRealtimeNotifier _notifier = Substitute.For(); + private readonly ICurrentUser _currentUser = Substitute.For(); + + [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()).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(), config.DeviceLimit, true, Arg.Any()); + await _notifier.Received(1).NotifyConfigStatusChangedAsync(userId, config.Id, ConfigStatus.Active, Arg.Any()); + 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()).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); + } +} diff --git a/backend/tests/PnvPanel.Application.Tests/Auth/ChangePasswordCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Auth/ChangePasswordCommandHandlerTests.cs new file mode 100644 index 0000000..0dfa32d --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/Auth/ChangePasswordCommandHandlerTests.cs @@ -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(); + + [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(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_Authenticated_DelegatesToIdentityService() + { + var userId = Guid.NewGuid(); + _identityService.ChangePasswordAsync(userId, "old-pass", "new-pass", Arg.Any()) + .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()); + } + + [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()) + .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); + } +} diff --git a/backend/tests/PnvPanel.Application.Tests/Auth/GetCurrentUserQueryHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Auth/GetCurrentUserQueryHandlerTests.cs new file mode 100644 index 0000000..a5656ff --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/Auth/GetCurrentUserQueryHandlerTests.cs @@ -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(); + + [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()).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()).Returns(profile); + _identityService.GetTelegramLinkInfoAsync(userId, Arg.Any()) + .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); + } +} diff --git a/backend/tests/PnvPanel.Application.Tests/Auth/LoginCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Auth/LoginCommandHandlerTests.cs new file mode 100644 index 0000000..bdd8b09 --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/Auth/LoginCommandHandlerTests.cs @@ -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(); + private readonly IJwtTokenService _jwtTokenService = Substitute.For(); + private readonly IRefreshTokenService _refreshTokenService = Substitute.For(); + + 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()) + .Returns(Result.Success(authUser)); + _identityService.GetProfileAsync(userId, Arg.Any()).Returns(profile); + _identityService.GetTelegramLinkInfoAsync(userId, Arg.Any()) + .Returns(new TelegramLinkInfo(true, 123456, "alice_tg")); + _jwtTokenService.GenerateAccessToken(Arg.Any()) + .Returns(("access-token", DateTimeOffset.UtcNow.AddMinutes(15))); + _refreshTokenService.IssueAsync(userId, Arg.Any()) + .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()) + .Returns(Result.Failure(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(), Arg.Any()); + } + + [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()) + .Returns(Result.Success(authUser)); + _identityService.GetProfileAsync(userId, Arg.Any()).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); + } +} diff --git a/backend/tests/PnvPanel.Application.Tests/Auth/RefreshCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Auth/RefreshCommandHandlerTests.cs new file mode 100644 index 0000000..60ff8ac --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/Auth/RefreshCommandHandlerTests.cs @@ -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(); + private readonly IJwtTokenService _jwtTokenService = Substitute.For(); + private readonly IRefreshTokenService _refreshTokenService = Substitute.For(); + + 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()).Returns(Result.Success(rotated)); + _identityService.GetProfileAsync(userId, Arg.Any()).Returns(profile); + _identityService.GetTelegramLinkInfoAsync(userId, Arg.Any()) + .Returns(new TelegramLinkInfo(false, null, null)); + _jwtTokenService.GenerateAccessToken(Arg.Any()) + .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()) + .Returns(Result.Failure(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(), Arg.Any()); + } + + [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()).Returns(Result.Success(rotated)); + _identityService.GetProfileAsync(userId, Arg.Any()).Returns((CurrentUserProfile?)null); + + var result = await CreateHandler().Handle(new RefreshCommand("old-token"), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal(AuthErrors.InvalidRefreshToken, result.Error); + } +} diff --git a/backend/tests/PnvPanel.Application.Tests/Configs/GetMyConfigs/GetMyConfigsQueryHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Configs/GetMyConfigs/GetMyConfigsQueryHandlerTests.cs new file mode 100644 index 0000000..101aa99 --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/Configs/GetMyConfigs/GetMyConfigsQueryHandlerTests.cs @@ -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(); + + [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()).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()).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); + } +} diff --git a/backend/tests/PnvPanel.Application.Tests/Configs/Revoke/RevokeVpnConfigCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Configs/Revoke/RevokeVpnConfigCommandHandlerTests.cs new file mode 100644 index 0000000..0056c89 --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/Configs/Revoke/RevokeVpnConfigCommandHandlerTests.cs @@ -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(); + private readonly IRealtimeNotifier _notifier = Substitute.For(); + + [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(), inbound.RemoteInboundId, "external-id", config.Protocol, Arg.Any()); + await _notifier.Received(1).NotifyConfigStatusChangedAsync(userId, config.Id, ConfigStatus.Revoked, Arg.Any()); + } + + [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(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [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); + } +} diff --git a/backend/tests/PnvPanel.Application.Tests/Configs/Rotate/RotateVpnConfigCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Configs/Rotate/RotateVpnConfigCommandHandlerTests.cs new file mode 100644 index 0000000..785f46f --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/Configs/Rotate/RotateVpnConfigCommandHandlerTests.cs @@ -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(); + + [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(), inbound.RemoteInboundId, config.Protocol, Arg.Any(), + Arg.Any(), config.DeviceLimit, Arg.Any()) + .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(), inbound.RemoteInboundId, "old-external-id", config.Protocol, Arg.Any()); + } + + [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(), inbound.RemoteInboundId, config.Protocol, Arg.Any(), + Arg.Any(), config.DeviceLimit, Arg.Any()) + .Returns(Result.Failure(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); + } +} diff --git a/backend/tests/PnvPanel.Application.Tests/PnvPanel.Application.Tests.csproj b/backend/tests/PnvPanel.Application.Tests/PnvPanel.Application.Tests.csproj new file mode 100644 index 0000000..9703d6d --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/PnvPanel.Application.Tests.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + enable + enable + false + false + false + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + diff --git a/backend/tests/PnvPanel.Application.Tests/Telegram/Bot/ApproveTelegramLoginCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Telegram/Bot/ApproveTelegramLoginCommandHandlerTests.cs new file mode 100644 index 0000000..51bc93a --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/Telegram/Bot/ApproveTelegramLoginCommandHandlerTests.cs @@ -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(); + + [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()).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()) + .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()).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()).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()).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); + } +} diff --git a/backend/tests/PnvPanel.Application.Tests/Telegram/Bot/LinkTelegramCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Telegram/Bot/LinkTelegramCommandHandlerTests.cs new file mode 100644 index 0000000..358631b --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/Telegram/Bot/LinkTelegramCommandHandlerTests.cs @@ -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(); + + [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()) + .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(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [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()) + .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); + } +} diff --git a/backend/tests/PnvPanel.Application.Tests/Telegram/Bot/RejectTelegramLoginCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Telegram/Bot/RejectTelegramLoginCommandHandlerTests.cs new file mode 100644 index 0000000..b1b632b --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/Telegram/Bot/RejectTelegramLoginCommandHandlerTests.cs @@ -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(); + + [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()).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()) + .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()).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()).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); + } +} diff --git a/backend/tests/PnvPanel.Application.Tests/Telegram/CreateLinkTokenCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Telegram/CreateLinkTokenCommandHandlerTests.cs new file mode 100644 index 0000000..7c492dc --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/Telegram/CreateLinkTokenCommandHandlerTests.cs @@ -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); + } +} diff --git a/backend/tests/PnvPanel.Application.Tests/Telegram/GetLoginRequestStatusQueryHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Telegram/GetLoginRequestStatusQueryHandlerTests.cs new file mode 100644 index 0000000..1a0e0e5 --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/Telegram/GetLoginRequestStatusQueryHandlerTests.cs @@ -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(); + private readonly IJwtTokenService _jwtTokenService = Substitute.For(); + private readonly IRefreshTokenService _refreshTokenService = Substitute.For(); + + 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(), Arg.Any()); + } + + [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()).Returns(profile); + _jwtTokenService.GenerateAccessToken(Arg.Any()) + .Returns(("access-token", DateTimeOffset.UtcNow.AddMinutes(15))); + _refreshTokenService.IssueAsync(userId, Arg.Any()) + .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()).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); + } +} diff --git a/backend/tests/PnvPanel.Application.Tests/Telegram/UnlinkTelegramCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Telegram/UnlinkTelegramCommandHandlerTests.cs new file mode 100644 index 0000000..7b440f3 --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/Telegram/UnlinkTelegramCommandHandlerTests.cs @@ -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(); + + [Fact] + public async Task Handle_WhenAuthenticated_DelegatesToIdentityService() + { + var userId = Guid.NewGuid(); + _identityService.UnlinkTelegramAsync(userId, Arg.Any()).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()); + } + + [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(), Arg.Any()); + } + + [Fact] + public async Task Handle_WhenIdentityServiceFails_PropagatesFailure() + { + var userId = Guid.NewGuid(); + var error = TelegramErrors.NotLinked; + _identityService.UnlinkTelegramAsync(userId, Arg.Any()).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); + } +} diff --git a/backend/tests/PnvPanel.Application.Tests/TestSupport/FakeCurrentUser.cs b/backend/tests/PnvPanel.Application.Tests/TestSupport/FakeCurrentUser.cs new file mode 100644 index 0000000..c912143 --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/TestSupport/FakeCurrentUser.cs @@ -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(); +} diff --git a/backend/tests/PnvPanel.Application.Tests/TestSupport/InMemoryDbContextFactory.cs b/backend/tests/PnvPanel.Application.Tests/TestSupport/InMemoryDbContextFactory.cs new file mode 100644 index 0000000..6ad878b --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/TestSupport/InMemoryDbContextFactory.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Infrastructure.Persistence; + +namespace PnvPanel.Application.Tests.TestSupport; + +/// +/// EF Core InMemory provider, не Sqlite/Npgsql — модель использует Postgres-специфичные типы +/// (uuid[] на Inbound.AllowedRoleIds, jsonb на AuditLog.Metadata), не имеющие реляционных +/// аналогов. InMemory игнорирует HasColumnType и не требует их маппинга. +/// +public static class InMemoryDbContextFactory +{ + public static AppDbContext Create() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + return new AppDbContext(options); + } +} diff --git a/backend/tests/PnvPanel.Domain.Tests/Activation/ActivationRequestTests.cs b/backend/tests/PnvPanel.Domain.Tests/Activation/ActivationRequestTests.cs new file mode 100644 index 0000000..7ee1c0d --- /dev/null +++ b/backend/tests/PnvPanel.Domain.Tests/Activation/ActivationRequestTests.cs @@ -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(() => request.Approve(Guid.NewGuid())); + } + + [Fact] + public void Reject_WhenAlreadyRejected_Throws() + { + var request = ActivationRequest.Create(Guid.NewGuid(), null); + request.Reject(Guid.NewGuid(), null); + + Assert.Throws(() => request.Reject(Guid.NewGuid(), null)); + } + + [Fact] + public void Reject_WhenAlreadyApproved_Throws() + { + var request = ActivationRequest.Create(Guid.NewGuid(), null); + request.Approve(Guid.NewGuid()); + + Assert.Throws(() => request.Reject(Guid.NewGuid(), null)); + } +} diff --git a/backend/tests/PnvPanel.Domain.Tests/Apps/ClientAppTests.cs b/backend/tests/PnvPanel.Domain.Tests/Apps/ClientAppTests.cs new file mode 100644 index 0000000..6f37162 --- /dev/null +++ b/backend/tests/PnvPanel.Domain.Tests/Apps/ClientAppTests.cs @@ -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); + } +} diff --git a/backend/tests/PnvPanel.Domain.Tests/Audit/AuditLogTests.cs b/backend/tests/PnvPanel.Domain.Tests/Audit/AuditLogTests.cs new file mode 100644 index 0000000..4c48541 --- /dev/null +++ b/backend/tests/PnvPanel.Domain.Tests/Audit/AuditLogTests.cs @@ -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); + } +} diff --git a/backend/tests/PnvPanel.Domain.Tests/Common/EntityTests.cs b/backend/tests/PnvPanel.Domain.Tests/Common/EntityTests.cs new file mode 100644 index 0000000..e57e9f1 --- /dev/null +++ b/backend/tests/PnvPanel.Domain.Tests/Common/EntityTests.cs @@ -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()); + } +} diff --git a/backend/tests/PnvPanel.Domain.Tests/Configs/TrafficSampleTests.cs b/backend/tests/PnvPanel.Domain.Tests/Configs/TrafficSampleTests.cs new file mode 100644 index 0000000..1adeb42 --- /dev/null +++ b/backend/tests/PnvPanel.Domain.Tests/Configs/TrafficSampleTests.cs @@ -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); + } +} diff --git a/backend/tests/PnvPanel.Domain.Tests/Configs/VpnConfigTests.cs b/backend/tests/PnvPanel.Domain.Tests/Configs/VpnConfigTests.cs new file mode 100644 index 0000000..388cf10 --- /dev/null +++ b/backend/tests/PnvPanel.Domain.Tests/Configs/VpnConfigTests.cs @@ -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(() => 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(() => 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; + } + } +} diff --git a/backend/tests/PnvPanel.Domain.Tests/Inbounds/InboundTests.cs b/backend/tests/PnvPanel.Domain.Tests/Inbounds/InboundTests.cs new file mode 100644 index 0000000..2dfeb19 --- /dev/null +++ b/backend/tests/PnvPanel.Domain.Tests/Inbounds/InboundTests.cs @@ -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); + } +} diff --git a/backend/tests/PnvPanel.Domain.Tests/Nodes/NodeTests.cs b/backend/tests/PnvPanel.Domain.Tests/Nodes/NodeTests.cs new file mode 100644 index 0000000..b44a147 --- /dev/null +++ b/backend/tests/PnvPanel.Domain.Tests/Nodes/NodeTests.cs @@ -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(() => 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); + } +} diff --git a/backend/tests/PnvPanel.Domain.Tests/PnvPanel.Domain.Tests.csproj b/backend/tests/PnvPanel.Domain.Tests/PnvPanel.Domain.Tests.csproj new file mode 100644 index 0000000..ba43054 --- /dev/null +++ b/backend/tests/PnvPanel.Domain.Tests/PnvPanel.Domain.Tests.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + enable + enable + false + + false + false + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/backend/tests/PnvPanel.Domain.Tests/Telegram/TelegramLinkTokenTests.cs b/backend/tests/PnvPanel.Domain.Tests/Telegram/TelegramLinkTokenTests.cs new file mode 100644 index 0000000..0190d5b --- /dev/null +++ b/backend/tests/PnvPanel.Domain.Tests/Telegram/TelegramLinkTokenTests.cs @@ -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(() => token.Consume()); + } + + [Fact] + public void Consume_WhenExpired_Throws() + { + var token = TelegramLinkToken.Create(Guid.NewGuid(), TimeSpan.Zero); + + Assert.False(token.IsValid); + Assert.Throws(() => token.Consume()); + } +} diff --git a/backend/tests/PnvPanel.Domain.Tests/Telegram/TelegramLoginRequestTests.cs b/backend/tests/PnvPanel.Domain.Tests/Telegram/TelegramLoginRequestTests.cs new file mode 100644 index 0000000..51b3367 --- /dev/null +++ b/backend/tests/PnvPanel.Domain.Tests/Telegram/TelegramLoginRequestTests.cs @@ -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(() => 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(() => 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(() => request.Consume()); + } + + [Fact] + public void Consume_WhenAlreadyConsumed_Throws() + { + var request = TelegramLoginRequest.Create(TimeSpan.FromMinutes(5), null); + request.Approve(Guid.NewGuid()); + request.Consume(); + + Assert.Throws(() => request.Consume()); + } +} diff --git a/backend/tests/PnvPanel.IntegrationTests/Auth/AuthFlowTests.cs b/backend/tests/PnvPanel.IntegrationTests/Auth/AuthFlowTests.cs new file mode 100644 index 0000000..0d6436c --- /dev/null +++ b/backend/tests/PnvPanel.IntegrationTests/Auth/AuthFlowTests.cs @@ -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(); + 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(); + 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(); + 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(); + 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); + } +} diff --git a/backend/tests/PnvPanel.IntegrationTests/PnvPanel.IntegrationTests.csproj b/backend/tests/PnvPanel.IntegrationTests/PnvPanel.IntegrationTests.csproj new file mode 100644 index 0000000..fd4edd9 --- /dev/null +++ b/backend/tests/PnvPanel.IntegrationTests/PnvPanel.IntegrationTests.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + enable + enable + false + false + false + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + diff --git a/backend/tests/PnvPanel.IntegrationTests/TestSupport/AuthTestHelper.cs b/backend/tests/PnvPanel.IntegrationTests/TestSupport/AuthTestHelper.cs new file mode 100644 index 0000000..41a1822 --- /dev/null +++ b/backend/tests/PnvPanel.IntegrationTests/TestSupport/AuthTestHelper.cs @@ -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(); + return (login!.User.Id, login.AccessToken); + } + + public static async Task LoginAsAdminAsync(HttpClient client) + { + var (_, accessToken) = await LoginAsync(client, PnvPanelWebApplicationFactory.AdminUserName, PnvPanelWebApplicationFactory.AdminPassword); + return accessToken; + } +} diff --git a/backend/tests/PnvPanel.IntegrationTests/TestSupport/FakeXuiPanelGateway.cs b/backend/tests/PnvPanel.IntegrationTests/TestSupport/FakeXuiPanelGateway.cs new file mode 100644 index 0000000..f6b7e69 --- /dev/null +++ b/backend/tests/PnvPanel.IntegrationTests/TestSupport/FakeXuiPanelGateway.cs @@ -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; + +/// +/// Заглушка 3x-ui для интеграционных тестов — реальной панели нет. Возвращает успех для проб/CRUD +/// клиентов, отдаёт один синтетический inbound на ноду для сценариев с SyncNode. +/// +public sealed class FakeXuiPanelGateway : IXuiPanelGateway +{ + public Result ValidateBaseAddress(Uri baseAddress) => Result.Success(); + + public Task ProbeAsync(Node node, CancellationToken cancellationToken) + => Task.FromResult(new NodeProbeResult(true, null)); + + public Task>> ListInboundsAsync(Node node, CancellationToken cancellationToken) + { + IReadOnlyList inbounds = + [ + new RemoteInboundInfo("1", VpnProtocol.Vless, "Test inbound", 443), + ]; + + return Task.FromResult(Result.Success(inbounds)); + } + + public void InvalidateClient(Guid nodeId) + { + } + + public Task> AddClientAsync( + Node node, string inboundRemoteId, VpnProtocol protocol, string clientEmail, string clientName, + int deviceLimit, CancellationToken cancellationToken) + => Task.FromResult(Result.Success(Guid.NewGuid().ToString())); + + public Task RemoveClientAsync( + Node node, string inboundRemoteId, string clientExternalId, VpnProtocol protocol, CancellationToken cancellationToken) + => Task.FromResult(Result.Success()); + + public Task UpdateClientAsync( + Node node, string inboundRemoteId, string clientExternalId, VpnProtocol protocol, + string name, int deviceLimit, bool enable, CancellationToken cancellationToken) + => Task.FromResult(Result.Success()); + + public Task> BuildConnectionStringAsync( + Node node, Inbound inbound, string clientExternalId, string clientName, string publicHost, CancellationToken cancellationToken) + => Task.FromResult(Result.Success("vless://fake-connection-string")); + + public Task>> GetClientTrafficAsync( + Node node, string inboundRemoteId, CancellationToken cancellationToken) + { + IReadOnlyDictionary traffic = new Dictionary(); + return Task.FromResult(Result.Success(traffic)); + } +} diff --git a/backend/tests/PnvPanel.IntegrationTests/TestSupport/HttpClientJsonExtensions.cs b/backend/tests/PnvPanel.IntegrationTests/TestSupport/HttpClientJsonExtensions.cs new file mode 100644 index 0000000..c0d5b1b --- /dev/null +++ b/backend/tests/PnvPanel.IntegrationTests/TestSupport/HttpClientJsonExtensions.cs @@ -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 ReadAsAsync(this HttpResponseMessage response) + => await response.Content.ReadFromJsonAsync(JsonOptions); + + public static Task PostJsonAsync(this HttpClient client, string url, object body) + => client.PostAsJsonAsync(url, body, JsonOptions); +} diff --git a/backend/tests/PnvPanel.IntegrationTests/TestSupport/IntegrationTestCollection.cs b/backend/tests/PnvPanel.IntegrationTests/TestSupport/IntegrationTestCollection.cs new file mode 100644 index 0000000..868860e --- /dev/null +++ b/backend/tests/PnvPanel.IntegrationTests/TestSupport/IntegrationTestCollection.cs @@ -0,0 +1,9 @@ +using Xunit; + +namespace PnvPanel.IntegrationTests.TestSupport; + +[CollectionDefinition(Name)] +public sealed class IntegrationTestCollection : ICollectionFixture +{ + public const string Name = "Integration"; +} diff --git a/backend/tests/PnvPanel.IntegrationTests/TestSupport/PnvPanelWebApplicationFactory.cs b/backend/tests/PnvPanel.IntegrationTests/TestSupport/PnvPanelWebApplicationFactory.cs new file mode 100644 index 0000000..4f3f339 --- /dev/null +++ b/backend/tests/PnvPanel.IntegrationTests/TestSupport/PnvPanelWebApplicationFactory.cs @@ -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; + +/// +/// Реальный Postgres через Testcontainers (не InMemory/Sqlite — нужно проверить Postgres-специфичное +/// поведение: pg_advisory_xact_lock для квоты конфигов, uuid[]/jsonb колонки). Program.cs сам +/// применяет миграции и сидит роли/админа при старте хоста — свежий контейнер становится полностью +/// готовой БД без ручных шагов. +/// +public sealed class PnvPanelWebApplicationFactory : WebApplicationFactory, 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 + { + ["ConnectionStrings:Default"] = _postgres.GetConnectionString(), + ["AdminSeed:Username"] = AdminUserName, + ["AdminSeed:Password"] = AdminPassword, + // Пусто — TelegramBotHostedService при пустом токене не стартует (см. Api/Telegram/TelegramBotHostedService.cs). + ["Telegram:BotToken"] = "", + }); + }); + + builder.ConfigureServices(services => + { + // Реальной панели 3x-ui в тестах нет — подменяем гейтвей заглушкой. + services.RemoveAll(); + services.AddSingleton(); + }); + } +}