From ee6b8a35b843e600c00a22915edfa486ff5cbb20 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 23 Jul 2026 01:05:02 +0300 Subject: [PATCH] Enhance node update functionality to include base address - Added `BaseAddress` property to `UpdateNodeCommand` and `UpdateNodeBody` for improved node management. - Implemented validation for the base address in `UpdateNodeCommandHandler`, ensuring it is a valid absolute URI. - Updated `Node` class to support address updates, including logic to invalidate cached clients on address changes. - Enhanced frontend components to handle base address input in the node editing dialog and API requests. - Updated validation rules to enforce base address requirements in `UpdateNodeCommandValidator`. --- .../PnvPanel.Api/Endpoints/NodeEndpoints.cs | 2 + .../Admin/Nodes/UpdateNodeCommand.cs | 1 + .../Admin/Nodes/UpdateNodeCommandHandler.cs | 29 ++- .../Admin/Nodes/UpdateNodeCommandValidator.cs | 1 + backend/src/PnvPanel.Domain/Nodes/Node.cs | 8 + .../Nodes/UpdateNodeCommandHandlerTests.cs | 212 ++++++++++++++++++ .../features/admin/nodes/EditNodeDialog.tsx | 21 +- frontend/src/features/admin/nodes/api.ts | 3 +- frontend/src/shared/api/schema.gen.ts | 1 + frontend/src/shared/lib/i18n.ts | 2 + 10 files changed, 270 insertions(+), 10 deletions(-) create mode 100644 backend/tests/PnvPanel.Application.Tests/Admin/Nodes/UpdateNodeCommandHandlerTests.cs diff --git a/backend/src/PnvPanel.Api/Endpoints/NodeEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/NodeEndpoints.cs index 354661d..672edfe 100644 --- a/backend/src/PnvPanel.Api/Endpoints/NodeEndpoints.cs +++ b/backend/src/PnvPanel.Api/Endpoints/NodeEndpoints.cs @@ -52,6 +52,7 @@ public static class NodeEndpoints var command = new UpdateNodeCommand( id, body.Name, + body.BaseAddress, body.Location, body.IsEnabled, body.Username, @@ -94,6 +95,7 @@ public static class NodeEndpoints public sealed record UpdateNodeBody( string Name, + string BaseAddress, string? Location, bool IsEnabled, string? Username, diff --git a/backend/src/PnvPanel.Application/Admin/Nodes/UpdateNodeCommand.cs b/backend/src/PnvPanel.Application/Admin/Nodes/UpdateNodeCommand.cs index e86404c..d0f020f 100644 --- a/backend/src/PnvPanel.Application/Admin/Nodes/UpdateNodeCommand.cs +++ b/backend/src/PnvPanel.Application/Admin/Nodes/UpdateNodeCommand.cs @@ -6,6 +6,7 @@ namespace PnvPanel.Application.Admin.Nodes; public sealed record UpdateNodeCommand( Guid NodeId, string Name, + string BaseAddress, string? Location, bool IsEnabled, string? Username, diff --git a/backend/src/PnvPanel.Application/Admin/Nodes/UpdateNodeCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Nodes/UpdateNodeCommandHandler.cs index 8562431..b13a21a 100644 --- a/backend/src/PnvPanel.Application/Admin/Nodes/UpdateNodeCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Admin/Nodes/UpdateNodeCommandHandler.cs @@ -26,24 +26,43 @@ public sealed class UpdateNodeCommandHandler( if (node is null) return Result.Failure(NodeErrors.NotFound); + if (!Uri.TryCreate(command.BaseAddress, UriKind.Absolute, out var baseAddress)) + return Result.Failure(NodeErrors.InvalidBaseAddress); + + var addressChanged = baseAddress != node.BaseAddress; + if (addressChanged) + { + var validation = gateway.ValidateBaseAddress(baseAddress); + if (!validation.IsSuccess) + return Result.Failure(validation.Error); + } + node.UpdateDetails(command.Name, command.Location); + if (addressChanged) + node.UpdateAddress(baseAddress); + if (command.IsEnabled) node.Enable(); else node.Disable(); - if ( + var credentialsChanged = !string.IsNullOrWhiteSpace(command.Username) - && !string.IsNullOrWhiteSpace(command.Password) - ) + && !string.IsNullOrWhiteSpace(command.Password); + if (credentialsChanged) { node.UpdateCredentials( - new NodeCredentials(command.Username, secretProtector.Protect(command.Password)) + new NodeCredentials(command.Username!, secretProtector.Protect(command.Password!)) ); - gateway.InvalidateClient(node.Id); } + // Клиент в гейтвее закэширован per-node (адрес+креденшлы захвачены при первом создании) — + // при смене любого из них старый закэшированный клиент нужно выбросить, иначе гейтвей + // продолжит стучаться по старому адресу/с старым паролем до перезапуска процесса. + if (addressChanged || credentialsChanged) + gateway.InvalidateClient(node.Id); + dbContext.AuditLogs.Add( AuditLog.Create( currentUser.UserId, diff --git a/backend/src/PnvPanel.Application/Admin/Nodes/UpdateNodeCommandValidator.cs b/backend/src/PnvPanel.Application/Admin/Nodes/UpdateNodeCommandValidator.cs index a5c06d7..86508da 100644 --- a/backend/src/PnvPanel.Application/Admin/Nodes/UpdateNodeCommandValidator.cs +++ b/backend/src/PnvPanel.Application/Admin/Nodes/UpdateNodeCommandValidator.cs @@ -7,6 +7,7 @@ public sealed class UpdateNodeCommandValidator : AbstractValidator x.Name).NotEmpty().MaximumLength(100); + RuleFor(x => x.BaseAddress).NotEmpty().MaximumLength(500); RuleFor(x => x.Location).MaximumLength(100); RuleFor(x => x.Username).MaximumLength(200); } diff --git a/backend/src/PnvPanel.Domain/Nodes/Node.cs b/backend/src/PnvPanel.Domain/Nodes/Node.cs index c9c00df..02842b6 100644 --- a/backend/src/PnvPanel.Domain/Nodes/Node.cs +++ b/backend/src/PnvPanel.Domain/Nodes/Node.cs @@ -49,6 +49,14 @@ public sealed class Node : Entity Location = location; } + public void UpdateAddress(Uri baseAddress) + { + if (!baseAddress.IsAbsoluteUri) + throw new DomainException("Адрес ноды должен быть абсолютным URI."); + + BaseAddress = baseAddress; + } + public void UpdateCredentials(NodeCredentials credentials) => Credentials = credentials; public void Enable() => IsEnabled = true; diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Nodes/UpdateNodeCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Nodes/UpdateNodeCommandHandlerTests.cs new file mode 100644 index 0000000..ef83d3d --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Nodes/UpdateNodeCommandHandlerTests.cs @@ -0,0 +1,212 @@ +using NSubstitute; +using PnvPanel.Application.Admin.Nodes; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Models; +using PnvPanel.Application.Tests.TestSupport; +using PnvPanel.Domain.Nodes; +using Xunit; + +namespace PnvPanel.Application.Tests.Admin.Nodes; + +public class UpdateNodeCommandHandlerTests +{ + private readonly IXuiPanelGateway _gateway = Substitute.For(); + private readonly ISecretProtector _secretProtector = Substitute.For(); + private readonly ICurrentUser _currentUser = Substitute.For(); + + private static Node SeedNode( + string baseAddress = "https://node1.example.com", + string username = "admin", + string protectedPassword = "protected-old-password" + ) => Node.Register("node-1", new Uri(baseAddress), new NodeCredentials(username, protectedPassword), null); + + [Fact] + public async Task Handle_WhenAddressChanged_ValidatesAndUpdatesAddressAndInvalidatesClient() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var node = SeedNode(); + dbContext.Nodes.Add(node); + await dbContext.SaveChangesAsync(CancellationToken.None); + + _gateway.ValidateBaseAddress(Arg.Any()).Returns(Result.Success()); + + var handler = new UpdateNodeCommandHandler(dbContext, _gateway, _secretProtector, _currentUser); + + var command = new UpdateNodeCommand( + node.Id, + "node-1", + "https://node1-new.example.com", + null, + true, + null, + null + ); + + var result = await handler.Handle(command, CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal("https://node1-new.example.com/", node.BaseAddress.ToString()); + _gateway.Received(1).ValidateBaseAddress(Arg.Is(u => u!.Host == "node1-new.example.com")); + _gateway.Received(1).InvalidateClient(node.Id); + } + + [Fact] + public async Task Handle_WhenAddressUnchanged_DoesNotValidateOrInvalidateClient() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var node = SeedNode(); + dbContext.Nodes.Add(node); + await dbContext.SaveChangesAsync(CancellationToken.None); + + var handler = new UpdateNodeCommandHandler(dbContext, _gateway, _secretProtector, _currentUser); + + var command = new UpdateNodeCommand( + node.Id, + "node-1-renamed", + "https://node1.example.com", + "eu", + true, + null, + null + ); + + var result = await handler.Handle(command, CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal("node-1-renamed", node.Name); + _gateway.DidNotReceive().ValidateBaseAddress(Arg.Any()); + _gateway.DidNotReceive().InvalidateClient(Arg.Any()); + } + + [Fact] + public async Task Handle_WithInvalidBaseAddress_ReturnsValidationErrorWithoutChangingNode() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var node = SeedNode(); + dbContext.Nodes.Add(node); + await dbContext.SaveChangesAsync(CancellationToken.None); + + var handler = new UpdateNodeCommandHandler(dbContext, _gateway, _secretProtector, _currentUser); + + var command = new UpdateNodeCommand(node.Id, "node-1", "not-a-uri", null, true, null, null); + + var result = await handler.Handle(command, CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal(NodeErrors.InvalidBaseAddress, result.Error); + Assert.Equal("https://node1.example.com/", node.BaseAddress.ToString()); + _gateway.DidNotReceive().ValidateBaseAddress(Arg.Any()); + } + + [Fact] + public async Task Handle_WhenGatewayRejectsNewBaseAddress_ReturnsFailureWithoutChangingNode() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var node = SeedNode(); + dbContext.Nodes.Add(node); + await dbContext.SaveChangesAsync(CancellationToken.None); + + var error = Error.Validation("Nodes.SchemeNotAllowed", "Разрешён только HTTPS."); + _gateway.ValidateBaseAddress(Arg.Any()).Returns(Result.Failure(error)); + + var handler = new UpdateNodeCommandHandler(dbContext, _gateway, _secretProtector, _currentUser); + + var command = new UpdateNodeCommand( + node.Id, + "node-1", + "http://node1-new.example.com", + null, + true, + null, + null + ); + + var result = await handler.Handle(command, CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal(error, result.Error); + Assert.Equal("https://node1.example.com/", node.BaseAddress.ToString()); + _gateway.DidNotReceive().InvalidateClient(Arg.Any()); + } + + [Fact] + public async Task Handle_WhenUsernameAndPasswordProvided_UpdatesCredentialsAndInvalidatesClient() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var node = SeedNode(); + dbContext.Nodes.Add(node); + await dbContext.SaveChangesAsync(CancellationToken.None); + + _secretProtector.Protect("new-password").Returns("protected-new-password"); + + var handler = new UpdateNodeCommandHandler(dbContext, _gateway, _secretProtector, _currentUser); + + var command = new UpdateNodeCommand( + node.Id, + "node-1", + "https://node1.example.com", + null, + true, + "new-admin", + "new-password" + ); + + var result = await handler.Handle(command, CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal("new-admin", node.Credentials.Username); + Assert.Equal("protected-new-password", node.Credentials.ProtectedPassword); + _gateway.Received(1).InvalidateClient(node.Id); + } + + [Fact] + public async Task Handle_WhenOnlyUsernameProvided_DoesNotChangeCredentials() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var node = SeedNode(); + dbContext.Nodes.Add(node); + await dbContext.SaveChangesAsync(CancellationToken.None); + + var handler = new UpdateNodeCommandHandler(dbContext, _gateway, _secretProtector, _currentUser); + + var command = new UpdateNodeCommand( + node.Id, + "node-1", + "https://node1.example.com", + null, + true, + "new-admin", + null + ); + + var result = await handler.Handle(command, CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal("admin", node.Credentials.Username); + Assert.Equal("protected-old-password", node.Credentials.ProtectedPassword); + _gateway.DidNotReceive().InvalidateClient(Arg.Any()); + } + + [Fact] + public async Task Handle_WhenNodeNotFound_ReturnsNotFound() + { + using var dbContext = InMemoryDbContextFactory.Create(); + + var handler = new UpdateNodeCommandHandler(dbContext, _gateway, _secretProtector, _currentUser); + + var command = new UpdateNodeCommand( + Guid.NewGuid(), + "node-1", + "https://node1.example.com", + null, + true, + null, + null + ); + + var result = await handler.Handle(command, CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal(NodeErrors.NotFound, result.Error); + } +} diff --git a/frontend/src/features/admin/nodes/EditNodeDialog.tsx b/frontend/src/features/admin/nodes/EditNodeDialog.tsx index 4eebc90..d5782ad 100644 --- a/frontend/src/features/admin/nodes/EditNodeDialog.tsx +++ b/frontend/src/features/admin/nodes/EditNodeDialog.tsx @@ -14,13 +14,15 @@ export function EditNodeDialog({ node, open, onOpenChange }: { node: NodeDto; op const { t } = useTranslation() const queryClient = useQueryClient() const [name, setName] = useState(node.name) + const [baseAddress, setBaseAddress] = useState(node.baseAddress) const [location, setLocation] = useState(node.location ?? '') const [isEnabled, setIsEnabled] = useState(node.isEnabled) - const [username, setUsername] = useState('') + const [username, setUsername] = useState(node.username) const [password, setPassword] = useState('') const mutation = useMutation({ - mutationFn: () => updateNode(node.id, name.trim(), location.trim() || undefined, isEnabled, username.trim() || undefined, password || undefined), + mutationFn: () => + updateNode(node.id, name.trim(), baseAddress.trim(), location.trim() || undefined, isEnabled, username.trim() || undefined, password || undefined), onSuccess: async () => { toast.success(t('admin.nodes.updated')) await queryClient.invalidateQueries({ queryKey: ['admin-nodes'] }) @@ -46,6 +48,16 @@ export function EditNodeDialog({ node, open, onOpenChange }: { node: NodeDto; op setName(e.target.value)} required /> +
+ + setBaseAddress(e.target.value)} + required + /> +
setLocation(e.target.value)} /> @@ -62,7 +74,7 @@ export function EditNodeDialog({ node, open, onOpenChange }: { node: NodeDto; op
setPassword(e.target.value)} />
- diff --git a/frontend/src/features/admin/nodes/api.ts b/frontend/src/features/admin/nodes/api.ts index f796c87..9dc6624 100644 --- a/frontend/src/features/admin/nodes/api.ts +++ b/frontend/src/features/admin/nodes/api.ts @@ -12,6 +12,7 @@ export function registerNode(name: string, baseAddress: string, username: string export function updateNode( id: string, name: string, + baseAddress: string, location: string | undefined, isEnabled: boolean, username: string | undefined, @@ -19,7 +20,7 @@ export function updateNode( ) { return apiRequest(`/admin/nodes/${id}`, { method: 'PUT', - body: { name, location: location ?? null, isEnabled, username: username ?? null, password: password ?? null }, + body: { name, baseAddress, location: location ?? null, isEnabled, username: username ?? null, password: password ?? null }, }) } diff --git a/frontend/src/shared/api/schema.gen.ts b/frontend/src/shared/api/schema.gen.ts index 5b6eaa0..c7ac988 100644 --- a/frontend/src/shared/api/schema.gen.ts +++ b/frontend/src/shared/api/schema.gen.ts @@ -3482,6 +3482,7 @@ export interface components { }; UpdateNodeBody: { name: string; + baseAddress: string; location: null | string; isEnabled: boolean; username: null | string; diff --git a/frontend/src/shared/lib/i18n.ts b/frontend/src/shared/lib/i18n.ts index cf317d8..2b42648 100644 --- a/frontend/src/shared/lib/i18n.ts +++ b/frontend/src/shared/lib/i18n.ts @@ -443,6 +443,7 @@ const resources = { allowedRoles: 'Доступно ролям', isPublishedLabel: 'Опубликовать инбаунд', optional: 'необязательно', + passwordKeepUnchanged: 'оставьте пустым, чтобы не менять', }, apps: { create: 'Добавить приложение', @@ -1011,6 +1012,7 @@ const resources = { allowedRoles: 'Allowed for roles', isPublishedLabel: 'Publish inbound', optional: 'optional', + passwordKeepUnchanged: 'leave blank to keep unchanged', }, apps: { create: 'Add app',