From 2e6d5c87bcd41b8a0c6a4745470edcd34d159907 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Fri, 19 Jul 2024 16:31:39 +0200 Subject: [PATCH] Add SMTP service integration tests (#111) --- .github/workflows/dotnet-e2e-tests.yml | 29 ++++ .../workflows/dotnet-integration-tests.yml | 8 +- ...ld-run-tests.yml => dotnet-unit-tests.yml} | 8 +- .../AllowedDomainsFilter.cs | 45 ------ .../{ => Handlers}/DatabaseMessageStore.cs | 27 ++-- .../AliasVault.SmtpService/Program.cs | 6 +- .../SmtpServer/SmtpServerTests.cs | 150 +++++++++++++++--- .../SmtpServer/TestHostBuilder.cs | 6 +- 8 files changed, 181 insertions(+), 98 deletions(-) create mode 100644 .github/workflows/dotnet-e2e-tests.yml rename .github/workflows/{dotnet-build-run-tests.yml => dotnet-unit-tests.yml} (92%) delete mode 100644 src/Services/AliasVault.SmtpService/AllowedDomainsFilter.cs rename src/Services/AliasVault.SmtpService/{ => Handlers}/DatabaseMessageStore.cs (94%) diff --git a/.github/workflows/dotnet-e2e-tests.yml b/.github/workflows/dotnet-e2e-tests.yml new file mode 100644 index 000000000..0bf12ce37 --- /dev/null +++ b/.github/workflows/dotnet-e2e-tests.yml @@ -0,0 +1,29 @@ +# This workflow will build a .NET project +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net + +name: .NET E2E Tests (Playwright) + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + test: + timeout-minutes: 60 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + - name: Install dependencies + run: dotnet workload install wasm-tools + - name: Build + run: dotnet build + - name: Ensure browsers are installed + run: pwsh src/Tests/AliasVault.E2ETests/bin/Debug/net8.0/playwright.ps1 install --with-deps + - name: Run E2E tests + run: dotnet test src/Tests/AliasVault.E2ETests --no-build --verbosity normal diff --git a/.github/workflows/dotnet-integration-tests.yml b/.github/workflows/dotnet-integration-tests.yml index 221052ab5..31330a2e3 100644 --- a/.github/workflows/dotnet-integration-tests.yml +++ b/.github/workflows/dotnet-integration-tests.yml @@ -1,7 +1,7 @@ # This workflow will build a .NET project # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net -name: Playwright integration tests +name: .NET Integration Tests on: push: @@ -23,7 +23,5 @@ jobs: run: dotnet workload install wasm-tools - name: Build run: dotnet build - - name: Ensure browsers are installed - run: pwsh src/Tests/AliasVault.E2ETests/bin/Debug/net8.0/playwright.ps1 install --with-deps - - name: Run your tests - run: dotnet test src/Tests/AliasVault.E2ETests --no-build --verbosity normal + - name: Run integration tests + run: dotnet test src/Tests/AliasVault.IntegrationTests --no-build --verbosity normal diff --git a/.github/workflows/dotnet-build-run-tests.yml b/.github/workflows/dotnet-unit-tests.yml similarity index 92% rename from .github/workflows/dotnet-build-run-tests.yml rename to .github/workflows/dotnet-unit-tests.yml index 79f2a4f6e..75314608b 100644 --- a/.github/workflows/dotnet-build-run-tests.yml +++ b/.github/workflows/dotnet-unit-tests.yml @@ -1,7 +1,7 @@ # This workflow will build a .NET project # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net -name: .NET build and run tests +name: .NET Unit Tests on: push: @@ -10,10 +10,8 @@ on: branches: [ "main" ] jobs: - build: - + test: runs-on: ubuntu-latest - steps: - uses: actions/checkout@v4 - name: Setup .NET @@ -26,5 +24,5 @@ jobs: run: dotnet restore - name: Build run: dotnet build --no-restore - - name: Test + - name: Run unittests run: dotnet test src/Tests/AliasVault.UnitTests --no-build --verbosity normal diff --git a/src/Services/AliasVault.SmtpService/AllowedDomainsFilter.cs b/src/Services/AliasVault.SmtpService/AllowedDomainsFilter.cs deleted file mode 100644 index 50a55b854..000000000 --- a/src/Services/AliasVault.SmtpService/AllowedDomainsFilter.cs +++ /dev/null @@ -1,45 +0,0 @@ -//----------------------------------------------------------------------- -// -// Copyright (c) lanedirt. All rights reserved. -// Licensed under the MIT license. See LICENSE.md file in the project root for full license information. -// -//----------------------------------------------------------------------- - -namespace AliasVault.SmtpService; - -using SmtpServer; -using SmtpServer.Mail; -using SmtpServer.Storage; - -/// -/// Filter to allow only emails from configured domains. -/// -public class AllowedDomainsFilter(Config config, ILogger logger) : IMailboxFilter, IMailboxFilterFactory -{ - private readonly TimeSpan _delay = TimeSpan.Zero; - - public async Task CanAcceptFromAsync(ISessionContext context, IMailbox from, int size, CancellationToken cancellationToken) - { - await Task.Delay(_delay, cancellationToken); - return true; - } - - public async Task CanDeliverToAsync(ISessionContext context, IMailbox to, IMailbox from, CancellationToken cancellationToken) - { - await Task.Delay(_delay, cancellationToken); - - if (!config.AllowedToDomains.Contains(to.Host.ToLowerInvariant())) - { - // ToAddress host is not allowed, return error to sender. - logger.LogWarning("Email to {ToAddress} is not allowed", to); - return false; - } - - return true; - } - - public IMailboxFilter CreateInstance(ISessionContext context) - { - return new AllowedDomainsFilter(context.ServiceProvider.GetRequiredService(), context.ServiceProvider.GetRequiredService>()); - } -} diff --git a/src/Services/AliasVault.SmtpService/DatabaseMessageStore.cs b/src/Services/AliasVault.SmtpService/Handlers/DatabaseMessageStore.cs similarity index 94% rename from src/Services/AliasVault.SmtpService/DatabaseMessageStore.cs rename to src/Services/AliasVault.SmtpService/Handlers/DatabaseMessageStore.cs index 6bbb12a93..3839cb375 100644 --- a/src/Services/AliasVault.SmtpService/DatabaseMessageStore.cs +++ b/src/Services/AliasVault.SmtpService/Handlers/DatabaseMessageStore.cs @@ -5,6 +5,8 @@ // //----------------------------------------------------------------------- +namespace AliasVault.SmtpService.Handlers; + using System.Buffers; using System.Net.Mail; using System.Text.RegularExpressions; @@ -16,8 +18,6 @@ using SmtpServer; using SmtpServer.Protocol; using SmtpServer.Storage; -namespace AliasVault.SmtpService; - /// /// Custom exception for when the email parsing fails to find the "to" address in the email. /// @@ -58,7 +58,7 @@ public class DatabaseMessageStore(ILogger logger, Config c } stream.Position = 0; - var message = await MimeKit.MimeMessage.LoadAsync(stream, cancellationToken); + var message = await MimeMessage.LoadAsync(stream, cancellationToken); // Retrieve all addresses from the SMTP transaction which should contain all recipients for this mail instance. var allAddresses = transaction.To .Distinct() @@ -76,19 +76,18 @@ public class DatabaseMessageStore(ILogger logger, Config c } if (!config.AllowedToDomains.Contains(toAddress.Host.ToLowerInvariant())) { - // ToAddress domain is not allowed, return error to sender. + // ToAddress domain is not allowed. + if (toAddresses.Count > 1) + { + // If more recipients, silently skip this one. + continue; + } + + // If only one recipient, return error. logger.LogWarning("Email to {ToAddress} is not allowed", toAddress.User + "@" + toAddress.Host); return SmtpResponse.NoValidRecipientsGiven; } - // Remove existing x-receiver and x-sender headers to avoid duplication. - message.Headers.RemoveAll("x-receiver"); - message.Headers.RemoveAll("x-sender"); - - // Add new x-receiver and x-sender headers. - message.Headers.Add("x-receiver", toAddress.User + "@" + toAddress.Host); - message.Headers.Add("x-sender", transaction.From.User + "@" + transaction.From.Host); - var insertedId = await InsertEmailIntoDatabase(message); logger.LogInformation("Email saved into database with ID {insertedId}.", insertedId); } @@ -115,8 +114,8 @@ public class DatabaseMessageStore(ILogger logger, Config c /// /// Convert MimeMessage to Email database object. /// - /// - /// + /// MimeMessage object. + /// Email object. /// private static Email ConvertMimeMessageToEmail(MimeMessage message) { diff --git a/src/Services/AliasVault.SmtpService/Program.cs b/src/Services/AliasVault.SmtpService/Program.cs index 1198f726f..4aea2c4ab 100644 --- a/src/Services/AliasVault.SmtpService/Program.cs +++ b/src/Services/AliasVault.SmtpService/Program.cs @@ -6,12 +6,13 @@ //----------------------------------------------------------------------- using System.Data.Common; -using AliasVault.SmtpService; -using SmtpServer; using System.Security.Cryptography.X509Certificates; using AliasServerDb; +using AliasVault.SmtpService; +using AliasVault.SmtpService.Handlers; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; +using SmtpServer; using SmtpServer.Storage; var builder = Host.CreateApplicationBuilder(args); @@ -47,7 +48,6 @@ builder.Services.AddDbContextFactory((container, options) }); builder.Services.AddTransient(); -builder.Services.AddTransient(); builder.Services.AddSingleton( provider => diff --git a/src/Tests/AliasVault.IntegrationTests/SmtpServer/SmtpServerTests.cs b/src/Tests/AliasVault.IntegrationTests/SmtpServer/SmtpServerTests.cs index 1e343a709..1113d75e7 100644 --- a/src/Tests/AliasVault.IntegrationTests/SmtpServer/SmtpServerTests.cs +++ b/src/Tests/AliasVault.IntegrationTests/SmtpServer/SmtpServerTests.cs @@ -19,10 +19,19 @@ using MimeKit; [TestFixture] public class SmtpServerTests { - private IHost _testHost; + /// + /// The test host instance. + /// + private IHost _testHost = null!; - private TestHostBuilder _testHostBuilder; + /// + /// The test host builder instance. + /// + private TestHostBuilder _testHostBuilder = null!; + /// + /// Setup logic for every test. + /// [SetUp] public async Task Setup() { @@ -32,6 +41,9 @@ public class SmtpServerTests await _testHost.StartAsync(); } + /// + /// Tear down logic for every test. + /// [TearDown] public async Task TearDown() { @@ -42,46 +54,138 @@ public class SmtpServerTests } } + /// + /// Tests sending a single email in plain format to the SMTP server to check if it is processed correctly. + /// [Test] - public async Task TestWorkerProcessesEmails() + public async Task SingleEmailPlain() + { + // Send an email to the SMTP server. + var message = new MimeMessage(); + message.From.Add(new MailboxAddress("Test Sender", "sender@example.com")); + message.To.Add(new MailboxAddress("Test Recipient", "recipient@example.tld")); + message.Subject = "Test Email"; + const string textBody = "This is a test email plain."; + message.Body = new BodyBuilder { TextBody = textBody}.ToMessageBody(); + await SendMessageToSmtpServer(message); + + // Check if the email is in the database. + var processedEmail = await _testHostBuilder.GetDbContext().Emails.FirstAsync(); + Assert.That(processedEmail, Is.Not.Null); + Assert.That(processedEmail.From, Is.EqualTo("\"Test Sender\" ")); + Assert.That(processedEmail.FromLocal, Is.EqualTo("sender")); + Assert.That(processedEmail.FromDomain, Is.EqualTo("example.com")); + Assert.That(processedEmail.To, Is.EqualTo("\"Test Recipient\" ")); + Assert.That(processedEmail.MessagePreview, Is.EqualTo("This is a test email plain.")); + Assert.That(processedEmail.MessagePlain, Is.EqualTo("This is a test email plain.")); + Assert.That(processedEmail.MessageHtml, Is.Null); + } + + /// + /// Tests sending a single email in html format to the SMTP server to check if it is processed correctly. + /// + [Test] + public async Task SingleEmailHtml() { // Arrange - // Simulate sending an email to your SMTP server - // You might need to implement a method to do this in your test SMTP server + var message = new MimeMessage(); + message.From.Add(new MailboxAddress("Test Sender", "sender@example.com")); + message.To.Add(new MailboxAddress("Test Recipient", "recipient@example.tld")); + message.Subject = "Test Email with HTML body."; + const string htmlBody = "

This is a test email html.

"; + message.Body = new BodyBuilder { HtmlBody = htmlBody }.ToMessageBody(); + await SendMessageToSmtpServer(message); + + // Check if the email is in the database. + var processedEmail = await _testHostBuilder.GetDbContext().Emails.FirstAsync(); + Assert.That(processedEmail, Is.Not.Null); + Assert.That(processedEmail.To, Is.EqualTo("\"Test Recipient\" ")); + Assert.That(processedEmail.MessagePreview, Is.EqualTo("This is a test email html.")); + Assert.That(processedEmail.MessagePlain, Is.Null); + Assert.That(processedEmail.MessageHtml, Is.EqualTo(htmlBody)); + } + + /// + /// Tests sending a single email in multipart format to the SMTP server to check if it is processed correctly. + /// + [Test] + public async Task SingleEmailMultipart() + { // Arrange var message = new MimeMessage(); message.From.Add(new MailboxAddress("Test Sender", "sender@example.com")); + message.To.Add(new MailboxAddress("Test Recipient", "recipient@example.tld")); + message.Subject = "Test Email with multipart body."; + const string textBody = "This is a test email multipart."; + const string htmlBody = "

This is a test email multipart.

"; + message.Body = new BodyBuilder { TextBody = textBody, HtmlBody = htmlBody}.ToMessageBody(); + await SendMessageToSmtpServer(message); + + // Check if the email is in the database. + var processedEmail = await _testHostBuilder.GetDbContext().Emails.FirstAsync(); + Assert.That(processedEmail, Is.Not.Null); + Assert.That(processedEmail.To, Is.EqualTo("\"Test Recipient\" ")); + Assert.That(processedEmail.MessagePreview, Is.EqualTo("This is a test email multipart.")); + Assert.That(processedEmail.MessagePlain, Is.EqualTo("This is a test email multipart.")); + Assert.That(processedEmail.MessageHtml, Is.EqualTo(htmlBody)); + } + + /// + /// Tests sending a single email in plain format to the SMTP server to check if it is processed correctly. + /// + [Test] + public async Task MultipleRecipientsEmail() + { + // Send an email to the SMTP server. + var message = new MimeMessage(); + message.From.Add(new MailboxAddress("Test Sender", "sender@example.com")); message.To.Add(new MailboxAddress("Test Recipient", "recipient.to@example.tld")); message.Cc.Add(new MailboxAddress("Test Recipient 2", "recipient.cc@example.tld")); - message.Subject = "Test Email"; - message.Body = new TextPart("plain") - { - Text = "This is a test email." - }; + message.Cc.Add(new MailboxAddress("Test Recipient 3 unknown domain", "recipient@unknowndomain.tld")); + message.Subject = "Test Email"; + const string textBody = "This is a test email plain."; + message.Body = new BodyBuilder { TextBody = textBody}.ToMessageBody(); + await SendMessageToSmtpServer(message); + + // Check that two emails are in the database, one for each allowed recipient. + Assert.That(await _testHostBuilder.GetDbContext().Emails.CountAsync(), Is.EqualTo(2)); + } + + /// + /// Tests sending a single email in plain format to the SMTP server to check if it is processed correctly. + /// + [Test] + public void SingleEmailUnknownRecipient() + { + // Send an email to the SMTP server. + var message = new MimeMessage(); + message.From.Add(new MailboxAddress("Test Sender", "sender@example.com")); + message.To.Add(new MailboxAddress("Test Recipient", "recipient@unknowndomain.tld")); + message.Subject = "Test Email"; + const string textBody = "This is a test email plain."; + message.Body = new BodyBuilder { TextBody = textBody}.ToMessageBody(); + + // Expect error from SmtpClient when sending email to unknown domain. + Assert.ThrowsAsync(async () => await SendMessageToSmtpServer(message)); + } + + /// + /// Sends a message to the SMTP server. + /// + /// + private async Task SendMessageToSmtpServer(MimeMessage message) + { using var client = new SmtpClient(); - // Send message to SMTP server await client.ConnectAsync("localhost", 25, SecureSocketOptions.None); try { await client.SendAsync(message); } - catch (Exception ex) - { - throw; - // Show failure message indicating that message was not sent: SMTP server issue? - Assert.Fail($"Failed to send email, check SMTP server receive logs: {ex}"); - } finally { await client.DisconnectAsync(true); } - - var dbContext = _testHostBuilder.GetDbContext(); - - var processedEmail = await dbContext.Emails.FirstOrDefaultAsync(e => e.Subject == "Test Email"); - Assert.That(processedEmail, Is.Not.Null); - Assert.That(processedEmail.To, Is.EqualTo("recipient.to@example.tld")); } } diff --git a/src/Tests/AliasVault.IntegrationTests/SmtpServer/TestHostBuilder.cs b/src/Tests/AliasVault.IntegrationTests/SmtpServer/TestHostBuilder.cs index e5a97c353..97ca5fcae 100644 --- a/src/Tests/AliasVault.IntegrationTests/SmtpServer/TestHostBuilder.cs +++ b/src/Tests/AliasVault.IntegrationTests/SmtpServer/TestHostBuilder.cs @@ -5,11 +5,13 @@ // // ----------------------------------------------------------------------- +using AliasVault.SmtpService.Handlers; + namespace AliasVault.IntegrationTests.SmtpServer; using System.Data.Common; using AliasServerDb; -using AliasVault.SmtpService; +using SmtpService; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.DependencyInjection; using Microsoft.Data.Sqlite; @@ -75,8 +77,6 @@ public class TestHostBuilder }); services.AddTransient(); - services.AddTransient(); - services.AddSingleton( provider => {