From b21a6ef175ed2240986c2feb4aac23b4dc46f38a Mon Sep 17 00:00:00 2001 From: Andy Howard Date: Sat, 12 Sep 2026 12:06:56 +0100 Subject: [PATCH] Add email message characterization tests --- .../Base/Email/SaEmailConsole_Tester.cs | 157 ++++++++++++++++++ SacsStandardBase/Base/Email/SaEmailConsole.cs | 2 +- SacsStandardBase/Properties/AssemblyInfo.cs | 3 + docs/component-reviews.md | 2 +- docs/topics.md | 2 +- 5 files changed, 163 insertions(+), 3 deletions(-) create mode 100644 SacsCoreUnitTests/Standard/Base/Email/SaEmailConsole_Tester.cs create mode 100644 SacsStandardBase/Properties/AssemblyInfo.cs diff --git a/SacsCoreUnitTests/Standard/Base/Email/SaEmailConsole_Tester.cs b/SacsCoreUnitTests/Standard/Base/Email/SaEmailConsole_Tester.cs new file mode 100644 index 0000000..e11d642 --- /dev/null +++ b/SacsCoreUnitTests/Standard/Base/Email/SaEmailConsole_Tester.cs @@ -0,0 +1,157 @@ +using System.Linq; +using System.Text; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using MimeKit; +using Sarand.Net.Standard.Base.Email; + +namespace Sarand.UnitTests.Standard.Base.Email +{ + [TestClass] + public class SaEmailConsole_Tester + { + // --- Test Support --- + + private class TestEmailConsole : SaEmailConsole + { + public MimeMessage CreateMessage(SaEmailData emailData) + { + return CreateMailMessage(emailData); + } + } + + // --- Message Content --- + + [TestMethod] + public void CreateMailMessage_PopulatesAddressesSubjectAndTextBody() + { + SaEmailData emailData = CreateEmailData(); + emailData.AddressesTo.Add("to@example.com"); + emailData.AddressesCc.Add("cc@example.com"); + emailData.AddressesBcc.Add("bcc@example.com"); + emailData.Config.Message.AddressesBcc.Add("configured-bcc@example.com"); + emailData.Subject = "Test subject"; + emailData.MessageBody = "Test body"; + + MimeMessage message = new TestEmailConsole().CreateMessage(emailData); + + Assert.AreEqual("sender@example.com", ((MailboxAddress)message.From.Single()).Address); + Assert.AreEqual("to@example.com", ((MailboxAddress)message.To.Single()).Address); + Assert.AreEqual("cc@example.com", ((MailboxAddress)message.Cc.Single()).Address); + CollectionAssert.AreEquivalent( + new[] { "bcc@example.com", "configured-bcc@example.com" }, + message.Bcc.Mailboxes.Select(address => address.Address).ToArray()); + Assert.AreEqual("Test subject", message.Subject); + Assert.AreEqual("Test body", message.TextBody); + Assert.IsNull(message.HtmlBody); + } + + [TestMethod] + public void CreateMailMessage_PopulatesHtmlBody() + { + SaEmailData emailData = CreateEmailData(); + emailData.HtmlBody = true; + emailData.MessageBody = "

Test body

"; + + MimeMessage message = new TestEmailConsole().CreateMessage(emailData); + + Assert.AreEqual("

Test body

", message.HtmlBody); + Assert.IsNull(message.TextBody); + } + + // --- Attachments --- + + [TestMethod] + public void CreateMailMessage_PopulatesAttachment() + { + SaEmailData emailData = CreateEmailData(); + emailData.Attachments.Add(new SaEmailAttachmentData("text", "plain") + { + Filename = "test.txt", + Data = Encoding.UTF8.GetBytes("Attachment content") + }); + + MimeMessage message = new TestEmailConsole().CreateMessage(emailData); + MimePart attachment = (MimePart)message.Attachments.Single(); + + Assert.AreEqual("test.txt", attachment.FileName); + Assert.AreEqual("text/plain", attachment.ContentType.MimeType); + Assert.AreEqual(ContentDisposition.Attachment, attachment.ContentDisposition.Disposition); + Assert.AreEqual(ContentEncoding.Base64, attachment.ContentTransferEncoding); + } + + [TestMethod] + public void CreateMailMessage_UsesDefaultAttachmentFilename() + { + SaEmailData emailData = CreateEmailData(); + emailData.Attachments.Add(new SaEmailAttachmentData("application", "octet-stream") + { + Data = new byte[] { 1, 2, 3 } + }); + + MimeMessage message = new TestEmailConsole().CreateMessage(emailData); + MimePart attachment = (MimePart)message.Attachments.Single(); + + Assert.AreEqual("Attachment_001.dat", attachment.FileName); + } + + [TestMethod] + public void CreateMailMessage_PopulatesInlineAttachment() + { + SaEmailData emailData = CreateEmailData(); + emailData.HtmlBody = true; + emailData.MessageBody = ""; + emailData.Attachments.Add(new SaEmailAttachmentData("image", "png") + { + Filename = "test.png", + ContentId = "test-image@example.com", + Data = new byte[] { 1, 2, 3 }, + Inline = true + }); + + MimeMessage message = new TestEmailConsole().CreateMessage(emailData); + MimePart attachment = message.BodyParts.OfType().Single(part => part.ContentId == "test-image@example.com"); + + Assert.AreEqual("test.png", attachment.FileName); + Assert.AreEqual(ContentDisposition.Inline, attachment.ContentDisposition.Disposition); + Assert.AreEqual("", message.HtmlBody); + } + + // --- Formatting and Tag Tests --- + + [TestMethod] + public void DynamicEmailTags_AreMatchedAndReplacedCaseInsensitively() + { + SaEmailData emailData = CreateEmailData(); + emailData.MessageBody = "Before [#Test.Tag] after [#TEST.TAG]."; + + Assert.IsTrue(SaEmailConsole.EmailContainsTag(emailData, "[#test.tag]")); + + SaEmailConsole.ReplaceDynamicEmailTag(emailData, "[#test.tag]", "replacement"); + + Assert.AreEqual("Before replacement after replacement.", emailData.MessageBody); + } + + [TestMethod] + public void FormatAttachmentContentId_ReplacesAddressAndWhitespaceCharacters() + { + Assert.AreEqual("test_image_name@saxonite.com", SaEmailConsole.FormatAttachmentContentId("test @ image name")); + } + + // --- Test Data --- + + private static SaEmailData CreateEmailData() + { + SaEmailConfig config = new SaEmailConfig("UnitTest"); + config.Message.AddressFrom = "sender@example.com"; + + return new SaEmailData + { + Config = config, + Subject = "Unit test subject", + MessageBody = "" + }; + } + + // --- The End --- + } +} diff --git a/SacsStandardBase/Base/Email/SaEmailConsole.cs b/SacsStandardBase/Base/Email/SaEmailConsole.cs index 880dfc1..0c3f386 100644 --- a/SacsStandardBase/Base/Email/SaEmailConsole.cs +++ b/SacsStandardBase/Base/Email/SaEmailConsole.cs @@ -60,7 +60,7 @@ namespace Sarand.Net.Standard.Base.Email } //------------------------------------------------------------------- - private MimeMessage CreateMailMessage(SaEmailData emailData) + internal MimeMessage CreateMailMessage(SaEmailData emailData) { // Mail message. MimeMessage mailMessage = new MimeMessage(); diff --git a/SacsStandardBase/Properties/AssemblyInfo.cs b/SacsStandardBase/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..ec76b8c --- /dev/null +++ b/SacsStandardBase/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("SacsCoreUnitTests")] diff --git a/docs/component-reviews.md b/docs/component-reviews.md index 381b9fa..9e55b6b 100644 --- a/docs/component-reviews.md +++ b/docs/component-reviews.md @@ -15,5 +15,5 @@ Last updated: 2026-09-11 | `SacsMobileXamarin` | Migration disposition | Isolated; retained for reference | 2026-09-10 | Keep outside the supported build unless a separately scoped resurrection or migration is approved. | Source is unchanged and available through `SaCommonVS.LegacyXamarin.sln`; see `docs/legacy-xamarin.md`. | | [`SacsMobileMaui`](../SacsMobileMaui/docs/component-reviews.md) | Project | .NET 10 migration complete; tracked in project docs | 2026-09-11 | Validate Apple targets on a Mac and continue API cleanup in the project tracker. | The shared project and both active mobile consumers target .NET 10. Windows runtime smoke tests pass, and Windows and Android Debug builds complete with zero errors. | | `SacsStandardBase/Base/Procs/SaProcess_Base.cs` | Class | In progress | 2026-09-09 | Complete the Process Base actions recorded in `topics.md`; review derived processes separately for cooperative polling. | Lifecycle state ownership and cancellation lock ordering were changed. Five focused tests pass. Remaining concerns include mixed sync/async execution, durable cancellation outcome, cancellation exception handling, disposal, and status synchronization. | -| `SacsStandardBase/Base/Email/SaEmailConsole.cs` | Class | Reviewed; action required | 2026-09-08 | Restore SMTP certificate validation and require the configured TLS mode. Add invalid-certificate and unavailable-TLS tests. | The current SMTP client accepts every server certificate. | +| `SacsStandardBase/Base/Email/SaEmailConsole.cs` | Class | Dependency upgrade in progress; security action also required | 2026-09-11 | Upgrade MailKit/MimeKit against the new characterization tests. Separately restore SMTP certificate validation and require the configured TLS mode. | Message construction is now testable without SMTP and covered for addressing, bodies, attachments, content IDs, default filenames, and tags. The current SMTP client still accepts every server certificate. | | `SacsStandardBase/Base/Helpers/SacsEncryptionHelper.cs` and `SacsStandardBase/Base/Data/SacsAesIvCrypter.cs` | Classes | Reviewed; action required | 2026-09-08 | Design a versioned authenticated-encryption format with legacy migration and tamper tests. | Current CBC encryption is unauthenticated; password-based key derivation uses a fixed salt and low iteration count. | diff --git a/docs/topics.md b/docs/topics.md index 7214864..b5cd294 100644 --- a/docs/topics.md +++ b/docs/topics.md @@ -13,7 +13,6 @@ Last updated: 2026-09-11 | Priority | Topic | Date noted | Next step | Notes | |---|---|---|---|---| -| P1 | Update vulnerable MailKit and MimeKit dependencies | 2026-09-10 | Select a patched compatible MailKit release, update the direct dependency, and rerun both target test suites plus downstream BusinessMgr validation. | Restore reports `NU1902` for MailKit 4.13.0 (`GHSA-9j88-vvj5-vhgr`) and its MimeKit 4.13.0 dependency (`GHSA-g7hc-96xr-gvvx`). Keep this security update distinct from the framework retargeting commit. | | P2 | Resolve .NET 10 migration warnings in non-MAUI Common code | 2026-09-10 | Replace the obsolete `Rfc2898DeriveBytes` constructor, make the three `FileStream.Read` operations exact, and review removal of the redundant `Microsoft.Win32.Registry` package. | The .NET 10 build reports `SYSLIB0060`, three `CA2022` warnings, and `NU1510`. The cryptography change overlaps the existing encryption-hardening topic and requires compatibility tests. | | P1 | Prevent synchronous and asynchronous execution from overlapping in `SaProcess_Base` | 2026-09-09 | Route `RunProcess()` and `RunAsync()` through one guarded start operation and extend the lifecycle tests. | A synchronous call can currently reuse and dispose the asynchronous worker's task data. | | P2 | Retain cancellation as a completed process outcome | 2026-09-09 | Store a durable cancellation result until the next process reset and test post-completion state. | `IsInterrupted` currently loses token-based cancellation after task-data cleanup. | @@ -39,6 +38,7 @@ Last updated: 2026-09-11 | Priority | Topic | Date noted | Date started | Next step | Notes | |---|---|---|---|---|---| +| P1 | Update vulnerable MailKit and MimeKit dependencies | 2026-09-10 | 2026-09-11 | Run the new email characterization tests, then select a patched compatible MailKit release and validate Common plus downstream consumers. | MailKit 4.13.0 and its MimeKit 4.13.0 dependency report `NU1902`. Focused tests now cover message addressing, text and HTML bodies, ordinary and inline attachments, default filenames, content-ID formatting, and dynamic tags without contacting an SMTP server. | | P1 | Review and harden `SaProcess_Base` lifecycle and cancellation | 2026-09-08 | 2026-09-08 | Address the prioritised Process Base findings above, beginning with prevention of mixed synchronous/asynchronous execution. | Task ownership, timeout retention, cancellation lock ordering, documentation, and initial lifecycle tests are complete. Cooperative polling in individual derived processes will be handled separately. | ## Completed Topics