diff --git a/HrynCo.NotificationService.Web.IntegrationTests/AdminSmtpSaveTests.cs b/HrynCo.NotificationService.Web.IntegrationTests/AdminSmtpSaveTests.cs new file mode 100644 index 0000000..89081e6 --- /dev/null +++ b/HrynCo.NotificationService.Web.IntegrationTests/AdminSmtpSaveTests.cs @@ -0,0 +1,136 @@ +using System.Net; +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 AdminSmtpSaveTests(TemplateDatabaseFixture fixture) : IClassFixture +{ + [Fact] + public async Task BlankPasswordKeepsSavedValueWhenOnlySenderChanges() + { + await using var context = await DefinitionApiTestContext.CreateAsync(fixture); + var channel = await SeedChannel(context); + using var client = context.App.CreateClient(new() { AllowAutoRedirect = false }); + var fields = TemplateEditorTestFields.Read(await client.GetStringAsync($"/admin/channels/{channel.Id}")); + fields["UseSsl"] = "true"; + fields["Password"] = ""; + fields["FromName"] = "Updated sender"; + + var response = await client.PostAsync("/admin/channels/save", new FormUrlEncodedContent(fields)); + + Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); + var saved = await ReadSettings(context, channel.Id); + Assert.Equal("saved-test-secret", saved.Password); + Assert.Equal("Updated sender", saved.FromName); + } + + [Theory] + [InlineData("Host", "other.example.invalid")] + [InlineData("Port", "2525")] + [InlineData("Username", "other-user")] + [InlineData("UseSsl", "false")] + public async Task ChangedConnectionWithBlankPasswordIsRejected(string field, string value) + { + await using var context = await DefinitionApiTestContext.CreateAsync(fixture); + var channel = await SeedChannel(context); + using var client = context.App.CreateClient(new() { AllowAutoRedirect = false }); + var fields = TemplateEditorTestFields.Read(await client.GetStringAsync($"/admin/channels/{channel.Id}")); + fields["UseSsl"] = "true"; + fields["Password"] = ""; + fields[field] = value; + + var response = await client.PostAsync("/admin/channels/save", new FormUrlEncodedContent(fields)); + var html = await response.Content.ReadAsStringAsync(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains("Enter the password to save changed SMTP connection settings.", html); + Assert.DoesNotContain("saved-test-secret", html); + var saved = await ReadSettings(context, channel.Id); + Assert.Equal("saved-test-secret", saved.Password); + Assert.Equal("smtp.example.invalid", saved.Host); + } + + [Fact] + public async Task ExplicitPasswordCanReplaceSavedValue() + { + await using var context = await DefinitionApiTestContext.CreateAsync(fixture); + var channel = await SeedChannel(context); + using var client = context.App.CreateClient(new() { AllowAutoRedirect = false }); + var fields = TemplateEditorTestFields.Read(await client.GetStringAsync($"/admin/channels/{channel.Id}")); + fields["UseSsl"] = "true"; + fields["Password"] = "replacement-test-secret"; + fields["Host"] = "other.example.invalid"; + + var response = await client.PostAsync("/admin/channels/save", new FormUrlEncodedContent(fields)); + + Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); + var saved = await ReadSettings(context, channel.Id); + Assert.Equal("replacement-test-secret", saved.Password); + Assert.Equal("other.example.invalid", saved.Host); + } + + [Fact] + public async Task NewChannelStillRequiresPassword() + { + await using var context = await DefinitionApiTestContext.CreateAsync(fixture); + using var client = context.App.CreateClient(new() { AllowAutoRedirect = false }); + var fields = TemplateEditorTestFields.Read(await client.GetStringAsync("/admin/channels/create")); + fields["ServiceName"] = "NewTestService"; + fields["Host"] = "smtp.example.invalid"; + fields["Username"] = "test-user"; + fields["FromEmail"] = "sender@example.invalid"; + fields["FromName"] = "Sender"; + fields["Password"] = ""; + + var response = await client.PostAsync("/admin/channels/save", new FormUrlEncodedContent(fields)); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains("The Password field is required.", await response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task InvalidFormDoesNotRenderEnteredPassword() + { + await using var context = await DefinitionApiTestContext.CreateAsync(fixture); + var channel = await SeedChannel(context); + using var client = context.App.CreateClient(new() { AllowAutoRedirect = false }); + var fields = TemplateEditorTestFields.Read(await client.GetStringAsync($"/admin/channels/{channel.Id}")); + fields["Password"] = "replacement-test-secret"; + fields["FromEmail"] = "invalid-address"; + + var response = await client.PostAsync("/admin/channels/save", new FormUrlEncodedContent(fields)); + var html = await response.Content.ReadAsStringAsync(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.DoesNotContain("replacement-test-secret", html); + Assert.Equal("saved-test-secret", (await ReadSettings(context, channel.Id)).Password); + } + + private static async Task SeedChannel(DefinitionApiTestContext context) + { + using var scope = context.App.Services.CreateScope(); + var channel = new EmailChannel + { + ServiceName = "TestService", EmailChannelType = EmailChannelType.Smtp, + Settings = new SmtpChannelSettings + { + Host = "smtp.example.invalid", Port = 587, Username = "test-user", + Password = "saved-test-secret", UseSsl = true, + FromEmail = "sender@example.invalid", FromName = "Original sender" + } + }; + await scope.ServiceProvider.GetRequiredService().AddAsync(channel); + await scope.ServiceProvider.GetRequiredService().SaveChangesAsync(); + return channel; + } + + private static async Task ReadSettings(DefinitionApiTestContext context, Guid id) + { + using var scope = context.App.Services.CreateScope(); + var channel = await scope.ServiceProvider.GetRequiredService().GetByIdAsync(id); + return Assert.IsType(channel?.Settings); + } +} diff --git a/HrynCo.NotificationService.Web/Controllers/Admin/AdminChannelsController.cs b/HrynCo.NotificationService.Web/Controllers/Admin/AdminChannelsController.cs index 792e83c..d7eb033 100644 --- a/HrynCo.NotificationService.Web/Controllers/Admin/AdminChannelsController.cs +++ b/HrynCo.NotificationService.Web/Controllers/Admin/AdminChannelsController.cs @@ -65,7 +65,6 @@ public class AdminChannelsController(IMediator mediator) : Controller Host = smtp.Host, Port = smtp.Port, Username = smtp.Username, - Password = smtp.Password, UseSsl = smtp.UseSsl, FromEmail = smtp.FromEmail, FromName = smtp.FromName @@ -80,14 +79,21 @@ public class AdminChannelsController(IMediator mediator) : Controller public async Task Save(EmailChannelEditViewModel model, CancellationToken ct) { if (!ModelState.IsValid) - return View("Edit", model); + return EditWithErrors(model); + + var (password, passwordError) = await ResolvePasswordForSave(model, ct); + if (passwordError is not null) + { + ModelState.AddModelError(nameof(model.Password), passwordError); + return EditWithErrors(model); + } var smtpSettings = new SmtpChannelSettings { Host = model.Host, Port = model.Port, Username = model.Username, - Password = model.Password, + Password = password, UseSsl = model.UseSsl, FromEmail = model.FromEmail, FromName = model.FromName @@ -109,7 +115,7 @@ public class AdminChannelsController(IMediator mediator) : Controller if (!result.IsSuccess) { ModelState.AddModelError("", result.Error?.Message ?? "Failed to create channel."); - return View("Edit", model); + return EditWithErrors(model); } } else @@ -127,13 +133,36 @@ public class AdminChannelsController(IMediator mediator) : Controller if (!result.IsSuccess) { ModelState.AddModelError("", result.Error?.Message ?? "Failed to update channel."); - return View("Edit", model); + return EditWithErrors(model); } } return RedirectToAction(nameof(Index)); } + private async Task<(string Password, string? Error)> ResolvePasswordForSave( + EmailChannelEditViewModel model, CancellationToken ct) + { + if (!string.IsNullOrEmpty(model.Password)) + return (model.Password, null); + if (model.IsNew) + return ("", "The Password field is required."); + + var channelResult = await mediator.Send(new GetEmailChannelQuery(model.Id), ct); + if (!channelResult.IsSuccess || channelResult.Result?.Settings is not SmtpChannelSettings saved) + return ("", "SMTP channel not found."); + if (!MatchesSavedSmtpConnection(model.Host, model.Port, model.Username, model.UseSsl, saved)) + return ("", "Enter the password to save changed SMTP connection settings."); + + return (saved.Password, null); + } + + private IActionResult EditWithErrors(EmailChannelEditViewModel model) + { + model.Password = null; + return View("Edit", model); + } + // POST /admin/channels/test-smtp [HttpPost("test-smtp")] public async Task TestSmtp([FromBody] TestSmtpRequest request, CancellationToken ct) @@ -145,7 +174,7 @@ public class AdminChannelsController(IMediator mediator) : Controller if (!channelResult.IsSuccess || channelResult.Result?.Settings is not SmtpChannelSettings saved) return Ok(new { success = false, message = "SMTP channel not found." }); - if (!MatchesSavedSmtpConnection(request, saved)) + if (!MatchesSavedSmtpConnection(request.Host, request.Port, request.Username, request.UseSsl, saved)) return Ok(new { success = false, message = "Enter the password to test changed SMTP connection settings." }); password = saved.Password; @@ -161,11 +190,12 @@ public class AdminChannelsController(IMediator mediator) : Controller return Ok(new { success = true, message = $"Test email sent to {request.ToEmail}." }); } - private static bool MatchesSavedSmtpConnection(TestSmtpRequest request, SmtpChannelSettings saved) => - string.Equals(request.Host, saved.Host, StringComparison.OrdinalIgnoreCase) - && request.Port == saved.Port - && string.Equals(request.Username, saved.Username, StringComparison.Ordinal) - && request.UseSsl == saved.UseSsl; + private static bool MatchesSavedSmtpConnection( + string host, int port, string username, bool useSsl, SmtpChannelSettings saved) => + string.Equals(host, saved.Host, StringComparison.OrdinalIgnoreCase) + && port == saved.Port + && string.Equals(username, saved.Username, StringComparison.Ordinal) + && useSsl == saved.UseSsl; // POST /admin/channels/{id}/test [HttpPost("{id:guid}/test")] diff --git a/HrynCo.NotificationService.Web/Controllers/Admin/ViewModels/EmailChannelEditViewModel.cs b/HrynCo.NotificationService.Web/Controllers/Admin/ViewModels/EmailChannelEditViewModel.cs index 9bf9731..da6685e 100644 --- a/HrynCo.NotificationService.Web/Controllers/Admin/ViewModels/EmailChannelEditViewModel.cs +++ b/HrynCo.NotificationService.Web/Controllers/Admin/ViewModels/EmailChannelEditViewModel.cs @@ -34,8 +34,7 @@ public class EmailChannelEditViewModel [Required] public string Username { get; set; } = ""; - [Required] - public string Password { get; set; } = ""; + public string? Password { get; set; } public bool UseSsl { get; set; } = true; diff --git a/HrynCo.NotificationService.Web/Views/AdminChannels/Edit.cshtml b/HrynCo.NotificationService.Web/Views/AdminChannels/Edit.cshtml index 0cd330f..ad8691b 100644 --- a/HrynCo.NotificationService.Web/Views/AdminChannels/Edit.cshtml +++ b/HrynCo.NotificationService.Web/Views/AdminChannels/Edit.cshtml @@ -94,6 +94,10 @@ + @if (!Model.IsNew) + { +
Leave blank to keep the saved password when SMTP connection settings are unchanged.
+ } diff --git a/docs/email-template-definition-api.md b/docs/email-template-definition-api.md index ee404c3..67dbad8 100644 --- a/docs/email-template-definition-api.md +++ b/docs/email-template-definition-api.md @@ -75,6 +75,8 @@ The service/catalog lookup, validation, revision check, explicit removals, trans In the admin channel editor, **Send Test Email** tests the form's current values without saving them. An explicitly entered password takes precedence. When the password field is blank for an existing channel, the Web server reads its saved password only if host, port, username, and SSL/TLS mode still match the saved SMTP connection. Changed connection settings require an explicit password. The saved password is not placed in the page or test response. A new channel keeps the ad-hoc test behavior, including unauthenticated SMTP when username and password are empty. +Saving an existing channel follows the same password rule: a blank password keeps the saved value when host, port, username, and SSL/TLS mode are unchanged, so sender name/address and limit edits do not require re-entering it. A changed SMTP connection needs an explicitly entered password. Creating a channel in the admin editor still requires a password. Validation errors redisplay the editor without rendering the saved or submitted password. + - `GET /api/v1/email-channels/all` enumerates **all** channels, including channels whose exact `serviceName` has no service row. - Existing `GET /api/v1/email-channels?serviceName={exactName}`, GET by ID, POST, PUT by ID and DELETE by ID remain available. Create returns 201/Location and its existing GUID response; update/delete return 200/Unit. - Create supports SMTP (`channelType: 1`). Request/response `settings` explicitly maps `host`, `port`, `username`, `password`, `useSsl`, `fromEmail`, and `fromName`; reads no longer lose derived SMTP fields through an abstract JSON type. Host/fromEmail and a valid port are required. The optional empty username/password support unauthenticated development SMTP.