Merge branch 'feature/smtp-config-tls-credentials': make SMTP TlsMode + Credentials configurable
Closes the gap found while debugging notification delivery: SmtpConfiguration has TlsMode + Credentials fields, but no non-SQL path could set them. - UpdateSmtpConfigCommand carries optional TlsMode + Credentials (nullable, preserve-if-null in HandleUpdateSmtpConfig — non-breaking for existing 5-arg callers). - CLI 'notification smtp update' gains optional --tls-mode (validated None/StartTLS/SSL) and --credentials. - Central UI SMTP form gains a TLS Mode select (None/StartTLS/SSL) — previously the form had no TlsMode field at all. - docs/test_infra/test_infra_smtp.md: replaced the invalid AuthMode:None example with a working Basic config (TlsMode None, dummy credentials); corrected the prose (delivery requires Basic/OAuth2, no anonymous mode) and noted the scadalink-smtp container hostname for in-cluster use. 4 commits, 13 new tests. Full solution green.
This commit is contained in:
@@ -29,18 +29,30 @@ For `appsettings.Development.json` (Notification Service):
|
|||||||
"Smtp": {
|
"Smtp": {
|
||||||
"Server": "localhost",
|
"Server": "localhost",
|
||||||
"Port": 1025,
|
"Port": 1025,
|
||||||
"AuthMode": "None",
|
"AuthMode": "Basic",
|
||||||
|
"Credentials": "test:test",
|
||||||
|
"TlsMode": "None",
|
||||||
"FromAddress": "scada-notifications@company.com",
|
"FromAddress": "scada-notifications@company.com",
|
||||||
"ConnectionTimeout": 30
|
"ConnectionTimeout": 30
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Since `MP_SMTP_AUTH_ACCEPT_ANY` is enabled, the Notification Service can use any auth mode:
|
> **`Server` host**: use `localhost` only when the Notification Service runs directly on
|
||||||
- **No auth**: Connect directly, no credentials needed.
|
> the host. When it runs inside the docker cluster, set `Server` to the container name
|
||||||
- **Basic Auth**: Any username/password will be accepted (useful for testing the auth code path without a real server).
|
> `scadalink-smtp` — the cluster compose stack and the infra compose stack share the
|
||||||
|
> `scadalink-net` network, so the container is reachable by name.
|
||||||
|
|
||||||
|
The delivery service (`MailKitSmtpClientWrapper`) only accepts `Basic` or `OAuth2` —
|
||||||
|
there is no "no auth" mode — so the working config above uses `Basic`:
|
||||||
|
- **Basic Auth**: `MP_SMTP_AUTH_ACCEPT_ANY` makes Mailpit accept any `username:password`,
|
||||||
|
so use a throwaway value such as `test:test`. This exercises the real auth code path
|
||||||
|
without a real server.
|
||||||
- **OAuth2**: Not supported by Mailpit. For OAuth2 testing, use a real Microsoft 365 tenant.
|
- **OAuth2**: Not supported by Mailpit. For OAuth2 testing, use a real Microsoft 365 tenant.
|
||||||
|
|
||||||
|
`TlsMode` **must** be `None`: Mailpit on port 1025 is plain SMTP and does not offer
|
||||||
|
STARTTLS. `StartTLS` or `SSL` would fail the connection.
|
||||||
|
|
||||||
## Mailpit API
|
## Mailpit API
|
||||||
|
|
||||||
Mailpit exposes a REST API at `http://localhost:8025/api` for programmatic access:
|
Mailpit exposes a REST API at `http://localhost:8025/api` for programmatic access:
|
||||||
|
|||||||
@@ -69,33 +69,72 @@ public static class NotificationCommands
|
|||||||
});
|
});
|
||||||
group.Add(listCmd);
|
group.Add(listCmd);
|
||||||
|
|
||||||
var idOption = new Option<int>("--id") { Description = "SMTP config ID", Required = true };
|
|
||||||
var serverOption = new Option<string>("--server") { Description = "SMTP server", Required = true };
|
|
||||||
var portOption = new Option<int>("--port") { Description = "SMTP port", Required = true };
|
|
||||||
var authModeOption = new Option<string>("--auth-mode") { Description = "Auth mode", Required = true };
|
|
||||||
var fromOption = new Option<string>("--from-address") { Description = "From email address", Required = true };
|
|
||||||
var updateCmd = new Command("update") { Description = "Update SMTP configuration" };
|
var updateCmd = new Command("update") { Description = "Update SMTP configuration" };
|
||||||
updateCmd.Add(idOption);
|
updateCmd.Add(SmtpIdOption);
|
||||||
updateCmd.Add(serverOption);
|
updateCmd.Add(SmtpServerOption);
|
||||||
updateCmd.Add(portOption);
|
updateCmd.Add(SmtpPortOption);
|
||||||
updateCmd.Add(authModeOption);
|
updateCmd.Add(SmtpAuthModeOption);
|
||||||
updateCmd.Add(fromOption);
|
updateCmd.Add(SmtpFromOption);
|
||||||
|
updateCmd.Add(SmtpTlsModeOption);
|
||||||
|
updateCmd.Add(SmtpCredentialsOption);
|
||||||
updateCmd.SetAction(async (ParseResult result) =>
|
updateCmd.SetAction(async (ParseResult result) =>
|
||||||
{
|
{
|
||||||
var id = result.GetValue(idOption);
|
|
||||||
var server = result.GetValue(serverOption)!;
|
|
||||||
var port = result.GetValue(portOption);
|
|
||||||
var authMode = result.GetValue(authModeOption)!;
|
|
||||||
var from = result.GetValue(fromOption)!;
|
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||||
new UpdateSmtpConfigCommand(id, server, port, authMode, from));
|
BuildUpdateSmtpConfigCommand(result));
|
||||||
});
|
});
|
||||||
group.Add(updateCmd);
|
group.Add(updateCmd);
|
||||||
|
|
||||||
return group;
|
return group;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SMTP update options are static so the parsed values can be read back both
|
||||||
|
// from the SetAction and from BuildUpdateSmtpConfigCommand (used by tests).
|
||||||
|
private static readonly Option<int> SmtpIdOption =
|
||||||
|
new("--id") { Description = "SMTP config ID", Required = true };
|
||||||
|
private static readonly Option<string> SmtpServerOption =
|
||||||
|
new("--server") { Description = "SMTP server", Required = true };
|
||||||
|
private static readonly Option<int> SmtpPortOption =
|
||||||
|
new("--port") { Description = "SMTP port", Required = true };
|
||||||
|
private static readonly Option<string> SmtpAuthModeOption =
|
||||||
|
new("--auth-mode") { Description = "Auth mode", Required = true };
|
||||||
|
private static readonly Option<string> SmtpFromOption =
|
||||||
|
new("--from-address") { Description = "From email address", Required = true };
|
||||||
|
private static readonly Option<string?> SmtpTlsModeOption = CreateTlsModeOption();
|
||||||
|
private static readonly Option<string?> SmtpCredentialsOption =
|
||||||
|
new("--credentials")
|
||||||
|
{
|
||||||
|
Description = "SMTP credentials — 'username:password' for Basic, or client secret " +
|
||||||
|
"for OAuth2 (optional; preserves existing if omitted)",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static Option<string?> CreateTlsModeOption()
|
||||||
|
{
|
||||||
|
var option = new Option<string?>("--tls-mode")
|
||||||
|
{
|
||||||
|
Description = "TLS mode: None, StartTLS, or SSL (optional; preserves existing if omitted)",
|
||||||
|
};
|
||||||
|
option.AcceptOnlyFromAmong("None", "StartTLS", "SSL");
|
||||||
|
return option;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the <see cref="UpdateSmtpConfigCommand"/> from a parsed <c>smtp update</c>
|
||||||
|
/// invocation. The optional <c>--tls-mode</c> / <c>--credentials</c> flags map to
|
||||||
|
/// null when omitted so the server-side handler preserves the existing values.
|
||||||
|
/// </summary>
|
||||||
|
internal static UpdateSmtpConfigCommand BuildUpdateSmtpConfigCommand(ParseResult result)
|
||||||
|
{
|
||||||
|
var id = result.GetValue(SmtpIdOption);
|
||||||
|
var server = result.GetValue(SmtpServerOption)!;
|
||||||
|
var port = result.GetValue(SmtpPortOption);
|
||||||
|
var authMode = result.GetValue(SmtpAuthModeOption)!;
|
||||||
|
var from = result.GetValue(SmtpFromOption)!;
|
||||||
|
var tlsMode = result.GetValue(SmtpTlsModeOption);
|
||||||
|
var credentials = result.GetValue(SmtpCredentialsOption);
|
||||||
|
return new UpdateSmtpConfigCommand(id, server, port, authMode, from, tlsMode, credentials);
|
||||||
|
}
|
||||||
|
|
||||||
private static Command BuildList(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
private static Command BuildList(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||||
{
|
{
|
||||||
var cmd = new Command("list") { Description = "List all notification lists" };
|
var cmd = new Command("list") { Description = "List all notification lists" };
|
||||||
|
|||||||
@@ -50,6 +50,8 @@
|
|||||||
<div class="col-md-8">@smtp.Host:@smtp.Port</div>
|
<div class="col-md-8">@smtp.Host:@smtp.Port</div>
|
||||||
<div class="col-md-4 text-muted">Auth Type</div>
|
<div class="col-md-4 text-muted">Auth Type</div>
|
||||||
<div class="col-md-8"><span class="badge bg-secondary">@smtp.AuthType</span></div>
|
<div class="col-md-8"><span class="badge bg-secondary">@smtp.AuthType</span></div>
|
||||||
|
<div class="col-md-4 text-muted">TLS Mode</div>
|
||||||
|
<div class="col-md-8">@(string.IsNullOrWhiteSpace(smtp.TlsMode) ? "(not set)" : smtp.TlsMode)</div>
|
||||||
<div class="col-md-4 text-muted">From Address</div>
|
<div class="col-md-4 text-muted">From Address</div>
|
||||||
<div class="col-md-8">@smtp.FromAddress</div>
|
<div class="col-md-8">@smtp.FromAddress</div>
|
||||||
<div class="col-md-4 text-muted">Credentials</div>
|
<div class="col-md-4 text-muted">Credentials</div>
|
||||||
@@ -73,13 +75,21 @@
|
|||||||
<label class="form-label">Port</label>
|
<label class="form-label">Port</label>
|
||||||
<input type="number" class="form-control" @bind="_port" min="1" max="65535" />
|
<input type="number" class="form-control" @bind="_port" min="1" max="65535" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-8">
|
<div class="col-md-4">
|
||||||
<label class="form-label">Auth Type</label>
|
<label class="form-label">Auth Type</label>
|
||||||
<select class="form-select" @bind="_authType">
|
<select class="form-select" @bind="_authType">
|
||||||
<option>OAuth2</option>
|
<option>OAuth2</option>
|
||||||
<option>Basic</option>
|
<option>Basic</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">TLS Mode</label>
|
||||||
|
<select class="form-select" @bind="_tlsMode">
|
||||||
|
<option>None</option>
|
||||||
|
<option>StartTLS</option>
|
||||||
|
<option>SSL</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<label class="form-label">Credentials</label>
|
<label class="form-label">Credentials</label>
|
||||||
<input type="password" class="form-control" @bind="_credentials"
|
<input type="password" class="form-control" @bind="_credentials"
|
||||||
@@ -122,6 +132,7 @@
|
|||||||
private string _host = string.Empty;
|
private string _host = string.Empty;
|
||||||
private int _port = 587;
|
private int _port = 587;
|
||||||
private string _authType = "OAuth2";
|
private string _authType = "OAuth2";
|
||||||
|
private string? _tlsMode;
|
||||||
private string? _credentials;
|
private string? _credentials;
|
||||||
private string _fromAddress = string.Empty;
|
private string _fromAddress = string.Empty;
|
||||||
private string? _formError;
|
private string? _formError;
|
||||||
@@ -154,6 +165,7 @@
|
|||||||
_host = string.Empty;
|
_host = string.Empty;
|
||||||
_port = 587;
|
_port = 587;
|
||||||
_authType = "OAuth2";
|
_authType = "OAuth2";
|
||||||
|
_tlsMode = "None";
|
||||||
_credentials = null;
|
_credentials = null;
|
||||||
_fromAddress = string.Empty;
|
_fromAddress = string.Empty;
|
||||||
_formError = null;
|
_formError = null;
|
||||||
@@ -166,6 +178,7 @@
|
|||||||
_host = smtp.Host;
|
_host = smtp.Host;
|
||||||
_port = smtp.Port;
|
_port = smtp.Port;
|
||||||
_authType = smtp.AuthType;
|
_authType = smtp.AuthType;
|
||||||
|
_tlsMode = smtp.TlsMode;
|
||||||
_credentials = smtp.Credentials;
|
_credentials = smtp.Credentials;
|
||||||
_fromAddress = smtp.FromAddress;
|
_fromAddress = smtp.FromAddress;
|
||||||
_formError = null;
|
_formError = null;
|
||||||
@@ -194,6 +207,7 @@
|
|||||||
_editingSmtp.Host = _host.Trim();
|
_editingSmtp.Host = _host.Trim();
|
||||||
_editingSmtp.Port = _port;
|
_editingSmtp.Port = _port;
|
||||||
_editingSmtp.AuthType = _authType;
|
_editingSmtp.AuthType = _authType;
|
||||||
|
_editingSmtp.TlsMode = _tlsMode;
|
||||||
_editingSmtp.Credentials = _credentials?.Trim();
|
_editingSmtp.Credentials = _credentials?.Trim();
|
||||||
_editingSmtp.FromAddress = _fromAddress.Trim();
|
_editingSmtp.FromAddress = _fromAddress.Trim();
|
||||||
await NotificationRepository.UpdateSmtpConfigurationAsync(_editingSmtp);
|
await NotificationRepository.UpdateSmtpConfigurationAsync(_editingSmtp);
|
||||||
@@ -203,6 +217,7 @@
|
|||||||
var smtp = new SmtpConfigurationEntity(_host.Trim(), _authType, _fromAddress.Trim())
|
var smtp = new SmtpConfigurationEntity(_host.Trim(), _authType, _fromAddress.Trim())
|
||||||
{
|
{
|
||||||
Port = _port,
|
Port = _port,
|
||||||
|
TlsMode = _tlsMode,
|
||||||
Credentials = _credentials?.Trim()
|
Credentials = _credentials?.Trim()
|
||||||
};
|
};
|
||||||
await NotificationRepository.AddSmtpConfigurationAsync(smtp);
|
await NotificationRepository.AddSmtpConfigurationAsync(smtp);
|
||||||
|
|||||||
@@ -6,4 +6,4 @@ public record CreateNotificationListCommand(string Name, IReadOnlyList<string> R
|
|||||||
public record UpdateNotificationListCommand(int NotificationListId, string Name, IReadOnlyList<string> RecipientEmails);
|
public record UpdateNotificationListCommand(int NotificationListId, string Name, IReadOnlyList<string> RecipientEmails);
|
||||||
public record DeleteNotificationListCommand(int NotificationListId);
|
public record DeleteNotificationListCommand(int NotificationListId);
|
||||||
public record ListSmtpConfigsCommand;
|
public record ListSmtpConfigsCommand;
|
||||||
public record UpdateSmtpConfigCommand(int SmtpConfigId, string Server, int Port, string AuthMode, string FromAddress);
|
public record UpdateSmtpConfigCommand(int SmtpConfigId, string Server, int Port, string AuthMode, string FromAddress, string? TlsMode = null, string? Credentials = null);
|
||||||
|
|||||||
@@ -1124,6 +1124,10 @@ public class ManagementActor : ReceiveActor
|
|||||||
config.Port = cmd.Port;
|
config.Port = cmd.Port;
|
||||||
config.AuthType = cmd.AuthMode;
|
config.AuthType = cmd.AuthMode;
|
||||||
config.FromAddress = cmd.FromAddress;
|
config.FromAddress = cmd.FromAddress;
|
||||||
|
// Preserve-if-null: an update that omits TlsMode/Credentials leaves the
|
||||||
|
// existing values intact (non-breaking for callers that do not send them).
|
||||||
|
if (cmd.TlsMode is not null) config.TlsMode = cmd.TlsMode;
|
||||||
|
if (cmd.Credentials is not null) config.Credentials = cmd.Credentials;
|
||||||
await repo.UpdateSmtpConfigurationAsync(config);
|
await repo.UpdateSmtpConfigurationAsync(config);
|
||||||
await repo.SaveChangesAsync();
|
await repo.SaveChangesAsync();
|
||||||
await AuditAsync(sp, user, "Update", "SmtpConfiguration", config.Id.ToString(), config.Host, config);
|
await AuditAsync(sp, user, "Update", "SmtpConfiguration", config.Id.ToString(), config.Host, config);
|
||||||
|
|||||||
104
tests/ScadaLink.CLI.Tests/Commands/SmtpUpdateCommandTests.cs
Normal file
104
tests/ScadaLink.CLI.Tests/Commands/SmtpUpdateCommandTests.cs
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
using System.CommandLine;
|
||||||
|
using ScadaLink.CLI.Commands;
|
||||||
|
using ScadaLink.Commons.Messages.Management;
|
||||||
|
|
||||||
|
namespace ScadaLink.CLI.Tests.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for the <c>scadalink notification smtp update</c> subcommand. The command
|
||||||
|
/// gained two optional flags — <c>--tls-mode</c> and <c>--credentials</c> — that plumb
|
||||||
|
/// through to <see cref="UpdateSmtpConfigCommand"/>. These tests pin that the flags
|
||||||
|
/// parse, are genuinely optional (non-breaking), and that <c>--tls-mode</c> rejects
|
||||||
|
/// values outside the canonical {None, StartTLS, SSL} set.
|
||||||
|
/// </summary>
|
||||||
|
public class SmtpUpdateCommandTests
|
||||||
|
{
|
||||||
|
private static readonly Option<string> Url = new("--url") { Recursive = true };
|
||||||
|
private static readonly Option<string> Username = new("--username") { Recursive = true };
|
||||||
|
private static readonly Option<string> Password = new("--password") { Recursive = true };
|
||||||
|
private static readonly Option<string> Format = CliOptions.CreateFormatOption();
|
||||||
|
|
||||||
|
private static Command SmtpUpdateCommand()
|
||||||
|
{
|
||||||
|
var notification = NotificationCommands.Build(Url, Format, Username, Password);
|
||||||
|
var smtp = notification.Subcommands.Single(c => c.Name == "smtp");
|
||||||
|
return smtp.Subcommands.Single(c => c.Name == "update");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ParseResult ParseUpdate(params string[] args)
|
||||||
|
=> SmtpUpdateCommand().Parse(args);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Update_WithTlsModeAndCredentials_ProducesCommandCarryingThem()
|
||||||
|
{
|
||||||
|
var parse = ParseUpdate(
|
||||||
|
"--id", "1", "--server", "smtp.example.com", "--port", "587",
|
||||||
|
"--auth-mode", "Basic", "--from-address", "noreply@example.com",
|
||||||
|
"--tls-mode", "None", "--credentials", "user:pass");
|
||||||
|
|
||||||
|
Assert.Empty(parse.Errors);
|
||||||
|
var cmd = NotificationCommands.BuildUpdateSmtpConfigCommand(parse);
|
||||||
|
|
||||||
|
Assert.Equal(1, cmd.SmtpConfigId);
|
||||||
|
Assert.Equal("smtp.example.com", cmd.Server);
|
||||||
|
Assert.Equal(587, cmd.Port);
|
||||||
|
Assert.Equal("Basic", cmd.AuthMode);
|
||||||
|
Assert.Equal("noreply@example.com", cmd.FromAddress);
|
||||||
|
Assert.Equal("None", cmd.TlsMode);
|
||||||
|
Assert.Equal("user:pass", cmd.Credentials);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Update_WithoutTlsModeAndCredentials_ProducesCommandWithNulls()
|
||||||
|
{
|
||||||
|
var parse = ParseUpdate(
|
||||||
|
"--id", "2", "--server", "smtp.example.com", "--port", "25",
|
||||||
|
"--auth-mode", "OAuth2", "--from-address", "noreply@example.com");
|
||||||
|
|
||||||
|
Assert.Empty(parse.Errors);
|
||||||
|
var cmd = NotificationCommands.BuildUpdateSmtpConfigCommand(parse);
|
||||||
|
|
||||||
|
Assert.Equal(2, cmd.SmtpConfigId);
|
||||||
|
Assert.Null(cmd.TlsMode);
|
||||||
|
Assert.Null(cmd.Credentials);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("None")]
|
||||||
|
[InlineData("StartTLS")]
|
||||||
|
[InlineData("SSL")]
|
||||||
|
public void Update_TlsModeOption_AcceptsCanonicalValues(string value)
|
||||||
|
{
|
||||||
|
var parse = ParseUpdate(
|
||||||
|
"--id", "1", "--server", "smtp.example.com", "--port", "587",
|
||||||
|
"--auth-mode", "Basic", "--from-address", "noreply@example.com",
|
||||||
|
"--tls-mode", value);
|
||||||
|
|
||||||
|
Assert.Empty(parse.Errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Bogus")]
|
||||||
|
[InlineData("tls")]
|
||||||
|
[InlineData("none")] // AcceptOnlyFromAmong is case-sensitive: constrain to canonical spelling
|
||||||
|
public void Update_TlsModeOption_RejectsValuesOutsideCanonicalSet(string value)
|
||||||
|
{
|
||||||
|
var parse = ParseUpdate(
|
||||||
|
"--id", "1", "--server", "smtp.example.com", "--port", "587",
|
||||||
|
"--auth-mode", "Basic", "--from-address", "noreply@example.com",
|
||||||
|
"--tls-mode", value);
|
||||||
|
|
||||||
|
Assert.NotEmpty(parse.Errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Update_TlsModeAndCredentials_AreNotRequired()
|
||||||
|
{
|
||||||
|
var update = SmtpUpdateCommand();
|
||||||
|
var tls = update.Options.Single(o => o.Name == "--tls-mode");
|
||||||
|
var creds = update.Options.Single(o => o.Name == "--credentials");
|
||||||
|
|
||||||
|
Assert.False(tls.Required, "--tls-mode must be optional (preserve-if-omitted).");
|
||||||
|
Assert.False(creds.Required, "--credentials must be optional (preserve-if-omitted).");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Bunit;
|
||||||
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using NSubstitute;
|
||||||
|
using ScadaLink.Commons.Entities.Notifications;
|
||||||
|
using ScadaLink.Commons.Interfaces.Repositories;
|
||||||
|
using SmtpConfigurationPage = ScadaLink.CentralUI.Components.Pages.Notifications.SmtpConfiguration;
|
||||||
|
|
||||||
|
namespace ScadaLink.CentralUI.Tests.Pages;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// bUnit rendering tests for the SMTP Configuration page — specifically the TlsMode
|
||||||
|
/// field added so the UI exposes all five user-relevant SmtpConfiguration fields.
|
||||||
|
/// </summary>
|
||||||
|
public class SmtpConfigurationPageTests : BunitContext
|
||||||
|
{
|
||||||
|
private void WireAuth()
|
||||||
|
{
|
||||||
|
var claims = new[]
|
||||||
|
{
|
||||||
|
new Claim("Username", "tester"),
|
||||||
|
new Claim(ClaimTypes.Role, "Admin"),
|
||||||
|
};
|
||||||
|
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||||
|
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
|
||||||
|
Services.AddAuthorizationCore();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SmtpConfiguration Sample() =>
|
||||||
|
new("smtp.example.com", "Basic", "noreply@example.com")
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
Port = 587,
|
||||||
|
TlsMode = "StartTLS",
|
||||||
|
Credentials = "user:pass",
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EditForm_RendersTlsModeSelectWithAllThreeModes()
|
||||||
|
{
|
||||||
|
var repo = Substitute.For<INotificationRepository>();
|
||||||
|
repo.GetAllSmtpConfigurationsAsync()
|
||||||
|
.Returns(Task.FromResult<IReadOnlyList<SmtpConfiguration>>(
|
||||||
|
new List<SmtpConfiguration> { Sample() }));
|
||||||
|
Services.AddSingleton(repo);
|
||||||
|
WireAuth();
|
||||||
|
|
||||||
|
var cut = Render<SmtpConfigurationPage>();
|
||||||
|
cut.WaitForState(() => cut.Markup.Contains("smtp.example.com"));
|
||||||
|
|
||||||
|
cut.FindAll("button").First(b => b.TextContent.Contains("Edit")).Click();
|
||||||
|
|
||||||
|
cut.WaitForAssertion(() =>
|
||||||
|
{
|
||||||
|
var selects = cut.FindAll("select");
|
||||||
|
var tlsSelect = selects.Single(s => s.QuerySelectorAll("option")
|
||||||
|
.Any(o => o.TextContent == "StartTLS"));
|
||||||
|
var modes = tlsSelect.QuerySelectorAll("option").Select(o => o.TextContent).ToList();
|
||||||
|
Assert.Equal(new[] { "None", "StartTLS", "SSL" }, modes);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ReadOnlyView_ShowsTlsMode()
|
||||||
|
{
|
||||||
|
var repo = Substitute.For<INotificationRepository>();
|
||||||
|
repo.GetAllSmtpConfigurationsAsync()
|
||||||
|
.Returns(Task.FromResult<IReadOnlyList<SmtpConfiguration>>(
|
||||||
|
new List<SmtpConfiguration> { Sample() }));
|
||||||
|
Services.AddSingleton(repo);
|
||||||
|
WireAuth();
|
||||||
|
|
||||||
|
var cut = Render<SmtpConfigurationPage>();
|
||||||
|
|
||||||
|
cut.WaitForAssertion(() =>
|
||||||
|
{
|
||||||
|
Assert.Contains("TLS Mode", cut.Markup);
|
||||||
|
Assert.Contains("StartTLS", cut.Markup);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SavingEdit_PersistsChosenTlsMode()
|
||||||
|
{
|
||||||
|
var config = Sample();
|
||||||
|
var repo = Substitute.For<INotificationRepository>();
|
||||||
|
repo.GetAllSmtpConfigurationsAsync()
|
||||||
|
.Returns(Task.FromResult<IReadOnlyList<SmtpConfiguration>>(
|
||||||
|
new List<SmtpConfiguration> { config }));
|
||||||
|
Services.AddSingleton(repo);
|
||||||
|
WireAuth();
|
||||||
|
|
||||||
|
var cut = Render<SmtpConfigurationPage>();
|
||||||
|
cut.WaitForState(() => cut.Markup.Contains("smtp.example.com"));
|
||||||
|
|
||||||
|
cut.FindAll("button").First(b => b.TextContent.Contains("Edit")).Click();
|
||||||
|
|
||||||
|
var tlsSelect = cut.FindAll("select")
|
||||||
|
.Single(s => s.QuerySelectorAll("option").Any(o => o.TextContent == "StartTLS"));
|
||||||
|
tlsSelect.Change("SSL");
|
||||||
|
|
||||||
|
cut.FindAll("button").First(b => b.TextContent.Contains("Save")).Click();
|
||||||
|
|
||||||
|
cut.WaitForAssertion(() =>
|
||||||
|
{
|
||||||
|
repo.Received().UpdateSmtpConfigurationAsync(
|
||||||
|
Arg.Is<SmtpConfiguration>(c => c.TlsMode == "SSL"));
|
||||||
|
repo.Received().SaveChangesAsync();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1002,6 +1002,72 @@ public class ManagementActorTests : TestKit, IDisposable
|
|||||||
Assert.Contains(envelope.CorrelationId, response.Error);
|
Assert.Contains(envelope.CorrelationId, response.Error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// UpdateSmtpConfig — TlsMode + Credentials plumbing (preserve-if-null)
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UpdateSmtpConfig_WithTlsModeAndCredentials_PersistsThem()
|
||||||
|
{
|
||||||
|
var notifRepo = Substitute.For<INotificationRepository>();
|
||||||
|
var existing = new Commons.Entities.Notifications.SmtpConfiguration(
|
||||||
|
"old.example.com", "OAuth2", "old@example.com")
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
Port = 25,
|
||||||
|
TlsMode = "StartTLS",
|
||||||
|
Credentials = "old-secret",
|
||||||
|
};
|
||||||
|
notifRepo.GetSmtpConfigurationByIdAsync(1, Arg.Any<CancellationToken>()).Returns(existing);
|
||||||
|
_services.AddScoped(_ => notifRepo);
|
||||||
|
|
||||||
|
var actor = CreateActor();
|
||||||
|
var envelope = Envelope(
|
||||||
|
new UpdateSmtpConfigCommand(1, "new.example.com", 465, "Basic", "new@example.com", "SSL", "user:pass"),
|
||||||
|
"Design");
|
||||||
|
|
||||||
|
actor.Tell(envelope);
|
||||||
|
|
||||||
|
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||||
|
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
||||||
|
Assert.Equal("SSL", existing.TlsMode);
|
||||||
|
Assert.Equal("user:pass", existing.Credentials);
|
||||||
|
Assert.Equal("new.example.com", existing.Host);
|
||||||
|
Assert.Equal("Basic", existing.AuthType);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UpdateSmtpConfig_WithNullTlsModeAndCredentials_PreservesExistingValues()
|
||||||
|
{
|
||||||
|
var notifRepo = Substitute.For<INotificationRepository>();
|
||||||
|
var existing = new Commons.Entities.Notifications.SmtpConfiguration(
|
||||||
|
"old.example.com", "OAuth2", "old@example.com")
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
Port = 25,
|
||||||
|
TlsMode = "StartTLS",
|
||||||
|
Credentials = "old-secret",
|
||||||
|
};
|
||||||
|
notifRepo.GetSmtpConfigurationByIdAsync(1, Arg.Any<CancellationToken>()).Returns(existing);
|
||||||
|
_services.AddScoped(_ => notifRepo);
|
||||||
|
|
||||||
|
var actor = CreateActor();
|
||||||
|
var envelope = Envelope(
|
||||||
|
new UpdateSmtpConfigCommand(1, "new.example.com", 465, "Basic", "new@example.com"),
|
||||||
|
"Design");
|
||||||
|
|
||||||
|
actor.Tell(envelope);
|
||||||
|
|
||||||
|
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||||
|
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
||||||
|
// Omitted fields are preserved, not nulled.
|
||||||
|
Assert.Equal("StartTLS", existing.TlsMode);
|
||||||
|
Assert.Equal("old-secret", existing.Credentials);
|
||||||
|
// Provided fields are still updated.
|
||||||
|
Assert.Equal("new.example.com", existing.Host);
|
||||||
|
Assert.Equal("Basic", existing.AuthType);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void CuratedHandlerFailure_SurfacesTheCuratedMessage()
|
public void CuratedHandlerFailure_SurfacesTheCuratedMessage()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user