34 KiB
Code Review — NotificationService
| Field | Value |
|---|---|
| Module | src/ScadaLink.NotificationService |
| Design doc | docs/requirements/Component-NotificationService.md |
| Status | Reviewed |
| Last reviewed | 2026-05-16 |
| Reviewer | claude-agent |
| Commit reviewed | 9c60592 |
| Open findings | 0 |
Summary
The NotificationService module is small (6 source files) and structurally clean: it
abstracts the SMTP client behind an interface, isolates the OAuth2 token lifecycle,
and integrates with the Store-and-Forward Engine for transient-failure buffering.
However, the review surfaced several substantive defects. The most serious is that
no Store-and-Forward delivery handler is ever registered for the Notification
category — buffered notifications are persisted but never retried or delivered,
silently losing every notification that hit a transient SMTP failure. Error
classification is fragile (substring matching on exception messages) and is
applied inconsistently between SendAsync and DeliverAsync. DeliverAsync also
contains a resource-management bug that constructs and leaks two SMTP clients per
call. Secondary themes: the OAuth2TokenService singleton caches a single token
keyed to no credential identity (incorrect if multiple SMTP configs exist), several
design-doc requirements are unimplemented (connection timeout, max concurrent
connections, TLS SSL/None modes), and credentials are stored and passed as
plaintext string values. Test coverage exercises the happy path and the main
error branches but misses the OAuth2 delivery path, the permanent-classification
fallback in DeliverAsync, and concurrency on the token cache.
Checklist coverage
| # | Category | Examined | Notes |
|---|---|---|---|
| 1 | Correctness & logic bugs | ☑ | Double SMTP client construction; Auto socket option for non-TLS; TimeoutException/OperationCanceledException misclassified. |
| 2 | Akka.NET conventions | ☑ | No actors in this module (AddNotificationServiceActors is a no-op); delivery is a plain DI service. No Akka-specific issues. |
| 3 | Concurrency & thread safety | ☑ | OAuth2TokenService is a singleton with a shared mutable token cache; double-checked locking present but cache key is wrong (NS-006). |
| 4 | Error handling & resilience | ☑ | Critical: no S&F delivery handler registered for Notification (NS-001). Fragile substring error classification (NS-002, NS-003). |
| 5 | Security | ☑ | Credentials handled as plaintext strings; OAuth2 client secret in DB credential blob; no recipient address validation. |
| 6 | Performance & resource management | ☑ | Two ISmtpClientWrapper instances created per send, one leaked; connection not pooled; MaxConcurrentConnections unenforced. |
| 7 | Design-document adherence | ☑ | Connection timeout, max concurrent connections, and TLS SSL/None modes from the design doc are not implemented. |
| 8 | Code organization & conventions | ☑ | SmtpPermanentException in the wrong file; SmtpConfiguration POCO has non-nullable strings with no initializer (compiler-warning risk). |
| 9 | Testing coverage | ☑ | Happy path and main error branches covered; OAuth2 delivery path, DeliverAsync permanent fallback, and token-cache concurrency untested. |
| 10 | Documentation & comments | ☑ | XML comment on DeliverAsync ("Throws on failure") and the misleading "OAuth2 token refresh if needed" comment do not match behaviour. |
Findings
NotificationService-001 — Buffered notifications are never retried (no S&F delivery handler)
| Severity | Critical |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | src/ScadaLink.NotificationService/NotificationDeliveryService.cs:96, src/ScadaLink.NotificationService/ServiceCollectionExtensions.cs:8 |
Description
On a transient SMTP failure the service calls _storeAndForward.EnqueueAsync(StoreAndForwardCategory.Notification, ...). The Store-and-Forward Engine only delivers (immediately or on retry sweep) a category for which a delivery handler has been registered via StoreAndForwardService.RegisterDeliveryHandler. A repo-wide search shows the Notification category handler is never registered anywhere — StoreAndForwardCategory.Notification appears only in this module's EnqueueAsync call. As a result, every buffered notification falls into the RetryMessageAsync "No delivery handler for category" branch (StoreAndForwardService.cs:201-204), which logs a warning and returns without ever delivering or removing the message. Buffered notifications accumulate in SQLite forever and are never sent. This silently loses every notification that hit a transient failure, while SendAsync returns Success=true, WasBuffered=true, telling the caller the notification is safely queued. This directly violates the design doc's "integrates with the Store-and-Forward Engine for reliable delivery" guarantee.
Recommendation
Register a delivery handler for StoreAndForwardCategory.Notification during startup that deserializes the buffered payload (ListName, Subject, Message), re-resolves the list/recipients/SMTP config, and re-attempts DeliverAsync, returning true on success, false on permanent failure, and throwing on transient failure. Wire it in AddNotificationService or the host bootstrap. Add an integration test covering the buffer-then-retry-then-deliver round trip.
Resolution
Resolved 2026-05-16. A delivery handler for StoreAndForwardCategory.Notification is now
registered at site startup in AkkaHostedService. The handler resolves
NotificationDeliveryService in a fresh DI scope and calls the new DeliverBufferedAsync,
which re-resolves the list, recipients and SMTP config and re-attempts delivery —
returning true on success, false (park) on permanent failure or missing
configuration, and throwing on transient failure so the engine retries. SendAsync now
buffers with attemptImmediateDelivery: false so registering the handler does not send
the notification twice. Regression tests cover the happy path and the list-removed park
path. Fixed by the commit whose message references NotificationService-001.
NotificationService-002 — TimeoutException/OperationCanceledException misclassified as transient
| Severity | High |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | src/ScadaLink.NotificationService/NotificationDeliveryService.cs:157-167 |
Description
IsTransientSmtpError treats OperationCanceledException (and its subtype TaskCanceledException) as a transient SMTP error. When the caller passes a CancellationToken that is cancelled — e.g. the Script Execution Actor is stopped, or the script times out — the resulting OperationCanceledException is caught by the catch ... when (IsTransientSmtpError(ex)) clause and the notification is buffered as if SMTP had failed. A deliberate cancellation should propagate, not be silently buffered for retry. The same clause classifies any IOException as transient even though IOException covers unrelated failures (e.g. a serialization stream error). Additionally, OperationCanceledException raised by token cancellation in the OAuth2 path would be miscategorised the same way.
Recommendation
Re-throw OperationCanceledException/TaskCanceledException when cancellationToken.IsCancellationRequested is true rather than classifying it as transient. Narrow IOException handling to SMTP-specific I/O failures, or rely on MailKit's typed exceptions (SmtpCommandException, SmtpProtocolException, ServiceNotConnectedException) instead of broad base types.
Resolution
Resolved 2026-05-16 (commit <pending>). Classification was rewritten around a typed
ClassifySmtpError helper: a caller-requested cancellation (OperationCanceledException/
TaskCanceledException while cancellationToken.IsCancellationRequested) now propagates
out of both SendAsync and DeliverAsync via dedicated catch filters instead of being
buffered. The broad IOException catch-all was dropped — only MailKit's typed exceptions
plus SocketException/TimeoutException are treated as transient. Regression tests
Send_CancellationRequested_PropagatesAndDoesNotBuffer and
Send_TaskCanceledException_WithCancellation_Propagates.
NotificationService-003 — Error classification by substring matching on exception messages is fragile
| Severity | High |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | src/ScadaLink.NotificationService/NotificationDeliveryService.cs:144-147, src/ScadaLink.NotificationService/NotificationDeliveryService.cs:163-166 |
Description
Transient/permanent classification depends on ex.Message.Contains("5."), Contains("4."), Contains("550"), Contains("421"), etc. This is unreliable: (a) Message.Contains("5.") matches any message containing the literal "5." anywhere — e.g. a host name smtp5.example.com, a version string, or a path — producing false permanent classification; (b) Contains("4.") likewise matches "v4.0" or an IP address octet; (c) MailKit exposes the actual SMTP status code on SmtpCommandException.StatusCode, which is the correct, locale-independent source of truth and is being ignored; (d) message text is culture/version-dependent and not part of any stable contract. Misclassification has real consequences: a permanent failure misread as transient floods the S&F buffer (which the design doc explicitly says must be prevented), and a transient failure misread as permanent loses the notification.
Recommendation
Classify on MailKit's typed exceptions and SmtpCommandException.StatusCode (4xx → transient, 5xx → permanent), and SocketException/SmtpProtocolException/connection-refused → transient. Remove all Message.Contains checks.
Resolution
Resolved 2026-05-16 (commit <pending>). All ex.Message.Contains(...) checks were
removed. The new ClassifySmtpError helper inspects SmtpCommandException.StatusCode
(numeric SMTP code: 4xx → transient, 5xx → permanent) and treats SmtpProtocolException,
ServiceNotConnectedException, SocketException and TimeoutException as transient;
anything else is Unknown and propagates unclassified rather than being guessed. The
permanent-promotion catch block in DeliverAsync now keys off this classification.
Regression tests Send_Smtp5xxCommandException_ClassifiedPermanent,
Send_Smtp4xxCommandException_ClassifiedTransientAndBuffered,
Send_SmtpProtocolException_ClassifiedTransient, and
Send_NonSmtpExceptionWith5xxLookalikeText_NotPromotedToPermanent.
NotificationService-004 — DeliverAsync constructs two SMTP clients and leaks the used one
| Severity | High |
| Category | Performance & resource management |
| Status | Resolved |
| Location | src/ScadaLink.NotificationService/NotificationDeliveryService.cs:118-119 |
Description
using var client = _smtpClientFactory() as IDisposable;
var smtp = _smtpClientFactory();
The factory is invoked twice, creating two separate MailKitSmtpClientWrapper instances (each owning a real SmtpClient with a socket). The first instance is assigned to client and disposed by the using, but it is never used. The second instance, smtp, is the one actually connected, authenticated, used to send, and DisconnectAsync'd — but it is never Disposed. MailKitSmtpClientWrapper implements IDisposable and wraps an unmanaged socket; the connected client is leaked on every send. DisconnectAsync closes the connection but does not dispose the SmtpClient. Over time this leaks sockets/handles.
Recommendation
Create exactly one client and dispose the one that is actually used:
using var smtp = _smtpClientFactory(); then cast to IDisposable only if needed (the factory's Func<ISmtpClientWrapper> should ideally return a type that the using can dispose directly — consider having ISmtpClientWrapper extend IAsyncDisposable/IDisposable).
Resolution
Resolved 2026-05-16 (commit <pending>). DeliverAsync now invokes _smtpClientFactory()
exactly once and disposes the client actually used via using var disposable = smtp as IDisposable;. The previous code created two MailKitSmtpClientWrapper instances per send
and disposed the unused one while leaking the connected one. Regression test
Send_CreatesExactlyOneSmtpClient_AndDisposesIt verifies the factory is invoked once and
the resulting client is disposed.
NotificationService-005 — Non-TLS path uses SecureSocketOptions.Auto, contradicting the requested mode
| Severity | Medium |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | src/ScadaLink.NotificationService/MailKitSmtpClientWrapper.cs:18, src/ScadaLink.NotificationService/NotificationDeliveryService.cs:123 |
Description
ConnectAsync maps useTls to either SecureSocketOptions.StartTls or SecureSocketOptions.Auto. useTls is computed in DeliverAsync as TlsMode == "starttls". So a configuration of TlsMode = "none" produces useTls = false → SecureSocketOptions.Auto, which lets MailKit opportunistically negotiate TLS — the opposite of "None". Worse, the design doc defines three TLS modes — None, StartTLS, SSL — but the code collapses them to a single boolean, so SSL (implicit TLS, typically port 465) is treated identically to None/Auto and the SSL mode is effectively unsupported. The bool useTls parameter cannot represent the three-state requirement.
Recommendation
Pass the TlsMode string (or a TlsMode enum) through to the wrapper and map explicitly: None → SecureSocketOptions.None, StartTLS → SecureSocketOptions.StartTls, SSL → SecureSocketOptions.SslOnConnect. Validate the configured value and reject unknown modes.
Resolution
Resolved 2026-05-16 (commit pending). Root cause confirmed against source: the
bool useTls parameter cannot represent three states, and the non-StartTLS branch used
SecureSocketOptions.Auto. A new SmtpTlsMode enum (None/StartTls/Ssl) and
SmtpTlsModeParser were added; ISmtpClientWrapper.ConnectAsync now takes SmtpTlsMode
and MailKitSmtpClientWrapper maps it explicitly to SecureSocketOptions.None/
StartTls/SslOnConnect. SendAsync/DeliverBufferedAsync validate the configured
TlsMode up front — an unknown value returns a clean NotificationResult failure (or
parks a buffered message) instead of silently negotiating TLS. Regression tests:
Send_TlsModeNone_DoesNotNegotiateTls, Send_TlsModeSsl_UsesImplicitSsl,
Send_UnknownTlsMode_ReturnsErrorNotSilentFallback, and the SmtpTlsModeParserTests set.
NotificationService-006 — OAuth2 token cache is keyed to nothing; wrong token returned when multiple SMTP configs exist
| Severity | Medium |
| Category | Concurrency & thread safety |
| Status | Resolved |
| Location | src/ScadaLink.NotificationService/OAuth2TokenService.cs:14-15, src/ScadaLink.NotificationService/OAuth2TokenService.cs:30-35 |
Description
OAuth2TokenService is registered as a singleton and stores a single _cachedToken/_tokenExpiry pair. GetTokenAsync ignores the credentials argument when deciding whether the cache is valid — it only checks expiry. If two SMTP configurations with different tenant/client credentials are ever used (the repository's GetAllSmtpConfigurationsAsync returns a list, implying multiple configs are possible), the second caller receives the first caller's token, which will fail authentication against the second tenant. Even with a single config today this is a latent correctness bug and makes the service's behaviour depend on call order.
Recommendation
Key the cache by the credential identity (e.g. a dictionary keyed by tenantId:clientId, or by a hash of the credential string), or document and enforce the single-SMTP-config invariant. Given the design doc says one SMTP config is deployed per site, enforcing the invariant is acceptable but should be explicit.
Resolution
Resolved 2026-05-16 (commit pending). Root cause confirmed: the singleton held a single
_cachedToken/_tokenExpiry pair and GetTokenAsync ignored the credentials argument
when validating the cache, so a second SMTP config got the first config's token.
OAuth2TokenService now stores a ConcurrentDictionary<string, CacheEntry> keyed by the
SHA-256 hash of the credential string; each distinct tenant/client/secret gets its own
cached token, expiry, and per-credential SemaphoreSlim (double-checked locking
preserved). Regression tests: GetTokenAsync_DifferentCredentials_ReturnPerCredentialTokens
and GetTokenAsync_SameCredentials_CachedPerCredential.
NotificationService-007 — Connection timeout and max-concurrent-connections from the design doc are not implemented
| Severity | Medium |
| Category | Design-document adherence |
| Status | Resolved |
| Location | src/ScadaLink.NotificationService/NotificationOptions.cs:11-14, src/ScadaLink.NotificationService/MailKitSmtpClientWrapper.cs:16-20, src/ScadaLink.NotificationService/NotificationDeliveryService.cs:111-140 |
Description
The design doc specifies an SMTP "Connection timeout (default 30s)" and "Max concurrent connections (default 5)", and NotificationOptions/SmtpConfiguration both carry these fields. Neither is enforced: MailKitSmtpClientWrapper.ConnectAsync never sets SmtpClient.Timeout, so the connection relies on MailKit's default timeout rather than the configured value (only the caller's CancellationToken bounds it, and callers may pass default). There is no semaphore or other throttle limiting concurrent SMTP connections per site, so MaxConcurrentConnections has no effect. Both options exist but are dead configuration.
Recommendation
Set SmtpClient.Timeout from ConnectionTimeoutSeconds in ConnectAsync (and/or derive a linked CancellationTokenSource). Introduce a SemaphoreSlim(MaxConcurrentConnections) gating DeliverAsync. If these limits are intentionally deferred, mark the options [Obsolete]/document them as not-yet-enforced and note the gap in the design doc.
Resolution
Resolved 2026-05-16 (commit pending). Root cause confirmed: ConnectAsync never set
SmtpClient.Timeout and no throttle gated DeliverAsync. ISmtpClientWrapper.ConnectAsync
now takes a connectionTimeoutSeconds argument; MailKitSmtpClientWrapper sets
SmtpClient.Timeout from SmtpConfiguration.ConnectionTimeoutSeconds. DeliverAsync
acquires a lazily-created SemaphoreSlim sized to SmtpConfiguration.MaxConcurrentConnections
(default 5 when non-positive) and releases it in a finally, so concurrent SMTP
deliveries per site are bounded. The timeout is sourced from the deployed
SmtpConfiguration rather than NotificationOptions; the NotificationOptions fields
remain as operational fallback defaults. Regression tests:
Send_PassesConfiguredConnectionTimeoutToClient and
Send_MaxConcurrentConnections_LimitsConcurrentDeliveries.
NotificationService-008 — Recipient email addresses are not validated before send
| Severity | Medium |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | src/ScadaLink.NotificationService/NotificationDeliveryService.cs:136-137, src/ScadaLink.NotificationService/MailKitSmtpClientWrapper.cs:50-53 |
Description
SendAsync builds bccAddresses directly from recipient.EmailAddress and passes them to MailboxAddress.Parse. If any recipient row has a malformed address, MailboxAddress.Parse throws ParseException. ParseException is not a TimeoutException/SocketException/IOException and its message will not generally contain "4." or "5.", so it falls through DeliverAsync's outer catch ... when (... && !IsTransientSmtpError(ex)) filter, which re-throws it (:153); it then escapes SendAsync entirely as an unhandled exception (the SendAsync catch blocks only cover SmtpPermanentException and transient errors). A single bad address in a list therefore crashes the send with an exception type the calling script is not told to expect, instead of producing a clean NotificationResult error. The same applies to a malformed FromAddress.
Recommendation
Validate addresses up front (e.g. MailboxAddress.TryParse) and return a NotificationResult(false, ...) listing invalid recipients, or wrap DeliverAsync so any non-classified exception becomes a permanent NotificationResult failure rather than escaping. Consider validating addresses at definition time in the Central UI as well.
Resolution
Resolved 2026-05-16 (commit pending). Root cause confirmed: MailboxAddress.Parse of a
malformed FromAddress/recipient threw ParseException, which is unclassified and
escaped SendAsync as an unhandled exception. A new ValidateAddresses helper uses
MailboxAddress.TryParse for the sender and every recipient; SendAsync now returns a
clean NotificationResult(false, ...) listing the invalid address(es) before any SMTP
attempt, and DeliverBufferedAsync parks a buffered message with a bad address (a fault
retrying cannot fix). Regression tests:
Send_MalformedRecipientAddress_ReturnsCleanError_DoesNotThrow and
Send_MalformedFromAddress_ReturnsCleanError_DoesNotThrow. Definition-time validation in
the Central UI is a separate component and out of this module's scope.
NotificationService-009 — Credentials handled as plaintext strings; OAuth2 client secret logged risk
| Severity | Medium — re-triaged: split into an in-scope log-leak fix (resolved) and a Commons-scoped at-rest-encryption / structured-credential follow-up (NotificationService-013, Deferred). |
| Category | Security |
| Status | Resolved |
| Location | src/ScadaLink.NotificationService/NotificationDeliveryService.cs:127-134, src/ScadaLink.NotificationService/OAuth2TokenService.cs:30-65, src/ScadaLink.Commons/Entities/Notifications/SmtpConfiguration.cs:9 |
Description
SMTP credentials — Basic Auth user:pass and OAuth2 tenantId:clientId:clientSecret — are stored and passed as a single colon-delimited plaintext string (SmtpConfiguration.Credentials). There is no indication the value is encrypted at rest in SQLite or in the central config DB. The colon-delimited packing is also brittle: a password or client secret containing a : will be split incorrectly (Split(':', 2) / Split(':', 3)), silently corrupting the secret. Separately, while the current code does not log the secret directly, the substring-based error classification logs full exception messages (_logger.LogWarning(ex, ...), LogError(ex, ...)) and MailKit exceptions can echo back server responses; an authentication failure message could surface credential fragments into logs. There is no defensive scrubbing.
Recommendation
Store credentials encrypted at rest (DPAPI/Data Protection or a secret store) and model them as structured fields rather than a colon-packed string, so secrets containing : are safe. Ensure credential values are never written to logs; consider a redaction step on exception messages before logging.
Resolution
Resolved 2026-05-16 (commit pending). Root cause re-triaged against source: the finding conflates two concerns with different ownership.
- Log-leak risk (in scope — fixed). The original code logged whole exception objects
(
_logger.LogWarning(ex, ...)/LogError(ex, ...)); MailKit auth exceptions can echo server responses quoting the supplied credentials. A new internalCredentialRedactormasks every colon-delimited credential component out of any text.SendAsyncandDeliverBufferedAsyncnow log a scrubbed message string (not the raw exception) and the permanent-failureNotificationResultis scrubbed before it returns to the caller.OAuth2TokenServicelogs the tenant id only — never the client secret or access token. Regression tests:CredentialRedactorTestsandSend_PermanentError_RedactsCredentialFromResultMessage. - At-rest encryption + structured-credential modelling (out of scope — Deferred).
Encrypting
SmtpConfiguration.Credentialsat rest and replacing the brittle colon-packedstringwith structured fields requires editingsrc/ScadaLink.Commons/Entities/Notifications/SmtpConfiguration.csand the ConfigurationDatabase EF layer — both outside this module. Tracked separately as NotificationService-013 (Deferred) so it is not lost.
NotificationService-013 — Encrypt SMTP credentials at rest; replace colon-packed string with structured fields
| Severity | Medium |
| Category | Security |
| Status | Deferred |
| Location | src/ScadaLink.Commons/Entities/Notifications/SmtpConfiguration.cs:9, ConfigurationDatabase EF mapping |
Description
Split out of NotificationService-009. SmtpConfiguration.Credentials packs Basic Auth
user:pass and OAuth2 tenantId:clientId:clientSecret into a single plaintext
colon-delimited string: (a) there is no encryption at rest in SQLite or the central
config DB; (b) a password or client secret containing a : is split incorrectly by
Split(':', 2) / Split(':', 3), silently corrupting the secret.
Recommendation
Model credentials as structured fields (or an encrypted blob) on the Commons entity and
encrypt at rest via Data Protection / a secret store. The colon-delimited parsing in
MailKitSmtpClientWrapper and OAuth2TokenService would then consume the structured
fields directly.
Resolution
Deferred — requires changes to src/ScadaLink.Commons and the ConfigurationDatabase
component, which are outside the NotificationService module. To be addressed in a
Commons/ConfigurationDatabase-scoped change. The associated log-leak risk is resolved
under NotificationService-009.
NotificationService-010 — DeliverAsync does not disconnect the SMTP client on failure
| Severity | Low |
| Category | Performance & resource management |
| Status | Resolved |
| Location | src/ScadaLink.NotificationService/NotificationDeliveryService.cs:121-154 |
Description
DisconnectAsync is only called at :139, on the success path inside the try block. If AuthenticateAsync or SendAsync throws, control jumps to the catch filter at :141 and the method exits (re-throwing or wrapping) without ever calling DisconnectAsync. Combined with NS-004 (the client is never disposed either), a failed send leaves an open, authenticated SMTP connection until the socket is eventually reclaimed by finalization. Under sustained transient failures this can exhaust the SMTP server's connection slots.
Recommendation
Move disconnect/dispose into a finally block (or use await using once ISmtpClientWrapper supports IAsyncDisposable) so the connection is always torn down regardless of outcome.
Resolution
Resolved 2026-05-16 (commit pending). Root cause confirmed against the current source:
DeliverAsync invoked smtp.DisconnectAsync only on the success path inside the try
block, so a failed Connect/Authenticate/Send left the connection open until
finalization. DisconnectAsync was moved into the existing finally block (which also
releases the NS-007 concurrency slot), so the SMTP connection is always torn down
regardless of outcome. The disconnect is best-effort — wrapped in its own try/catch that
logs at debug level — so a disconnect failure (e.g. the connection is already dead) cannot
mask the original delivery exception. Regression tests
Send_TransientFailureDuringSend_StillDisconnectsClient and
Send_FailureDuringAuthenticate_StillDisconnectsClient.
NotificationService-011 — SmtpPermanentException declared in the wrong file; module conventions
| Severity | Low |
| Category | Code organization & conventions |
| Status | Resolved |
| Location | src/ScadaLink.NotificationService/NotificationDeliveryService.cs:173-177, src/ScadaLink.Commons/Entities/Notifications/SmtpConfiguration.cs:5-15 |
Description
Two minor convention issues. (1) SmtpPermanentException is a public exception type declared at the bottom of NotificationDeliveryService.cs rather than in its own file (SmtpPermanentException.cs), which is inconsistent with the one-type-per-file layout used elsewhere and makes it harder to locate. (2) SmtpConfiguration (a Commons POCO) declares non-nullable string properties (Host, AuthType, FromAddress) that are only guaranteed by the constructor; EF Core materialization or object-initializer use can leave them null while the type system says otherwise. These are persistence-ignorant POCO concerns but worth flagging because the delivery service dereferences config.Host, config.AuthType, config.FromAddress without null checks.
Recommendation
Move SmtpPermanentException to its own file. For SmtpConfiguration, either keep the constructor as the only path and document it, or use required members so the compiler enforces initialization.
Resolution
Resolved 2026-05-16 (commit pending). Both issues confirmed against source.
SmtpPermanentExceptionplacement (in scope — fixed). The public exception type was extracted from the bottom ofNotificationDeliveryService.csinto its own file,src/ScadaLink.NotificationService/SmtpPermanentException.cs, restoring the one-type-per-file layout. No behaviour change, so no dedicated regression test — the move is verified by the module test suite still compiling and passing (56 tests green).SmtpConfigurationnon-nullable strings (out of scope — re-triaged).SmtpConfigurationlives insrc/ScadaLink.Commons, which is outside the NotificationService module's edit scope; it cannot be changed here. This is the same Commons entity already flagged for follow-up under NotificationService-013 (Deferred). Therequired-members / documented-constructor change should be folded into that Commons/ConfigurationDatabase-scoped work. Note the delivery service's actual risk is bounded:SendAsync/DeliverBufferedAsyncalready validateTlsModeand addresses up front (NS-005/NS-008), and a nullHost/AuthTypefrom a malformed config row surfaces as a classified/clean failure rather than silent corruption.
NotificationService-012 — Test coverage gaps: OAuth2 delivery path, permanent-classification fallback, token-cache concurrency
| Severity | Low |
| Category | Testing coverage |
| Status | Resolved |
| Location | tests/ScadaLink.NotificationService.Tests/NotificationDeliveryServiceTests.cs, tests/ScadaLink.NotificationService.Tests/OAuth2TokenServiceTests.cs |
Description
The tests cover the happy path, list-not-found, no-recipients, no-SMTP-config, permanent failure, transient-without-S&F, and transient-with-S&F buffering. Notable untested paths: (1) the OAuth2 delivery branch in DeliverAsync:128-132 — every test uses tokenService: null and Basic Auth, so OAuth2 token resolution during a send is never exercised; (2) DeliverAsync's permanent-classification fallback (:144-149) that promotes a generic exception whose message contains "550"/"553"/"554" to SmtpPermanentException is never tested; (3) OAuth2TokenServiceTests never tests concurrent GetTokenAsync calls (the double-checked-locking path) or token expiry/refresh — the cache test uses a 3600s token so refresh never triggers; (4) no test covers the transient-with-S&F path actually delivering after retry (which would also have caught NS-001). Given NS-001 is a critical defect, the absence of an end-to-end buffer-and-retry test is significant.
Recommendation
Add tests for: OAuth2-authenticated send with a mocked OAuth2TokenService; the DeliverAsync 5xx-message permanent fallback; token expiry/refresh (short expires_in); concurrent token acquisition; and an end-to-end buffered-notification retry once a Notification S&F handler is registered.
Resolution
Resolved 2026-05-16 (commit pending). Each listed gap was re-triaged against the current source (the module was substantially rewritten by NS-001..009 since this finding was filed) and the genuinely-missing tests were added:
- OAuth2 delivery path (gap real — covered). New test
Send_OAuth2Config_AuthenticatesWithResolvedAccessTokendrivesDeliverAsyncwith anoauth2SmtpConfigurationand a realOAuth2TokenService(mockedIHttpClientFactory), asserting the SMTP client is authenticated with the resolved access token, not the rawtenant:client:secrettriple. - 5xx-message permanent fallback (gap obsolete — re-triaged). The substring-based
permanent fallback this item describes no longer exists: NS-003 replaced message-text
matching with typed-exception/
SmtpStatusCodeclassification. The equivalent path is already covered bySend_Smtp5xxCommandException_ClassifiedPermanent, andSend_NonSmtpExceptionWith5xxLookalikeText_NotPromotedToPermanentproves a "5xx lookalike" message is no longer promoted. No new test needed for a removed branch. - Token expiry/refresh and concurrent acquisition (gaps real — covered). New tests
GetTokenAsync_ExpiredToken_RefreshesOnNextCall(usesexpires_in: 60so the token is stale immediately given the 60s refresh margin) andGetTokenAsync_ConcurrentCalls_MakeExactlyOneHttpRequest(20 racing callers collapse to a single token fetch, exercising the per-credential double-checked lock). - End-to-end buffer-and-retry (gap already closed — re-triaged). The
buffer-then-retry-then-deliver round trip was added when NS-001 was resolved
(
DeliverBuffered_HappyPath_ReturnsTrue,DeliverBuffered_ListNoLongerExists_…, plusAkkaHostedServicehandler registration), so no further test is required here.
Module test suite is green at 56 tests.