9 Commits

Author SHA1 Message Date
Joseph Doherty 8fd0cf355b Merge branch 'feature/notification-report-detail-modal': row double-click detail modal
Double-clicking a row on /notifications/report opens a Bootstrap modal
showing the notification's full detail (untruncated ID, full LastError,
SourceInstanceId, exact timestamps) — fields the grid truncates or omits.
Parked notifications also get Retry/Discard buttons in the modal footer.
Inline no-JS modal, in-memory NotificationSummary, no extra query.
2026-05-21 02:40:07 -04:00
Joseph Doherty ef5cf76026 feat(ui): notification report row double-click opens detail modal 2026-05-21 02:39:41 -04:00
Joseph Doherty 80076a3951 Merge branch 'chore/dev-cluster-dispatch-tuning': raise dev-cluster notification dispatch throughput 2026-05-21 02:35:22 -04:00
Joseph Doherty 1c9b2445ad chore(dev-cluster): raise NotificationOutbox dispatch throughput
Both central nodes ran on the NotificationOutboxOptions code defaults
(100 / 10s = 600/min) because the mounted per-node appsettings.Central.json
had no ScadaLink:NotificationOutbox section. Add the section with
DispatchBatchSize 1000 + DispatchInterval 5s — measured ~6,000/min after
restart (sweep duration becomes the binding constraint, which is fine:
the no-overlap guard self-regulates). Dev-cluster tuning only.
2026-05-21 02:35:22 -04:00
Joseph Doherty 163446948d 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.
2026-05-21 02:16:23 -04:00
Joseph Doherty e58e038db9 docs(test-infra): correct SMTP example — Basic auth, TlsMode None, container hostname
The appsettings example used AuthMode 'None', which the delivery code
(MailKitSmtpClientWrapper) rejects — only Basic and OAuth2 are valid.
Switch to a working Basic config with Credentials and TlsMode None, and
document that Server must be the container name scadalink-smtp when the
Notification Service runs inside the docker cluster.
2026-05-21 02:13:19 -04:00
Joseph Doherty c66ef71017 feat(ui): SMTP config form TlsMode field
Add a TlsMode read-only row and a None/StartTLS/SSL select to the SMTP
Configuration page edit form. New configs default to None; edits load
and persist the chosen mode through the repository.
2026-05-21 02:13:02 -04:00
Joseph Doherty 399b4aac92 feat(cli): notification smtp update --tls-mode / --credentials options
Expose the two previously-unreachable SmtpConfiguration fields on the
CLI. Both flags are optional — omitting them sends null so the server
preserves the existing value. --tls-mode is constrained to the canonical
{None, StartTLS, SSL} set via AcceptOnlyFromAmong for fast-fail.
2026-05-21 02:11:51 -04:00
Joseph Doherty ec92d55ebf feat(smtp): UpdateSmtpConfigCommand carries TlsMode + Credentials
Add two optional nullable fields (TlsMode, Credentials) to the
UpdateSmtpConfigCommand record. The handler applies preserve-if-null
semantics: an update that omits a field leaves the existing value
intact, so existing 5-arg callers remain non-breaking.
2026-05-21 02:11:03 -04:00
12 changed files with 669 additions and 24 deletions
@@ -53,6 +53,10 @@
"AuthMode": "None",
"FromAddress": "scada-notifications@company.com"
},
"NotificationOutbox": {
"DispatchInterval": "00:00:05",
"DispatchBatchSize": 1000
},
"Logging": {
"MinimumLevel": "Information"
}
@@ -53,6 +53,10 @@
"AuthMode": "None",
"FromAddress": "scada-notifications@company.com"
},
"NotificationOutbox": {
"DispatchInterval": "00:00:05",
"DispatchBatchSize": 1000
},
"Logging": {
"MinimumLevel": "Information"
}
+16 -4
View File
@@ -29,18 +29,30 @@ For `appsettings.Development.json` (Notification Service):
"Smtp": {
"Server": "localhost",
"Port": 1025,
"AuthMode": "None",
"AuthMode": "Basic",
"Credentials": "test:test",
"TlsMode": "None",
"FromAddress": "scada-notifications@company.com",
"ConnectionTimeout": 30
}
}
```
Since `MP_SMTP_AUTH_ACCEPT_ANY` is enabled, the Notification Service can use any auth mode:
- **No auth**: Connect directly, no credentials needed.
- **Basic Auth**: Any username/password will be accepted (useful for testing the auth code path without a real server).
> **`Server` host**: use `localhost` only when the Notification Service runs directly on
> the host. When it runs inside the docker cluster, set `Server` to the container name
> `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.
`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 exposes a REST API at `http://localhost:8025/api` for programmatic access:
@@ -69,33 +69,72 @@ public static class NotificationCommands
});
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" };
updateCmd.Add(idOption);
updateCmd.Add(serverOption);
updateCmd.Add(portOption);
updateCmd.Add(authModeOption);
updateCmd.Add(fromOption);
updateCmd.Add(SmtpIdOption);
updateCmd.Add(SmtpServerOption);
updateCmd.Add(SmtpPortOption);
updateCmd.Add(SmtpAuthModeOption);
updateCmd.Add(SmtpFromOption);
updateCmd.Add(SmtpTlsModeOption);
updateCmd.Add(SmtpCredentialsOption);
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(
result, urlOption, formatOption, usernameOption, passwordOption,
new UpdateSmtpConfigCommand(id, server, port, authMode, from));
BuildUpdateSmtpConfigCommand(result));
});
group.Add(updateCmd);
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)
{
var cmd = new Command("list") { Description = "List all notification lists" };
@@ -139,7 +139,9 @@
<tbody>
@foreach (var n in _notifications)
{
<tr @key="n.NotificationId" class="@(n.IsStuck ? "table-warning" : "")">
<tr @key="n.NotificationId" class="@(n.IsStuck ? "table-warning" : "")"
style="cursor: pointer;" @ondblclick="() => ShowDetail(n)"
title="Double-click for full detail">
<td><code class="small" title="@n.NotificationId">@ShortId(n.NotificationId)</code></td>
<td>@n.Type</td>
<td>@n.ListName</td>
@@ -162,7 +164,7 @@
<td><span class="small">@SiteName(n.SourceSiteId)</span></td>
<td><TimestampDisplay Value="@n.CreatedAt" Format="yyyy-MM-dd HH:mm" /></td>
<td><TimestampDisplay Value="@n.DeliveredAt" Format="yyyy-MM-dd HH:mm" NullText="—" /></td>
<td class="text-end">
<td class="text-end" @ondblclick:stopPropagation="true">
@* Bundle D (#23 M7-T10) drill-in: NotificationId is the audit
CorrelationId, so the link deep-links into the central Audit
Log pre-filtered to this notification's lifecycle events. *@
@@ -206,6 +208,86 @@
}
</div>
@* ── Row detail modal ── *@
@if (_detailNotification != null)
{
var d = _detailNotification;
<div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);"
@onclick="CloseDetail">
<div class="modal-dialog modal-dialog-scrollable modal-lg" @onclick:stopPropagation="true">
<div class="modal-content">
<div class="modal-header">
<h6 class="modal-title">Notification Detail — @ShortId(d.NotificationId)</h6>
<button type="button" class="btn-close" aria-label="Close"
@onclick="CloseDetail"></button>
</div>
<div class="modal-body">
<dl class="row mb-0">
<dt class="col-sm-3">Notification ID</dt>
<dd class="col-sm-9"><code>@d.NotificationId</code></dd>
<dt class="col-sm-3">Type</dt>
<dd class="col-sm-9">@d.Type</dd>
<dt class="col-sm-3">List</dt>
<dd class="col-sm-9">@d.ListName</dd>
<dt class="col-sm-3">Subject</dt>
<dd class="col-sm-9">@d.Subject</dd>
<dt class="col-sm-3">Status</dt>
<dd class="col-sm-9">
<span class="badge @StatusBadgeClass(d.Status)">@d.Status</span>
@if (d.IsStuck)
{
<span class="badge bg-warning text-dark ms-1">Stuck</span>
}
</dd>
<dt class="col-sm-3">Stuck</dt>
<dd class="col-sm-9">@(d.IsStuck ? "Yes" : "No")</dd>
<dt class="col-sm-3">Retry count</dt>
<dd class="col-sm-9 font-monospace">@d.RetryCount</dd>
<dt class="col-sm-3">Source site</dt>
<dd class="col-sm-9">@SiteName(d.SourceSiteId)</dd>
<dt class="col-sm-3">Source instance</dt>
<dd class="col-sm-9">@(string.IsNullOrEmpty(d.SourceInstanceId) ? "—" : d.SourceInstanceId)</dd>
<dt class="col-sm-3">Created</dt>
<dd class="col-sm-9"><TimestampDisplay Value="@d.CreatedAt" Format="yyyy-MM-dd HH:mm:ss" /></dd>
<dt class="col-sm-3">Delivered</dt>
<dd class="col-sm-9"><TimestampDisplay Value="@d.DeliveredAt" Format="yyyy-MM-dd HH:mm:ss" NullText="—" /></dd>
@if (!string.IsNullOrEmpty(d.LastError))
{
<dt class="col-sm-3">Last error</dt>
<dd class="col-sm-9 text-danger">@d.LastError</dd>
}
</dl>
</div>
<div class="modal-footer">
@if (d.Status == "Parked")
{
<button class="btn btn-outline-success btn-sm"
@onclick="() => RetryFromDetail(d)" disabled="@_actionInProgress">
Retry
</button>
<button class="btn btn-outline-danger btn-sm"
@onclick="() => DiscardFromDetail(d)" disabled="@_actionInProgress">
Discard
</button>
}
<button class="btn btn-outline-secondary btn-sm" @onclick="CloseDetail">Close</button>
</div>
</div>
</div>
</div>
}
@code {
private const int _pageSize = 50;
@@ -220,6 +302,9 @@
private string? _listError;
private bool _actionInProgress;
// Row detail modal
private NotificationSummary? _detailNotification;
// Filters
private string _statusFilter = string.Empty;
private string _typeFilter = string.Empty;
@@ -355,6 +440,24 @@
_actionInProgress = false;
}
private void ShowDetail(NotificationSummary n) => _detailNotification = n;
private void CloseDetail() => _detailNotification = null;
private async Task RetryFromDetail(NotificationSummary n)
{
await RetryNotification(n);
// RefreshAll replaces the row list; close the modal so the user sees the
// refreshed grid rather than a now-stale detail snapshot.
CloseDetail();
}
private async Task DiscardFromDetail(NotificationSummary n)
{
await DiscardNotification(n);
CloseDetail();
}
private void ClearFilters()
{
_statusFilter = string.Empty;
@@ -50,6 +50,8 @@
<div class="col-md-8">@smtp.Host:@smtp.Port</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-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-8">@smtp.FromAddress</div>
<div class="col-md-4 text-muted">Credentials</div>
@@ -73,13 +75,21 @@
<label class="form-label">Port</label>
<input type="number" class="form-control" @bind="_port" min="1" max="65535" />
</div>
<div class="col-md-8">
<div class="col-md-4">
<label class="form-label">Auth Type</label>
<select class="form-select" @bind="_authType">
<option>OAuth2</option>
<option>Basic</option>
</select>
</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">
<label class="form-label">Credentials</label>
<input type="password" class="form-control" @bind="_credentials"
@@ -122,6 +132,7 @@
private string _host = string.Empty;
private int _port = 587;
private string _authType = "OAuth2";
private string? _tlsMode;
private string? _credentials;
private string _fromAddress = string.Empty;
private string? _formError;
@@ -154,6 +165,7 @@
_host = string.Empty;
_port = 587;
_authType = "OAuth2";
_tlsMode = "None";
_credentials = null;
_fromAddress = string.Empty;
_formError = null;
@@ -166,6 +178,7 @@
_host = smtp.Host;
_port = smtp.Port;
_authType = smtp.AuthType;
_tlsMode = smtp.TlsMode;
_credentials = smtp.Credentials;
_fromAddress = smtp.FromAddress;
_formError = null;
@@ -194,6 +207,7 @@
_editingSmtp.Host = _host.Trim();
_editingSmtp.Port = _port;
_editingSmtp.AuthType = _authType;
_editingSmtp.TlsMode = _tlsMode;
_editingSmtp.Credentials = _credentials?.Trim();
_editingSmtp.FromAddress = _fromAddress.Trim();
await NotificationRepository.UpdateSmtpConfigurationAsync(_editingSmtp);
@@ -203,6 +217,7 @@
var smtp = new SmtpConfigurationEntity(_host.Trim(), _authType, _fromAddress.Trim())
{
Port = _port,
TlsMode = _tlsMode,
Credentials = _credentials?.Trim()
};
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 DeleteNotificationListCommand(int NotificationListId);
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.AuthType = cmd.AuthMode;
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.SaveChangesAsync();
await AuditAsync(sp, user, "Update", "SmtpConfiguration", config.Id.ToString(), config.Host, config);
@@ -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,182 @@
using System.Security.Claims;
using Akka.Actor;
using Bunit;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ScadaLink.CentralUI.Components.Shared;
using NSubstitute;
using ScadaLink.Commons.Entities.Sites;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Messages.Notification;
using ScadaLink.Communication;
using NotificationReportPage = ScadaLink.CentralUI.Components.Pages.Notifications.NotificationReport;
namespace ScadaLink.CentralUI.Tests.Pages;
/// <summary>
/// bUnit tests for the Notification Report row-detail modal — double-clicking a
/// notification row opens a Bootstrap modal showing that notification's full,
/// untruncated details.
///
/// Mirrors <see cref="NotificationReportPageTests"/>'s seam: the report's
/// <see cref="CommunicationService"/> calls route through an injected scripted
/// actor (the notification-outbox proxy).
/// </summary>
public class NotificationReportDetailModalTests : BunitContext
{
private readonly ActorSystem _system = ActorSystem.Create("notif-report-modal-tests");
private readonly CommunicationService _comms;
private NotificationOutboxQueryResponse _queryReply =
new("q", true, null, new List<NotificationSummary>
{
new("notif-aaaaaaaa-1111-full-id", "Email", "Ops On-Call", "Pump fault at Plant-A",
"Parked", RetryCount: 3, LastError: "SMTP timeout connecting to mail relay",
SourceSiteId: "plant-a", SourceInstanceId: "Pump-001",
CreatedAt: DateTimeOffset.UtcNow.AddMinutes(-30),
DeliveredAt: null, IsStuck: true),
new("notif-bbbbbbbb-2222-full-id", "Email", "Maintenance", "Daily summary",
"Delivered", RetryCount: 0, LastError: null, SourceSiteId: "plant-b",
SourceInstanceId: null, CreatedAt: DateTimeOffset.UtcNow.AddHours(-2),
DeliveredAt: DateTimeOffset.UtcNow.AddHours(-2), IsStuck: false),
}, TotalCount: 2);
public NotificationReportDetailModalTests()
{
_comms = new CommunicationService(
Options.Create(new CommunicationOptions()),
NullLogger<CommunicationService>.Instance);
var outbox = _system.ActorOf(Props.Create(() => new ScriptedOutboxActor(this)));
_comms.SetNotificationOutbox(outbox);
Services.AddSingleton(_comms);
Services.AddSingleton<IDialogService>(new AlwaysConfirmDialogService());
var siteRepo = Substitute.For<ISiteRepository>();
siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site>
{
new("Plant A", "plant-a") { Id = 1 },
new("Plant B", "plant-b") { Id = 2 },
}));
Services.AddSingleton(siteRepo);
var claims = new[]
{
new Claim("Username", "tester"),
new Claim(ClaimTypes.Role, "Deployment"),
};
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
}
[Fact]
public void DoubleClickRow_OpensDetailModal()
{
var cut = Render<NotificationReportPage>();
cut.WaitForState(() => cut.Markup.Contains("Pump fault at Plant-A"));
// No modal initially.
Assert.Empty(cut.FindAll(".modal.show"));
var row = cut.FindAll("tbody tr")
.First(r => r.TextContent.Contains("Pump fault at Plant-A"));
row.DoubleClick();
cut.WaitForAssertion(() =>
{
var modal = cut.Find(".modal.show");
Assert.Contains("Pump fault at Plant-A", modal.TextContent);
Assert.Contains("Ops On-Call", modal.TextContent);
});
}
[Fact]
public void Modal_ShowsFullNotificationId_NotTruncated()
{
var cut = Render<NotificationReportPage>();
cut.WaitForState(() => cut.Markup.Contains("Pump fault at Plant-A"));
var row = cut.FindAll("tbody tr")
.First(r => r.TextContent.Contains("Pump fault at Plant-A"));
row.DoubleClick();
cut.WaitForAssertion(() =>
{
var modal = cut.Find(".modal.show");
// The grid renders ShortId(...) (first 12 chars); the modal must show
// the complete identifier.
Assert.Contains("notif-aaaaaaaa-1111-full-id", modal.TextContent);
});
}
[Fact]
public void CloseButton_DismissesModal()
{
var cut = Render<NotificationReportPage>();
cut.WaitForState(() => cut.Markup.Contains("Pump fault at Plant-A"));
var row = cut.FindAll("tbody tr")
.First(r => r.TextContent.Contains("Pump fault at Plant-A"));
row.DoubleClick();
cut.WaitForState(() => cut.FindAll(".modal.show").Count > 0);
var closeButton = cut.Find(".modal.show .modal-footer button");
closeButton.Click();
cut.WaitForAssertion(() => Assert.Empty(cut.FindAll(".modal.show")));
}
[Fact]
public void Modal_ShowsLastError_WhenPresent()
{
var cut = Render<NotificationReportPage>();
cut.WaitForState(() => cut.Markup.Contains("Pump fault at Plant-A"));
var row = cut.FindAll("tbody tr")
.First(r => r.TextContent.Contains("Pump fault at Plant-A"));
row.DoubleClick();
cut.WaitForAssertion(() =>
{
var modal = cut.Find(".modal.show");
Assert.Contains("SMTP timeout connecting to mail relay", modal.TextContent);
});
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
_system.Terminate().Wait(TimeSpan.FromSeconds(5));
}
base.Dispose(disposing);
}
private sealed class ScriptedOutboxActor : ReceiveActor
{
public ScriptedOutboxActor(NotificationReportDetailModalTests test)
{
Receive<NotificationOutboxQueryRequest>(_ => Sender.Tell(test._queryReply));
Receive<RetryNotificationRequest>(r =>
Sender.Tell(new RetryNotificationResponse(r.CorrelationId, true, null)));
Receive<DiscardNotificationRequest>(r =>
Sender.Tell(new DiscardNotificationResponse(r.CorrelationId, true, null)));
}
}
private sealed class AlwaysConfirmDialogService : IDialogService
{
public Task<bool> ConfirmAsync(string title, string message, bool danger = false)
=> Task.FromResult(true);
public Task<string?> PromptAsync(
string title, string label, string initialValue = "", string? placeholder = null)
=> Task.FromResult<string?>(null);
}
}
@@ -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);
}
// ========================================================================
// 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]
public void CuratedHandlerFailure_SurfacesTheCuratedMessage()
{