fix(notification): enable web smtp test with saved password

Allow Web SMTP egress on TCP 587 and reuse the saved channel password only when connection settings match.

Ref: IT-1601
This commit is contained in:
2026-10-18 00:17:25 +03:00
parent 121ebbfbcd
commit 2dd5e318d7
9 changed files with 260 additions and 5 deletions
@@ -0,0 +1,34 @@
using HrynCo.NotificationService.DAL.Abstract.Providers;
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.EF;
using Microsoft.Extensions.DependencyInjection;
namespace HrynCo.NotificationService.Web.IntegrationTests;
public sealed class AdminSmtpEditorViewTests(TemplateDatabaseFixture fixture) : IClassFixture<TemplateDatabaseFixture>
{
[Fact]
public async Task ExistingChannelTestIncludesIdWithoutRenderingSavedPassword()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
using var scope = context.App.Services.CreateScope();
var channel = new EmailChannel
{
ServiceName = "TestService",
EmailChannelType = EmailChannelType.Smtp,
Settings = new SmtpChannelSettings
{
Host = "smtp.example.invalid", Username = "test-user",
Password = "saved-secret-must-not-render", FromEmail = "sender@example.invalid"
}
};
await scope.ServiceProvider.GetRequiredService<IEmailChannelRepository>().AddAsync(channel);
await scope.ServiceProvider.GetRequiredService<NotificationDbContext>().SaveChangesAsync();
var html = await context.Client.GetStringAsync($"/admin/channels/{channel.Id}");
Assert.Contains($"value=\"{channel.Id}\"", html);
Assert.Contains("const channelId = form.querySelector('[name=\"Id\"]').value", html);
Assert.DoesNotContain("saved-secret-must-not-render", html);
}
}
@@ -0,0 +1,112 @@
using System.Reflection;
using System.Text.Json;
using HrynCo.NotificationService.DAL.Abstract.Providers;
using HrynCo.NotificationService.Web.Controllers.Admin;
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace HrynCo.NotificationService.Web.IntegrationTests;
public sealed class AdminSmtpTestControllerTests
{
private static readonly Guid ChannelId = Guid.NewGuid();
[Fact]
public async Task BlankPasswordUsesSavedSecretAndCurrentUnsavedSender()
{
var (controller, recorder) = CreateController();
var response = await controller.TestSmtp(Request(), CancellationToken.None);
Assert.True(Succeeded(response));
Assert.Equal("saved-test-secret", recorder.SentCommand?.Password);
Assert.Equal("changed@example.invalid", recorder.SentCommand?.FromEmail);
Assert.Equal("Changed sender", recorder.SentCommand?.FromName);
Assert.Equal(1, recorder.LookupCount);
Assert.DoesNotContain("saved-test-secret", JsonSerializer.Serialize(((OkObjectResult)response).Value));
}
[Fact]
public async Task ExplicitPasswordOverridesSavedWithoutLookup()
{
var (controller, recorder) = CreateController();
var response = await controller.TestSmtp(Request() with { Password = "typed-test-secret" }, CancellationToken.None);
Assert.True(Succeeded(response));
Assert.Equal("typed-test-secret", recorder.SentCommand?.Password);
Assert.Equal(0, recorder.LookupCount);
}
[Theory]
[InlineData("host")]
[InlineData("port")]
[InlineData("username")]
[InlineData("ssl")]
public async Task ChangedConnectionRequiresExplicitPassword(string field)
{
var (controller, recorder) = CreateController();
var request = field switch
{
"host" => Request() with { Host = "other.example.invalid" },
"port" => Request() with { Port = 2525 },
"username" => Request() with { Username = "other-user" },
_ => Request() with { UseSsl = false }
};
var response = await controller.TestSmtp(request, CancellationToken.None);
Assert.False(Succeeded(response));
Assert.Null(recorder.SentCommand);
}
[Fact]
public async Task UnknownChannelDoesNotSend()
{
var (controller, recorder) = CreateController();
recorder.Channel = null;
var response = await controller.TestSmtp(Request(), CancellationToken.None);
Assert.False(Succeeded(response));
Assert.Null(recorder.SentCommand);
}
[Fact]
public async Task NewChannelKeepsAdHocTestBehavior()
{
var (controller, recorder) = CreateController();
var response = await controller.TestSmtp(Request() with { ChannelId = null }, CancellationToken.None);
Assert.True(Succeeded(response));
Assert.Equal(string.Empty, recorder.SentCommand?.Password);
Assert.Equal(0, recorder.LookupCount);
}
private static (AdminChannelsController Controller, RecordingAdminSmtpMediator Recorder) CreateController()
{
var mediator = DispatchProxy.Create<IMediator, RecordingAdminSmtpMediator>();
var recorder = (RecordingAdminSmtpMediator)mediator;
recorder.Channel = new EmailChannel
{
Id = ChannelId,
ServiceName = "TestService",
EmailChannelType = EmailChannelType.Smtp,
Settings = new SmtpChannelSettings
{
Host = "smtp.example.invalid", Port = 587,
Username = "test-user", Password = "saved-test-secret", UseSsl = true
}
};
return (new AdminChannelsController(mediator), recorder);
}
private static TestSmtpRequest Request() => new(
"smtp.example.invalid", 587, "test-user", "", true,
"changed@example.invalid", "Changed sender", "recipient@example.invalid", ChannelId);
private static bool Succeeded(IActionResult response)
{
var body = JsonSerializer.Serialize(((OkObjectResult)response).Value);
return JsonDocument.Parse(body).RootElement.GetProperty("success").GetBoolean();
}
}
@@ -0,0 +1,43 @@
using System.Reflection;
using HrynCo.NotificationService.DAL.Abstract.Providers;
using HrynCo.NotificationService.Services.Core;
using HrynCo.NotificationService.Services.EmailChannels.Get;
using HrynCo.NotificationService.Services.EmailChannels.TestSmtp;
using MediatR;
using ServiceUnit = HrynCo.NotificationService.Services.Core.Unit;
namespace HrynCo.NotificationService.Web.IntegrationTests;
public class RecordingAdminSmtpMediator : DispatchProxy
{
public EmailChannel? Channel { get; set; }
public TestSmtpCommand? SentCommand { get; private set; }
public int LookupCount { get; private set; }
protected override object Invoke(MethodInfo? targetMethod, object?[]? args)
{
if (targetMethod?.Name != nameof(IMediator.Send) || args?.Length is null or 0)
throw new NotSupportedException();
return args[0] switch
{
GetEmailChannelQuery => RecordLookup(),
TestSmtpCommand command => RecordSend(command),
_ => throw new NotSupportedException()
};
}
private Task<ServiceResult<EmailChannel>> RecordLookup()
{
LookupCount++;
return Task.FromResult(Channel is null
? ServiceResult<EmailChannel>.Failure("Channel not found.")
: ServiceResult<EmailChannel>.Success(Channel));
}
private Task<ServiceResult<ServiceUnit>> RecordSend(TestSmtpCommand command)
{
SentCommand = command;
return Task.FromResult(ServiceResult<ServiceUnit>.Success(ServiceUnit.Value));
}
}