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:
Joseph Doherty
2026-05-21 02:16:23 -04:00
8 changed files with 374 additions and 22 deletions

View File

@@ -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:

View File

@@ -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" };

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View 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).");
}
}

View File

@@ -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();
});
}
}

View File

@@ -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()
{ {