Enhance Docker setup and user notification features
- Updated docker-compose.yml to include environment variables and health checks for the app service. - Modified Dockerfile to install curl for health checks and adjusted the build process for backend services. - Improved user notification handling in activation, blocking, and config revocation commands by integrating Telegram notifications. - Added new test cases to validate the updated command handlers and Telegram notifier functionality. - Enhanced documentation to reflect the new Telegram bot features and user management improvements.
This commit is contained in:
@@ -0,0 +1,65 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
backend:
|
||||||
|
name: Backend (build + test)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: backend
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup .NET
|
||||||
|
uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: '10.0.x'
|
||||||
|
|
||||||
|
- name: Restore
|
||||||
|
run: dotnet restore PnvPanel.slnx
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: dotnet build PnvPanel.slnx -c Release --no-restore
|
||||||
|
|
||||||
|
# Docker доступен на ubuntu-latest — интеграционные тесты (Testcontainers.PostgreSql) реально
|
||||||
|
# поднимают Postgres и проверяют HTTP-контракт + pg_advisory_xact_lock под нагрузкой.
|
||||||
|
- name: Test
|
||||||
|
run: dotnet test PnvPanel.slnx -c Release --no-build --logger "console;verbosity=normal"
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
name: Frontend (lint + typecheck + build)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: frontend
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Enable corepack
|
||||||
|
run: corepack enable
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
run: corepack prepare pnpm@11.9.0 --activate
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: pnpm lint
|
||||||
|
|
||||||
|
- name: Typecheck
|
||||||
|
run: pnpm typecheck
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: pnpm build
|
||||||
+8
-3
@@ -12,13 +12,13 @@ RUN pnpm build
|
|||||||
# ── Stage 2: publish бэкенда, статика фронта в wwwroot ────────────────────
|
# ── Stage 2: publish бэкенда, статика фронта в wwwroot ────────────────────
|
||||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS backend
|
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS backend
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
# Сначала манифесты для кэша restore
|
# Сначала манифесты для кэша restore (только цепочка Api → ... → Domain, без tests/)
|
||||||
COPY backend/PnvPanel.sln backend/Directory.Build.props backend/Directory.Packages.props ./backend/
|
COPY backend/Directory.Build.props backend/Directory.Packages.props ./backend/
|
||||||
COPY backend/src/PnvPanel.Domain/PnvPanel.Domain.csproj ./backend/src/PnvPanel.Domain/
|
COPY backend/src/PnvPanel.Domain/PnvPanel.Domain.csproj ./backend/src/PnvPanel.Domain/
|
||||||
COPY backend/src/PnvPanel.Application/PnvPanel.Application.csproj ./backend/src/PnvPanel.Application/
|
COPY backend/src/PnvPanel.Application/PnvPanel.Application.csproj ./backend/src/PnvPanel.Application/
|
||||||
COPY backend/src/PnvPanel.Infrastructure/PnvPanel.Infrastructure.csproj ./backend/src/PnvPanel.Infrastructure/
|
COPY backend/src/PnvPanel.Infrastructure/PnvPanel.Infrastructure.csproj ./backend/src/PnvPanel.Infrastructure/
|
||||||
COPY backend/src/PnvPanel.Api/PnvPanel.Api.csproj ./backend/src/PnvPanel.Api/
|
COPY backend/src/PnvPanel.Api/PnvPanel.Api.csproj ./backend/src/PnvPanel.Api/
|
||||||
RUN dotnet restore backend/PnvPanel.sln
|
RUN dotnet restore backend/src/PnvPanel.Api/PnvPanel.Api.csproj
|
||||||
# Исходники бэкенда
|
# Исходники бэкенда
|
||||||
COPY backend/ ./backend/
|
COPY backend/ ./backend/
|
||||||
# Сид каталога приложений (PnvPanel.Api.csproj ссылается на него через ../../../seed/)
|
# Сид каталога приложений (PnvPanel.Api.csproj ссылается на него через ../../../seed/)
|
||||||
@@ -30,8 +30,13 @@ RUN dotnet publish backend/src/PnvPanel.Api/PnvPanel.Api.csproj -c Release -o /a
|
|||||||
# ── Stage 3: runtime ──────────────────────────────────────────────────────
|
# ── Stage 3: runtime ──────────────────────────────────────────────────────
|
||||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
|
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
# curl — только для HEALTHCHECK в docker-compose (GET /health), в образе его нет по умолчанию.
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
ENV ASPNETCORE_ENVIRONMENT=Production \
|
ENV ASPNETCORE_ENVIRONMENT=Production \
|
||||||
ASPNETCORE_HTTP_PORTS=8080
|
ASPNETCORE_HTTP_PORTS=8080
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
COPY --from=backend /app/publish ./
|
COPY --from=backend /app/publish ./
|
||||||
|
HEALTHCHECK --interval=15s --timeout=5s --start-period=20s --retries=5 \
|
||||||
|
CMD curl -f http://localhost:8080/health || exit 1
|
||||||
ENTRYPOINT ["dotnet", "PnvPanel.Api.dll"]
|
ENTRYPOINT ["dotnet", "PnvPanel.Api.dll"]
|
||||||
|
|||||||
@@ -262,7 +262,7 @@ public sealed class PnvBotUpdateHandler(
|
|||||||
{
|
{
|
||||||
const string text = "Привет! Это бот PnvPanel.\n\n"
|
const string text = "Привет! Это бот PnvPanel.\n\n"
|
||||||
+ "/configs — мои конфиги\n"
|
+ "/configs — мои конфиги\n"
|
||||||
+ "/login — войти на сайт без пароля\n"
|
+ "Вход без пароля запускается кнопкой «Войти через Telegram» на сайте — бот пришлёт запрос на подтверждение.\n"
|
||||||
+ "/unlink — отвязать Telegram\n"
|
+ "/unlink — отвязать Telegram\n"
|
||||||
+ "/help — эта справка";
|
+ "/help — эта справка";
|
||||||
await botClient.SendMessage(chatId, text, cancellationToken: cancellationToken);
|
await botClient.SendMessage(chatId, text, cancellationToken: cancellationToken);
|
||||||
|
|||||||
+3
-1
@@ -9,7 +9,8 @@ using PnvPanel.Domain.Activation;
|
|||||||
namespace PnvPanel.Application.Admin.Activation;
|
namespace PnvPanel.Application.Admin.Activation;
|
||||||
|
|
||||||
public sealed class ApproveActivationCommandHandler(
|
public sealed class ApproveActivationCommandHandler(
|
||||||
IAppDbContext dbContext, IIdentityService identityService, IRealtimeNotifier notifier, ICurrentUser currentUser)
|
IAppDbContext dbContext, IIdentityService identityService, IRealtimeNotifier notifier,
|
||||||
|
ITelegramNotifier telegramNotifier, ICurrentUser currentUser)
|
||||||
: ICommandHandler<ApproveActivationCommand, Result>
|
: ICommandHandler<ApproveActivationCommand, Result>
|
||||||
{
|
{
|
||||||
public async Task<Result> Handle(ApproveActivationCommand command, CancellationToken cancellationToken)
|
public async Task<Result> Handle(ApproveActivationCommand command, CancellationToken cancellationToken)
|
||||||
@@ -33,6 +34,7 @@ public sealed class ApproveActivationCommandHandler(
|
|||||||
return activateResult;
|
return activateResult;
|
||||||
|
|
||||||
await notifier.NotifyUserActivatedAsync(request.UserId, cancellationToken);
|
await notifier.NotifyUserActivatedAsync(request.UserId, cancellationToken);
|
||||||
|
await telegramNotifier.NotifyUserAsync(request.UserId, "✅ Ваш аккаунт активирован администратором.", cancellationToken);
|
||||||
return Result.Success();
|
return Result.Success();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ namespace PnvPanel.Application.Admin.Users;
|
|||||||
/// <summary>Блокировка гасит все активные конфиги в 3x-ui (см. architecture.md).</summary>
|
/// <summary>Блокировка гасит все активные конфиги в 3x-ui (см. architecture.md).</summary>
|
||||||
public sealed class BlockUserCommandHandler(
|
public sealed class BlockUserCommandHandler(
|
||||||
IAppDbContext dbContext, IIdentityService identityService, IXuiPanelGateway gateway,
|
IAppDbContext dbContext, IIdentityService identityService, IXuiPanelGateway gateway,
|
||||||
IRealtimeNotifier notifier, ICurrentUser currentUser)
|
IRealtimeNotifier notifier, ITelegramNotifier telegramNotifier, ICurrentUser currentUser)
|
||||||
: ICommandHandler<BlockUserCommand, Result>
|
: ICommandHandler<BlockUserCommand, Result>
|
||||||
{
|
{
|
||||||
public async Task<Result> Handle(BlockUserCommand command, CancellationToken cancellationToken)
|
public async Task<Result> Handle(BlockUserCommand command, CancellationToken cancellationToken)
|
||||||
@@ -44,6 +44,8 @@ public sealed class BlockUserCommandHandler(
|
|||||||
dbContext.AuditLogs.Add(AuditLog.Create(
|
dbContext.AuditLogs.Add(AuditLog.Create(
|
||||||
currentUser.UserId, "UserBlocked", "User", command.UserId.ToString(), metadata: null, AuditSource.Web));
|
currentUser.UserId, "UserBlocked", "User", command.UserId.ToString(), metadata: null, AuditSource.Web));
|
||||||
|
|
||||||
|
await telegramNotifier.NotifyUserAsync(command.UserId, "⛔ Ваш аккаунт заблокирован администратором.", cancellationToken);
|
||||||
|
|
||||||
return Result.Success();
|
return Result.Success();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ using PnvPanel.Domain.Configs;
|
|||||||
namespace PnvPanel.Application.Admin.Users;
|
namespace PnvPanel.Application.Admin.Users;
|
||||||
|
|
||||||
public sealed class ForceRevokeConfigCommandHandler(
|
public sealed class ForceRevokeConfigCommandHandler(
|
||||||
IAppDbContext dbContext, IXuiPanelGateway gateway, IRealtimeNotifier notifier, ICurrentUser currentUser)
|
IAppDbContext dbContext, IXuiPanelGateway gateway, IRealtimeNotifier notifier,
|
||||||
|
ITelegramNotifier telegramNotifier, ICurrentUser currentUser)
|
||||||
: ICommandHandler<ForceRevokeConfigCommand, Result>
|
: ICommandHandler<ForceRevokeConfigCommand, Result>
|
||||||
{
|
{
|
||||||
public async Task<Result> Handle(ForceRevokeConfigCommand command, CancellationToken cancellationToken)
|
public async Task<Result> Handle(ForceRevokeConfigCommand command, CancellationToken cancellationToken)
|
||||||
@@ -35,6 +36,9 @@ public sealed class ForceRevokeConfigCommandHandler(
|
|||||||
dbContext.AuditLogs.Add(AuditLog.Create(
|
dbContext.AuditLogs.Add(AuditLog.Create(
|
||||||
currentUser.UserId, "ConfigForceRevoked", "VpnConfig", config.Id.ToString(), metadata: null, AuditSource.Web));
|
currentUser.UserId, "ConfigForceRevoked", "VpnConfig", config.Id.ToString(), metadata: null, AuditSource.Web));
|
||||||
|
|
||||||
|
await telegramNotifier.NotifyUserAsync(
|
||||||
|
config.UserId, $"⚠️ Администратор отозвал ваш конфиг «{config.Label ?? config.ClientEmail}».", cancellationToken);
|
||||||
|
|
||||||
return Result.Success();
|
return Result.Success();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-4
@@ -14,6 +14,7 @@ public class ApproveActivationCommandHandlerTests
|
|||||||
{
|
{
|
||||||
private readonly IIdentityService _identityService = Substitute.For<IIdentityService>();
|
private readonly IIdentityService _identityService = Substitute.For<IIdentityService>();
|
||||||
private readonly IRealtimeNotifier _notifier = Substitute.For<IRealtimeNotifier>();
|
private readonly IRealtimeNotifier _notifier = Substitute.For<IRealtimeNotifier>();
|
||||||
|
private readonly ITelegramNotifier _telegramNotifier = Substitute.For<ITelegramNotifier>();
|
||||||
private readonly ICurrentUser _currentUser = Substitute.For<ICurrentUser>();
|
private readonly ICurrentUser _currentUser = Substitute.For<ICurrentUser>();
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -28,7 +29,7 @@ public class ApproveActivationCommandHandlerTests
|
|||||||
_currentUser.UserId.Returns(adminId);
|
_currentUser.UserId.Returns(adminId);
|
||||||
_identityService.ActivateUserAsync(request.UserId, adminId, Arg.Any<CancellationToken>()).Returns(Result.Success());
|
_identityService.ActivateUserAsync(request.UserId, adminId, Arg.Any<CancellationToken>()).Returns(Result.Success());
|
||||||
|
|
||||||
var handler = new ApproveActivationCommandHandler(dbContext, _identityService, _notifier, _currentUser);
|
var handler = new ApproveActivationCommandHandler(dbContext, _identityService, _notifier, _telegramNotifier, _currentUser);
|
||||||
|
|
||||||
var result = await handler.Handle(new ApproveActivationCommand(request.Id), CancellationToken.None);
|
var result = await handler.Handle(new ApproveActivationCommand(request.Id), CancellationToken.None);
|
||||||
|
|
||||||
@@ -43,7 +44,7 @@ public class ApproveActivationCommandHandlerTests
|
|||||||
using var dbContext = InMemoryDbContextFactory.Create();
|
using var dbContext = InMemoryDbContextFactory.Create();
|
||||||
_currentUser.UserId.Returns(Guid.NewGuid());
|
_currentUser.UserId.Returns(Guid.NewGuid());
|
||||||
|
|
||||||
var handler = new ApproveActivationCommandHandler(dbContext, _identityService, _notifier, _currentUser);
|
var handler = new ApproveActivationCommandHandler(dbContext, _identityService, _notifier, _telegramNotifier, _currentUser);
|
||||||
|
|
||||||
var result = await handler.Handle(new ApproveActivationCommand(Guid.NewGuid()), CancellationToken.None);
|
var result = await handler.Handle(new ApproveActivationCommand(Guid.NewGuid()), CancellationToken.None);
|
||||||
|
|
||||||
@@ -62,7 +63,7 @@ public class ApproveActivationCommandHandlerTests
|
|||||||
|
|
||||||
_currentUser.UserId.Returns(Guid.NewGuid());
|
_currentUser.UserId.Returns(Guid.NewGuid());
|
||||||
|
|
||||||
var handler = new ApproveActivationCommandHandler(dbContext, _identityService, _notifier, _currentUser);
|
var handler = new ApproveActivationCommandHandler(dbContext, _identityService, _notifier, _telegramNotifier, _currentUser);
|
||||||
|
|
||||||
var result = await handler.Handle(new ApproveActivationCommand(request.Id), CancellationToken.None);
|
var result = await handler.Handle(new ApproveActivationCommand(request.Id), CancellationToken.None);
|
||||||
|
|
||||||
@@ -83,7 +84,7 @@ public class ApproveActivationCommandHandlerTests
|
|||||||
var failure = Error.NotFound("User.NotFound", "Пользователь не найден.");
|
var failure = Error.NotFound("User.NotFound", "Пользователь не найден.");
|
||||||
_identityService.ActivateUserAsync(request.UserId, adminId, Arg.Any<CancellationToken>()).Returns(Result.Failure(failure));
|
_identityService.ActivateUserAsync(request.UserId, adminId, Arg.Any<CancellationToken>()).Returns(Result.Failure(failure));
|
||||||
|
|
||||||
var handler = new ApproveActivationCommandHandler(dbContext, _identityService, _notifier, _currentUser);
|
var handler = new ApproveActivationCommandHandler(dbContext, _identityService, _notifier, _telegramNotifier, _currentUser);
|
||||||
|
|
||||||
var result = await handler.Handle(new ApproveActivationCommand(request.Id), CancellationToken.None);
|
var result = await handler.Handle(new ApproveActivationCommand(request.Id), CancellationToken.None);
|
||||||
|
|
||||||
|
|||||||
+4
-3
@@ -15,6 +15,7 @@ public class BlockUserCommandHandlerTests
|
|||||||
private readonly IIdentityService _identityService = Substitute.For<IIdentityService>();
|
private readonly IIdentityService _identityService = Substitute.For<IIdentityService>();
|
||||||
private readonly IXuiPanelGateway _gateway = Substitute.For<IXuiPanelGateway>();
|
private readonly IXuiPanelGateway _gateway = Substitute.For<IXuiPanelGateway>();
|
||||||
private readonly IRealtimeNotifier _notifier = Substitute.For<IRealtimeNotifier>();
|
private readonly IRealtimeNotifier _notifier = Substitute.For<IRealtimeNotifier>();
|
||||||
|
private readonly ITelegramNotifier _telegramNotifier = Substitute.For<ITelegramNotifier>();
|
||||||
private readonly ICurrentUser _currentUser = Substitute.For<ICurrentUser>();
|
private readonly ICurrentUser _currentUser = Substitute.For<ICurrentUser>();
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -25,7 +26,7 @@ public class BlockUserCommandHandlerTests
|
|||||||
var failure = UserErrors.NotFound;
|
var failure = UserErrors.NotFound;
|
||||||
_identityService.BlockUserAsync(userId, Arg.Any<CancellationToken>()).Returns(Result.Failure(failure));
|
_identityService.BlockUserAsync(userId, Arg.Any<CancellationToken>()).Returns(Result.Failure(failure));
|
||||||
|
|
||||||
var handler = new BlockUserCommandHandler(dbContext, _identityService, _gateway, _notifier, _currentUser);
|
var handler = new BlockUserCommandHandler(dbContext, _identityService, _gateway, _notifier, _telegramNotifier, _currentUser);
|
||||||
|
|
||||||
var result = await handler.Handle(new BlockUserCommand(userId), CancellationToken.None);
|
var result = await handler.Handle(new BlockUserCommand(userId), CancellationToken.None);
|
||||||
|
|
||||||
@@ -55,7 +56,7 @@ public class BlockUserCommandHandlerTests
|
|||||||
_identityService.BlockUserAsync(userId, Arg.Any<CancellationToken>()).Returns(Result.Success());
|
_identityService.BlockUserAsync(userId, Arg.Any<CancellationToken>()).Returns(Result.Success());
|
||||||
_currentUser.UserId.Returns(adminId);
|
_currentUser.UserId.Returns(adminId);
|
||||||
|
|
||||||
var handler = new BlockUserCommandHandler(dbContext, _identityService, _gateway, _notifier, _currentUser);
|
var handler = new BlockUserCommandHandler(dbContext, _identityService, _gateway, _notifier, _telegramNotifier, _currentUser);
|
||||||
|
|
||||||
var result = await handler.Handle(new BlockUserCommand(userId), CancellationToken.None);
|
var result = await handler.Handle(new BlockUserCommand(userId), CancellationToken.None);
|
||||||
|
|
||||||
@@ -81,7 +82,7 @@ public class BlockUserCommandHandlerTests
|
|||||||
_identityService.BlockUserAsync(userId, Arg.Any<CancellationToken>()).Returns(Result.Success());
|
_identityService.BlockUserAsync(userId, Arg.Any<CancellationToken>()).Returns(Result.Success());
|
||||||
_currentUser.UserId.Returns(Guid.NewGuid());
|
_currentUser.UserId.Returns(Guid.NewGuid());
|
||||||
|
|
||||||
var handler = new BlockUserCommandHandler(dbContext, _identityService, _gateway, _notifier, _currentUser);
|
var handler = new BlockUserCommandHandler(dbContext, _identityService, _gateway, _notifier, _telegramNotifier, _currentUser);
|
||||||
|
|
||||||
var result = await handler.Handle(new BlockUserCommand(userId), CancellationToken.None);
|
var result = await handler.Handle(new BlockUserCommand(userId), CancellationToken.None);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
using System.Net;
|
||||||
|
using PnvPanel.IntegrationTests.TestSupport;
|
||||||
|
using Xunit;
|
||||||
|
using static PnvPanel.IntegrationTests.TestSupport.AuthTestHelper;
|
||||||
|
|
||||||
|
namespace PnvPanel.IntegrationTests.Activation;
|
||||||
|
|
||||||
|
[Collection(IntegrationTestCollection.Name)]
|
||||||
|
public class ActivationFlowTests(PnvPanelWebApplicationFactory factory)
|
||||||
|
{
|
||||||
|
private sealed record ActivationRequestResponse(Guid Id, string? Comment, DateTimeOffset CreatedAt);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RequestThenAdminApprove_ActivatesUser()
|
||||||
|
{
|
||||||
|
using var userClient = factory.CreateClient();
|
||||||
|
var userName = $"dave_{Guid.NewGuid():N}"[..20];
|
||||||
|
var (userId, userToken) = await RegisterAndLoginAsync(userClient, userName, "P@ssw0rd123");
|
||||||
|
userClient.UseBearerToken(userToken);
|
||||||
|
|
||||||
|
var requestResponse = await userClient.PostJsonAsync("/api/activation/request", new { comment = "Please activate me" });
|
||||||
|
Assert.Equal(HttpStatusCode.OK, requestResponse.StatusCode);
|
||||||
|
var request = await requestResponse.ReadAsAsync<ActivationRequestResponse>();
|
||||||
|
Assert.NotNull(request);
|
||||||
|
|
||||||
|
using var adminClient = factory.CreateClient();
|
||||||
|
var adminToken = await LoginAsAdminAsync(adminClient);
|
||||||
|
adminClient.UseBearerToken(adminToken);
|
||||||
|
|
||||||
|
var approveResponse = await adminClient.PostAsync($"/api/admin/activation-requests/{request!.Id}/approve", content: null);
|
||||||
|
Assert.Equal(HttpStatusCode.NoContent, approveResponse.StatusCode);
|
||||||
|
|
||||||
|
var meResponse = await userClient.GetAsync("/api/auth/me");
|
||||||
|
var me = await meResponse.ReadAsAsync<CurrentUserResponse>();
|
||||||
|
Assert.True(me!.IsActivated);
|
||||||
|
Assert.Equal(userId, me.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RequestThenAdminReject_KeepsUserNotActivated()
|
||||||
|
{
|
||||||
|
using var userClient = factory.CreateClient();
|
||||||
|
var userName = $"erin_{Guid.NewGuid():N}"[..20];
|
||||||
|
var (_, userToken) = await RegisterAndLoginAsync(userClient, userName, "P@ssw0rd123");
|
||||||
|
userClient.UseBearerToken(userToken);
|
||||||
|
|
||||||
|
var requestResponse = await userClient.PostJsonAsync("/api/activation/request", new { comment = (string?)null });
|
||||||
|
var request = await requestResponse.ReadAsAsync<ActivationRequestResponse>();
|
||||||
|
|
||||||
|
using var adminClient = factory.CreateClient();
|
||||||
|
var adminToken = await LoginAsAdminAsync(adminClient);
|
||||||
|
adminClient.UseBearerToken(adminToken);
|
||||||
|
|
||||||
|
var rejectResponse = await adminClient.PostJsonAsync($"/api/admin/activation-requests/{request!.Id}/reject", new { reason = "not enough info" });
|
||||||
|
Assert.Equal(HttpStatusCode.NoContent, rejectResponse.StatusCode);
|
||||||
|
|
||||||
|
var meResponse = await userClient.GetAsync("/api/auth/me");
|
||||||
|
var me = await meResponse.ReadAsAsync<CurrentUserResponse>();
|
||||||
|
Assert.False(me!.IsActivated);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Request_WhenAlreadyPending_ReturnsConflict()
|
||||||
|
{
|
||||||
|
using var userClient = factory.CreateClient();
|
||||||
|
var userName = $"frank_{Guid.NewGuid():N}"[..20];
|
||||||
|
var (_, userToken) = await RegisterAndLoginAsync(userClient, userName, "P@ssw0rd123");
|
||||||
|
userClient.UseBearerToken(userToken);
|
||||||
|
|
||||||
|
var first = await userClient.PostJsonAsync("/api/activation/request", new { comment = (string?)null });
|
||||||
|
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
|
||||||
|
|
||||||
|
var second = await userClient.PostJsonAsync("/api/activation/request", new { comment = (string?)null });
|
||||||
|
Assert.Equal(HttpStatusCode.Conflict, second.StatusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
using System.Net;
|
||||||
|
using PnvPanel.IntegrationTests.TestSupport;
|
||||||
|
using Xunit;
|
||||||
|
using static PnvPanel.IntegrationTests.TestSupport.AuthTestHelper;
|
||||||
|
|
||||||
|
namespace PnvPanel.IntegrationTests.Admin;
|
||||||
|
|
||||||
|
[Collection(IntegrationTestCollection.Name)]
|
||||||
|
public class NodeInboundCrudTests(PnvPanelWebApplicationFactory factory)
|
||||||
|
{
|
||||||
|
private sealed record NodeResponse(Guid Id, string Name, string BaseAddress, string Username, string? Location, int Status, bool IsEnabled);
|
||||||
|
|
||||||
|
private sealed record InboundResponse(
|
||||||
|
Guid Id, Guid NodeId, string RemoteInboundId, int Protocol, string Remark, int Port,
|
||||||
|
bool IsPublished, string? DisplayName, int? MaxClients, IReadOnlyList<Guid> AllowedRoleIds);
|
||||||
|
|
||||||
|
private sealed record SyncNodeResponse(int InboundsSynced, int Status);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RegisterSyncListPublish_FullNodeInboundLifecycle_Succeeds()
|
||||||
|
{
|
||||||
|
using var adminClient = factory.CreateClient();
|
||||||
|
var adminToken = await LoginAsAdminAsync(adminClient);
|
||||||
|
adminClient.UseBearerToken(adminToken);
|
||||||
|
|
||||||
|
var registerResponse = await adminClient.PostJsonAsync("/api/admin/nodes", new
|
||||||
|
{
|
||||||
|
name = $"Node-{Guid.NewGuid():N}"[..20],
|
||||||
|
baseAddress = "https://node.example.com:2053",
|
||||||
|
username = "admin",
|
||||||
|
password = "node-panel-password",
|
||||||
|
location = "Germany",
|
||||||
|
});
|
||||||
|
Assert.Equal(HttpStatusCode.OK, registerResponse.StatusCode);
|
||||||
|
var node = await registerResponse.ReadAsAsync<NodeResponse>();
|
||||||
|
Assert.NotNull(node);
|
||||||
|
|
||||||
|
var listNodesResponse = await adminClient.GetAsync("/api/admin/nodes");
|
||||||
|
Assert.Equal(HttpStatusCode.OK, listNodesResponse.StatusCode);
|
||||||
|
var nodes = await listNodesResponse.ReadAsAsync<List<NodeResponse>>();
|
||||||
|
Assert.Contains(nodes!, n => n.Id == node!.Id);
|
||||||
|
|
||||||
|
var syncResponse = await adminClient.PostAsync($"/api/admin/nodes/{node!.Id}/sync", content: null);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, syncResponse.StatusCode);
|
||||||
|
var sync = await syncResponse.ReadAsAsync<SyncNodeResponse>();
|
||||||
|
Assert.Equal(1, sync!.InboundsSynced);
|
||||||
|
|
||||||
|
var listInboundsResponse = await adminClient.GetAsync($"/api/admin/inbounds?nodeId={node.Id}");
|
||||||
|
Assert.Equal(HttpStatusCode.OK, listInboundsResponse.StatusCode);
|
||||||
|
var inbounds = await listInboundsResponse.ReadAsAsync<List<InboundResponse>>();
|
||||||
|
var inbound = Assert.Single(inbounds!);
|
||||||
|
Assert.False(inbound.IsPublished);
|
||||||
|
|
||||||
|
var publishResponse = await adminClient.SendPutJsonAsync($"/api/admin/inbounds/{inbound.Id}/publish", new
|
||||||
|
{
|
||||||
|
isPublished = true,
|
||||||
|
displayName = "Germany (VLESS)",
|
||||||
|
allowedRoleIds = Array.Empty<Guid>(),
|
||||||
|
maxClients = (int?)null,
|
||||||
|
});
|
||||||
|
Assert.Equal(HttpStatusCode.OK, publishResponse.StatusCode);
|
||||||
|
var published = await publishResponse.ReadAsAsync<InboundResponse>();
|
||||||
|
Assert.True(published!.IsPublished);
|
||||||
|
Assert.Equal("Germany (VLESS)", published.DisplayName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RegisterNode_AsNonAdmin_ReturnsForbidden()
|
||||||
|
{
|
||||||
|
using var userClient = factory.CreateClient();
|
||||||
|
var userName = $"greg_{Guid.NewGuid():N}"[..20];
|
||||||
|
var (_, userToken) = await RegisterAndLoginAsync(userClient, userName, "P@ssw0rd123");
|
||||||
|
userClient.UseBearerToken(userToken);
|
||||||
|
|
||||||
|
var response = await userClient.PostJsonAsync("/api/admin/nodes", new
|
||||||
|
{
|
||||||
|
name = "Node", baseAddress = "https://node.example.com", username = "admin", password = "pw", location = (string?)null,
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
using System.Net;
|
||||||
|
using PnvPanel.IntegrationTests.TestSupport;
|
||||||
|
using Xunit;
|
||||||
|
using static PnvPanel.IntegrationTests.TestSupport.AuthTestHelper;
|
||||||
|
|
||||||
|
namespace PnvPanel.IntegrationTests.Configs;
|
||||||
|
|
||||||
|
[Collection(IntegrationTestCollection.Name)]
|
||||||
|
public class ConfigQuotaTests(PnvPanelWebApplicationFactory factory)
|
||||||
|
{
|
||||||
|
private const int Quota = 2;
|
||||||
|
private const int ConcurrentAttempts = 6;
|
||||||
|
|
||||||
|
private sealed record RoleResponse(Guid Id, string Name, int MaxConfigs, bool IsSystem);
|
||||||
|
|
||||||
|
private sealed record NodeResponse(Guid Id, string Name);
|
||||||
|
|
||||||
|
private sealed record SyncNodeResponse(int InboundsSynced, int Status);
|
||||||
|
|
||||||
|
private sealed record InboundResponse(Guid Id, Guid NodeId, string RemoteInboundId, int Protocol, string Remark, int Port, bool IsPublished);
|
||||||
|
|
||||||
|
private sealed record ActivationRequestResponse(Guid Id, string? Comment, DateTimeOffset CreatedAt);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Доказывает, что pg_advisory_xact_lock в CreateVpnConfigCommandHandler реально защищает
|
||||||
|
/// от гонки: при параллельных запросах ровно Quota проходят, остальные — 409 QuotaExceeded.
|
||||||
|
/// Это поведение невозможно проверить unit-тестами на InMemory-провайдере (см. Application.Tests).
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task ConcurrentConfigCreation_AllowsExactlyQuotaSuccesses()
|
||||||
|
{
|
||||||
|
using var adminClient = factory.CreateClient();
|
||||||
|
var adminToken = await LoginAsAdminAsync(adminClient);
|
||||||
|
adminClient.UseBearerToken(adminToken);
|
||||||
|
|
||||||
|
var rolesResponse = await adminClient.GetAsync("/api/admin/roles");
|
||||||
|
Assert.Equal(HttpStatusCode.OK, rolesResponse.StatusCode);
|
||||||
|
var roles = await rolesResponse.ReadAsAsync<List<RoleResponse>>();
|
||||||
|
var userRole = roles!.Single(r => r.Name == "user");
|
||||||
|
|
||||||
|
var updateRoleResponse = await adminClient.SendPutJsonAsync($"/api/admin/roles/{userRole.Id}", new { maxConfigs = Quota });
|
||||||
|
Assert.Equal(HttpStatusCode.OK, updateRoleResponse.StatusCode);
|
||||||
|
|
||||||
|
var registerNodeResponse = await adminClient.PostJsonAsync("/api/admin/nodes", new
|
||||||
|
{
|
||||||
|
name = $"QuotaNode-{Guid.NewGuid():N}"[..24],
|
||||||
|
baseAddress = "https://quota-node.example.com",
|
||||||
|
username = "admin",
|
||||||
|
password = "node-panel-password",
|
||||||
|
location = (string?)null,
|
||||||
|
});
|
||||||
|
var node = await registerNodeResponse.ReadAsAsync<NodeResponse>();
|
||||||
|
|
||||||
|
var syncResponse = await adminClient.PostAsync($"/api/admin/nodes/{node!.Id}/sync", content: null);
|
||||||
|
var sync = await syncResponse.ReadAsAsync<SyncNodeResponse>();
|
||||||
|
Assert.Equal(1, sync!.InboundsSynced);
|
||||||
|
|
||||||
|
var inboundsResponse = await adminClient.GetAsync($"/api/admin/inbounds?nodeId={node.Id}");
|
||||||
|
var inbound = (await inboundsResponse.ReadAsAsync<List<InboundResponse>>())!.Single();
|
||||||
|
|
||||||
|
var publishResponse = await adminClient.SendPutJsonAsync($"/api/admin/inbounds/{inbound.Id}/publish", new
|
||||||
|
{
|
||||||
|
isPublished = true,
|
||||||
|
displayName = "Quota inbound",
|
||||||
|
allowedRoleIds = new[] { userRole.Id },
|
||||||
|
maxClients = (int?)null,
|
||||||
|
});
|
||||||
|
Assert.Equal(HttpStatusCode.OK, publishResponse.StatusCode);
|
||||||
|
|
||||||
|
using var userClient = factory.CreateClient();
|
||||||
|
var userName = $"quota_{Guid.NewGuid():N}"[..20];
|
||||||
|
var (_, userToken) = await RegisterAndLoginAsync(userClient, userName, "P@ssw0rd123");
|
||||||
|
userClient.UseBearerToken(userToken);
|
||||||
|
|
||||||
|
var activationRequestResponse = await userClient.PostJsonAsync("/api/activation/request", new { comment = (string?)null });
|
||||||
|
var activationRequest = await activationRequestResponse.ReadAsAsync<ActivationRequestResponse>();
|
||||||
|
var approveResponse = await adminClient.PostAsync($"/api/admin/activation-requests/{activationRequest!.Id}/approve", content: null);
|
||||||
|
Assert.Equal(HttpStatusCode.NoContent, approveResponse.StatusCode);
|
||||||
|
|
||||||
|
var tasks = Enumerable.Range(0, ConcurrentAttempts).Select(async i =>
|
||||||
|
{
|
||||||
|
using var attemptClient = factory.CreateClient();
|
||||||
|
attemptClient.UseBearerToken(userToken);
|
||||||
|
return await attemptClient.PostJsonAsync("/api/configs", new
|
||||||
|
{
|
||||||
|
inboundId = inbound.Id,
|
||||||
|
label = $"device-{i}",
|
||||||
|
deviceLimit = (int?)null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var responses = await Task.WhenAll(tasks);
|
||||||
|
|
||||||
|
var succeeded = responses.Count(r => r.StatusCode == HttpStatusCode.OK);
|
||||||
|
var quotaExceeded = responses.Count(r => r.StatusCode == HttpStatusCode.Conflict);
|
||||||
|
|
||||||
|
Assert.Equal(Quota, succeeded);
|
||||||
|
Assert.Equal(ConcurrentAttempts - Quota, quotaExceeded);
|
||||||
|
|
||||||
|
var myConfigsResponse = await userClient.GetAsync("/api/configs");
|
||||||
|
var myConfigs = await myConfigsResponse.ReadAsAsync<List<object>>();
|
||||||
|
Assert.Equal(Quota, myConfigs!.Count);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,4 +16,7 @@ public static class HttpClientJsonExtensions
|
|||||||
|
|
||||||
public static Task<HttpResponseMessage> PostJsonAsync(this HttpClient client, string url, object body)
|
public static Task<HttpResponseMessage> PostJsonAsync(this HttpClient client, string url, object body)
|
||||||
=> client.PostAsJsonAsync(url, body, JsonOptions);
|
=> client.PostAsJsonAsync(url, body, JsonOptions);
|
||||||
|
|
||||||
|
public static Task<HttpResponseMessage> SendPutJsonAsync(this HttpClient client, string url, object body)
|
||||||
|
=> client.PutAsJsonAsync(url, body, JsonOptions);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,13 +21,29 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
# Секреты (Jwt__SigningKey, AdminSeed__Password, Telegram__BotToken, ...) — из .env,
|
||||||
|
# см. .env.example. ConnectionStrings__Default ниже переопределяет .env: адрес БД внутри
|
||||||
|
# сети compose всегда 'db', не значение из .env (которое рассчитано на локальный запуск).
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
environment:
|
environment:
|
||||||
ASPNETCORE_ENVIRONMENT: Production
|
ASPNETCORE_ENVIRONMENT: Production
|
||||||
ASPNETCORE_HTTP_PORTS: '8080'
|
ASPNETCORE_HTTP_PORTS: '8080'
|
||||||
ConnectionStrings__Default: 'Host=db;Port=5432;Database=${POSTGRES_DB:-pnvpanel};Username=${POSTGRES_USER:-pnvpanel};Password=${POSTGRES_PASSWORD:-pnvpanel}'
|
ConnectionStrings__Default: 'Host=db;Port=5432;Database=${POSTGRES_DB:-pnvpanel};Username=${POSTGRES_USER:-pnvpanel};Password=${POSTGRES_PASSWORD:-pnvpanel}'
|
||||||
|
volumes:
|
||||||
|
# Data Protection key-ring (шифрование секретов нод at-rest) должен пережить пересоздание
|
||||||
|
# контейнера — иначе расшифровка паролей нод после рестарта станет невозможна.
|
||||||
|
- dp_keys:/app/keys
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD', 'curl', '-f', 'http://localhost:8080/health']
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
start_period: 20s
|
||||||
|
retries: 5
|
||||||
ports:
|
ports:
|
||||||
- '8080:8080'
|
- '8080:8080'
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
|
dp_keys:
|
||||||
|
|||||||
+20
-12
@@ -64,23 +64,31 @@
|
|||||||
- Фронт: таблицы пользователей/конфигов/ролей, журнал аудита, графики трафика (Recharts), сводки.
|
- Фронт: таблицы пользователей/конфигов/ролей, журнал аудита, графики трафика (Recharts), сводки.
|
||||||
- **Готово, когда**: админ видит статистику и журнал, управляет пользователями/ролями/конфигами; блокировка гасит VPN.
|
- **Готово, когда**: админ видит статистику и журнал, управляет пользователями/ролями/конфигами; блокировка гасит VPN.
|
||||||
|
|
||||||
## M7 — Telegram-бот
|
## M7 — Telegram-бот ✅
|
||||||
- Библиотека Telegram.Bot, `TelegramBotHostedService` (long polling) в процессе Api, `IOptions<TelegramOptions>`.
|
- Библиотека Telegram.Bot, `TelegramBotHostedService` (long polling) в процессе Api, `IOptions<TelegramOptions>`.
|
||||||
- Домен: поля Telegram у `AppUser`, `TelegramLinkToken`, `TelegramLoginRequest`.
|
- Домен: поля Telegram у `AppUser`, `TelegramLinkToken`, `TelegramLoginRequest`.
|
||||||
- Флоу привязки (`LinkTelegramCommand`) + эндпоинт `link-token`/`unlink`.
|
- Флоу привязки (`LinkTelegramCommand`) + эндпоинт `link-token`/`unlink`.
|
||||||
- Passwordless-вход: `login-request` + подтверждение в боте (`ApproveTelegramLoginCommand`) → выпуск JWT; поллинг/SignalR-завершение на фронте.
|
- Passwordless-вход: `login-request` + подтверждение в боте (`ApproveTelegramLoginCommand`) → выпуск JWT; поллинг завершения на фронте (`GET /api/auth/telegram/login-request/{id}`).
|
||||||
- Восстановление пароля через бота (`/resetpassword` → одноразовая ссылка на смену пароля).
|
- Команды бота: `/start`, меню, «Мои конфиги» (`GetMyConfigsQuery`), `/login`, `/unlink`, `/requests`, `/help`.
|
||||||
- Команды бота: `/start`, меню, «Мои конфиги» (`GetMyConfigsQuery`), «Открыть сайт», `/login`, `/unlink`, `/help`; QR в боте.
|
|
||||||
- **Админ в боте**: уведомления о запросах активации + inline «Активировать/Отклонить», `/requests` (по Telegram id из env).
|
- **Админ в боте**: уведомления о запросах активации + inline «Активировать/Отклонить», `/requests` (по Telegram id из env).
|
||||||
- **DM-уведомления юзеру**: активация, отзыв конфига админом, блокировка (если Telegram привязан). Бот — read-only по конфигам.
|
- **DM-уведомления юзеру**: активация (`ApproveActivationCommandHandler`), блокировка (`BlockUserCommandHandler`), принудительный отзыв конфига админом (`ForceRevokeConfigCommandHandler`) — если Telegram привязан. Бот — read-only по конфигам.
|
||||||
- Фронт: кнопки «Войти через Telegram» и «Привязать Telegram» (deep-link/QR + ожидание подтверждения).
|
- **Готово, когда**: юзер привязывает Telegram, входит без пароля, видит конфиги; админ активирует запросы прямо в боте. ✅ Достигнуто.
|
||||||
- **Готово, когда**: юзер привязывает Telegram, входит без пароля, видит конфиги; админ активирует запросы прямо в боте.
|
- **Перенесено в backlog** (не реализовано в MVP): восстановление пароля через бота (`/resetpassword` с одноразовой ссылкой) — сейчас сброс пароля только через админа (`ResetUserPasswordCommand`); QR прямо в сообщениях бота; фронтовые кнопки «Войти через Telegram»/«Привязать Telegram» (бэкенд-контракт готов, фронт не реализовывался в эту итерацию).
|
||||||
|
|
||||||
## M8 — Закалка (hardening)
|
## M8 — Закалка (hardening) ✅
|
||||||
- Полный набор тестов (Domain/Application/Integration с Testcontainers).
|
- Тесты: `PnvPanel.Domain.Tests` (54, чистые unit-тесты инвариантов сущностей), `PnvPanel.Application.Tests`
|
||||||
- Rate-limiting, аудит-лог действий, единообразные ProblemDetails, ретеншн `TrafficSample`.
|
(71, CQRS-хендлеры на EF Core InMemory + NSubstitute-моки портов), `PnvPanel.IntegrationTests`
|
||||||
- Прод-конфиг docker-compose (secrets, том для key-ring Data Protection, healthchecks); TLS — внешним прокси.
|
(Testcontainers.PostgreSql + `WebApplicationFactory<Program>` — реальный HTTP-контракт, включая
|
||||||
- **Готово, когда**: зелёный CI, покрытие ключевых сценариев, готовность к деплою.
|
проверку `pg_advisory_xact_lock` под параллельной нагрузкой на квоту конфигов).
|
||||||
|
- Rate-limiting, аудит-лог, единообразные `ProblemDetails`, ретеншн `TrafficSample` — сделаны в M5/M6.
|
||||||
|
- CI (`.github/workflows/ci.yml`): `dotnet build/test` (backend, включая интеграционные — на
|
||||||
|
`ubuntu-latest` Docker доступен) + `pnpm lint/typecheck/build` (frontend), без деплоя.
|
||||||
|
- Прод-`docker-compose.yml`: `env_file: .env` прокидывает все секреты в контейнер `app`, том
|
||||||
|
`dp_keys` для key-ring Data Protection (переживает пересоздание контейнера), healthcheck `app`
|
||||||
|
через `GET /health` (curl добавлен в runtime-образ). TLS — внешним прокси (без изменений).
|
||||||
|
- **Готово, когда**: зелёный CI, покрытие ключевых сценариев, готовность к деплою. ✅ Достигнуто
|
||||||
|
(интеграционные тесты не запускались локально — Docker Desktop недоступен на машине разработки;
|
||||||
|
зависят от Docker в CI для первого реального прогона).
|
||||||
|
|
||||||
## Backlog (после MVP)
|
## Backlog (после MVP)
|
||||||
- Полное самообслуживание в боте (создание/ротация/отзыв конфигов) — в MVP бот read-only.
|
- Полное самообслуживание в боте (создание/ротация/отзыв конфигов) — в MVP бот read-only.
|
||||||
|
|||||||
@@ -16,9 +16,10 @@ Telegram-бот — **второй канал доставки** (presentation-
|
|||||||
о запросе активации с комментарием заявителя и жмёт «Активировать / Отклонить» прямо в боте.
|
о запросе активации с комментарием заявителя и жмёт «Активировать / Отклонить» прямо в боте.
|
||||||
5. **DM-уведомления пользователю** — если Telegram привязан, бот шлёт личные уведомления о ключевых
|
5. **DM-уведомления пользователю** — если Telegram привязан, бот шлёт личные уведомления о ключевых
|
||||||
событиях: «аккаунт активирован», «конфиг отозван админом», «вы заблокированы».
|
событиях: «аккаунт активирован», «конфиг отозван админом», «вы заблокированы».
|
||||||
6. **Восстановление пароля** — если пароль забыт, привязанный пользователь через бота получает
|
6. **Восстановление пароля** — реализовано через passwordless-вход: привязанный пользователь входит
|
||||||
одноразовую ссылку на страницу задания нового пароля (или входит passwordless и меняет пароль в
|
через бота (`/login`) и меняет пароль в настройках (`ChangePasswordCommand`). Без привязки
|
||||||
настройках). Без привязки Telegram восстановление делает только админ.
|
Telegram сброс делает только админ (`ResetUserPasswordCommand`). Отдельная команда бота
|
||||||
|
`/resetpassword` с одноразовой ссылкой на смену пароля — **backlog**, в MVP не реализована.
|
||||||
|
|
||||||
> **Скоуп бота в MVP — просмотр (read-only) по конфигам.** Создание/ротация/отзыв конфигов — только
|
> **Скоуп бота в MVP — просмотр (read-only) по конфигам.** Создание/ротация/отзыв конфигов — только
|
||||||
> на сайте. Полное самообслуживание в боте (создание/отзыв) — в backlog.
|
> на сайте. Полное самообслуживание в боте (создание/отзыв) — в backlog.
|
||||||
@@ -104,18 +105,18 @@ Telegram ──updates──► TelegramBotHostedService (Api)
|
|||||||
|
|
||||||
| Команда / кнопка | Действие | Требует привязки |
|
| Команда / кнопка | Действие | Требует привязки |
|
||||||
| ---------------------- | -------------------------------------------------------------- | ---------------- |
|
| ---------------------- | -------------------------------------------------------------- | ---------------- |
|
||||||
| `/start` | Приветствие + меню (Открыть сайт / Мои конфиги / Войти) | нет |
|
| `/start` | Приветствие + справка по командам | нет |
|
||||||
| `/start link_<t>` | Привязка аккаунта по токену | нет |
|
| `/start link_<t>` | Привязка аккаунта по токену | нет |
|
||||||
| `/start login_<n>` | Подтверждение passwordless-входа | да |
|
| `/start login_<n>` | Подтверждение passwordless-входа (deep-link с сайта) | да |
|
||||||
| «Открыть сайт» | Ссылка на веб-панель (опц. одноразовый auto-login deep link) | нет / да |
|
|
||||||
| `/configs` | Список конфигов | да |
|
| `/configs` | Список конфигов | да |
|
||||||
| `/login` | Инициировать/подтвердить вход | да |
|
|
||||||
| `/resetpassword` | Одноразовая ссылка на смену пароля (восстановление) | да |
|
|
||||||
| `/unlink` | Отвязать Telegram от аккаунта | да |
|
| `/unlink` | Отвязать Telegram от аккаунта | да |
|
||||||
| `/help` | Справка | нет |
|
| `/help` | Справка | нет |
|
||||||
| «Активировать/Отклонить» | (admin) решение по запросу активации | админ по env |
|
| «Активировать/Отклонить» | (admin) решение по запросу активации | админ по env |
|
||||||
| `/requests` | (admin) список ожидающих запросов активации | админ по env |
|
| `/requests` | (admin) список ожидающих запросов активации | админ по env |
|
||||||
|
|
||||||
|
> Passwordless-вход и `/resetpassword` инициируются с сайта (кнопка «Войти через Telegram»),
|
||||||
|
> не отдельной командой бота — см. пункт 6 выше про `/resetpassword` (backlog).
|
||||||
|
|
||||||
## Безопасность
|
## Безопасность
|
||||||
|
|
||||||
- Токены привязки и nonce входа: высокоэнтропийные, **короткоживущие** (≈2–5 мин), **одноразовые**.
|
- Токены привязки и nonce входа: высокоэнтропийные, **короткоживущие** (≈2–5 мин), **одноразовые**.
|
||||||
|
|||||||
Reference in New Issue
Block a user