Merge pull request #116 from lanedirt/111-add-e2eunit-test-for-email-smtp-service

Add integration test for email smtp service
This commit is contained in:
Leendert de Borst
2024-07-19 08:03:24 -07:00
committed by GitHub
11 changed files with 423 additions and 79 deletions

29
.github/workflows/dotnet-e2e-tests.yml vendored Normal file
View File

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

View File

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

View File

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

View File

@@ -39,6 +39,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Services", "Services", "{8A
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AliasVault.SmtpService", "src\Services\AliasVault.SmtpService\AliasVault.SmtpService.csproj", "{B095A174-E528-4D38-BEC1-D1D38B3B30C0}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AliasVault.IntegrationTests", "src\Tests\AliasVault.IntegrationTests\AliasVault.IntegrationTests.csproj", "{1C7C8DE9-5F2A-43DB-A25E-33319E80A509}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -97,6 +99,10 @@ Global
{B095A174-E528-4D38-BEC1-D1D38B3B30C0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B095A174-E528-4D38-BEC1-D1D38B3B30C0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B095A174-E528-4D38-BEC1-D1D38B3B30C0}.Release|Any CPU.Build.0 = Release|Any CPU
{1C7C8DE9-5F2A-43DB-A25E-33319E80A509}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1C7C8DE9-5F2A-43DB-A25E-33319E80A509}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1C7C8DE9-5F2A-43DB-A25E-33319E80A509}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1C7C8DE9-5F2A-43DB-A25E-33319E80A509}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -112,6 +118,7 @@ Global
{607945F3-9896-4544-99EC-F3496CF4D36B} = {29DE523D-EEF2-41E9-AC12-F20D8D02BEBB}
{A9C9A606-C87E-4298-AB32-09B1884D7487} = {01AB9389-2F89-4F8E-A688-BF4BF1FC42C8}
{B095A174-E528-4D38-BEC1-D1D38B3B30C0} = {8A477241-B96C-4174-968D-D40CB77F1ECD}
{1C7C8DE9-5F2A-43DB-A25E-33319E80A509} = {29DE523D-EEF2-41E9-AC12-F20D8D02BEBB}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {FEE82475-C009-4762-8113-A6563D9DC49E}

View File

@@ -1,45 +0,0 @@
//-----------------------------------------------------------------------
// <copyright file="AllowedDomainsFilter.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.SmtpService;
using SmtpServer;
using SmtpServer.Mail;
using SmtpServer.Storage;
/// <summary>
/// Filter to allow only emails from configured domains.
/// </summary>
public class AllowedDomainsFilter(Config config, ILogger<AllowedDomainsFilter> logger) : IMailboxFilter, IMailboxFilterFactory
{
private readonly TimeSpan _delay = TimeSpan.Zero;
public async Task<bool> CanAcceptFromAsync(ISessionContext context, IMailbox from, int size, CancellationToken cancellationToken)
{
await Task.Delay(_delay, cancellationToken);
return true;
}
public async Task<bool> 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<Config>(), context.ServiceProvider.GetRequiredService<ILogger<AllowedDomainsFilter>>());
}
}

View File

@@ -5,6 +5,8 @@
// </copyright>
//-----------------------------------------------------------------------
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;
/// <summary>
/// Custom exception for when the email parsing fails to find the "to" address in the email.
/// </summary>
@@ -58,7 +58,7 @@ public class DatabaseMessageStore(ILogger<DatabaseMessageStore> 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<DatabaseMessageStore> 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<DatabaseMessageStore> logger, Config c
/// <summary>
/// Convert MimeMessage to Email database object.
/// </summary>
/// <param name="message"></param>
/// <returns></returns>
/// <param name="message">MimeMessage object.</param>
/// <returns>Email object.</returns>
/// <exception cref="EmailParseMissingToException"></exception>
private static Email ConvertMimeMessageToEmail(MimeMessage message)
{

View File

@@ -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);
@@ -43,11 +44,10 @@ builder.Services.AddSingleton<DbConnection>(container =>
builder.Services.AddDbContextFactory<AliasServerDbContext>((container, options) =>
{
var connection = container.GetRequiredService<DbConnection>();
options.UseSqlite(connection).UseLazyLoadingProxies();
options.UseSqlite(connection);
});
builder.Services.AddTransient<IMessageStore, DatabaseMessageStore>();
builder.Services.AddTransient<IMailboxFilter, AllowedDomainsFilter>();
builder.Services.AddSingleton(
provider =>
@@ -120,6 +120,14 @@ builder.Services.AddSingleton(
);
builder.Services.AddHostedService<Worker>();
var host = builder.Build();
using (var scope = host.Services.CreateScope())
{
var container = scope.ServiceProvider;
var db = container.GetRequiredService<AliasServerDbContext>();
await db.Database.MigrateAsync();
}
await host.RunAsync();

View File

@@ -24,16 +24,16 @@ using Microsoft.Extensions.Hosting;
public class WebApplicationApiFactoryFixture<TEntryPoint> : WebApplicationFactory<TEntryPoint>
where TEntryPoint : class
{
/// <summary>
/// The DbContext instance that is created for the test.
/// </summary>
private AliasServerDbContext? _dbContext;
/// <summary>
/// The DbConnection instance that is created for the test.
/// </summary>
private DbConnection? _dbConnection;
/// <summary>
/// The DbContext instance that is created for the test.
/// </summary>
private AliasServerDbContext? _dbContext;
/// <summary>
/// Gets or sets the URL the web application host will listen on.
/// </summary>

View File

@@ -0,0 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.0"/>
<PackageReference Include="MailKit" Version="4.7.1.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0"/>
<PackageReference Include="NUnit" Version="3.14.0"/>
<PackageReference Include="NUnit.Analyzers" Version="3.9.0"/>
<PackageReference Include="NUnit3TestAdapter" Version="4.5.0"/>
</ItemGroup>
<ItemGroup>
<Using Include="NUnit.Framework"/>
</ItemGroup>
<ItemGroup>
<AdditionalFiles Include="..\..\stylecop.json">
<Link>stylecop.json</Link>
</AdditionalFiles>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Services\AliasVault.SmtpService\AliasVault.SmtpService.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,200 @@
//-----------------------------------------------------------------------
// <copyright file="SmtpServerTests.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.IntegrationTests.SmtpServer;
using AliasServerDb;
using MailKit.Security;
using MailKit.Net.Smtp;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using MimeKit;
[TestFixture]
public class SmtpServerTests
{
/// <summary>
/// The test host instance.
/// </summary>
private IHost _testHost = null!;
/// <summary>
/// The test host builder instance.
/// </summary>
private TestHostBuilder _testHostBuilder = null!;
/// <summary>
/// Setup logic for every test.
/// </summary>
[SetUp]
public async Task Setup()
{
_testHostBuilder = new TestHostBuilder();
_testHost = _testHostBuilder.Build();
await _testHost.StartAsync();
}
/// <summary>
/// Tear down logic for every test.
/// </summary>
[TearDown]
public async Task TearDown()
{
if (_testHost != null)
{
await _testHost.StopAsync();
_testHost.Dispose();
}
}
/// <summary>
/// Tests sending a single email in plain format to the SMTP server to check if it is processed correctly.
/// </summary>
[Test]
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.Multiple(() =>
{
Assert.That(processedEmail, Is.Not.Null);
Assert.That(processedEmail.From, Is.EqualTo("\"Test Sender\" <sender@example.com>"));
Assert.That(processedEmail.FromLocal, Is.EqualTo("sender"));
Assert.That(processedEmail.FromDomain, Is.EqualTo("example.com"));
Assert.That(processedEmail.To, Is.EqualTo("\"Test Recipient\" <recipient@example.tld>"));
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);
});
}
/// <summary>
/// Tests sending a single email in html format to the SMTP server to check if it is processed correctly.
/// </summary>
[Test]
public async Task SingleEmailHtml()
{
// 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 HTML body.";
const string htmlBody = "<html><body><h1>This is a test email html.</h1></body></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.Multiple(() =>
{
Assert.That(processedEmail, Is.Not.Null);
Assert.That(processedEmail.To, Is.EqualTo("\"Test Recipient\" <recipient@example.tld>"));
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));
});
}
/// <summary>
/// Tests sending a single email in multipart format to the SMTP server to check if it is processed correctly.
/// </summary>
[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 = "<html><body><h1>This is a test email multipart.</h1></body></html>";
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.Multiple(() =>
{
Assert.That(processedEmail, Is.Not.Null);
Assert.That(processedEmail.To, Is.EqualTo("\"Test Recipient\" <recipient@example.tld>"));
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));
});
}
/// <summary>
/// Tests sending a single email in plain format to the SMTP server to check if it is processed correctly.
/// </summary>
[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.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));
}
/// <summary>
/// Tests sending a single email in plain format to the SMTP server to check if it is processed correctly.
/// </summary>
[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<SmtpCommandException>(async () => await SendMessageToSmtpServer(message));
}
/// <summary>
/// Sends a message to the SMTP server.
/// </summary>
/// <param name="message"></param>
private static async Task SendMessageToSmtpServer(MimeMessage message)
{
using var client = new SmtpClient();
await client.ConnectAsync("localhost", 2525, SecureSocketOptions.None);
try
{
await client.SendAsync(message);
}
finally
{
await client.DisconnectAsync(true);
}
}
}

View File

@@ -0,0 +1,115 @@
// -----------------------------------------------------------------------
// <copyright file="TestHostBuilder.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
// -----------------------------------------------------------------------
using AliasVault.SmtpService.Handlers;
namespace AliasVault.IntegrationTests.SmtpServer;
using System.Data.Common;
using AliasServerDb;
using SmtpService;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using global::SmtpServer;
using global::SmtpServer.Storage;
public class TestHostBuilder
{
/// <summary>
/// The DbConnection instance that is created for the test.
/// </summary>
private DbConnection? _dbConnection;
/// <summary>
/// The DbContext instance that is created for the test.
/// </summary>
private AliasServerDbContext? _dbContext;
/// <summary>
/// Returns the DbContext instance for the test. This can be used to seed the database with test data.
/// </summary>
/// <returns>AliasServerDbContext instance.</returns>
public AliasServerDbContext GetDbContext()
{
if (_dbContext == null)
{
var options = new DbContextOptionsBuilder<AliasServerDbContext>()
.UseSqlite(_dbConnection!)
.Options;
_dbContext = new AliasServerDbContext(options);
}
return _dbContext;
}
/// <summary>
/// Builds the SmtpService test host.
/// </summary>
/// <returns></returns>
public IHost Build()
{
// Create a persistent in-memory database for the duration of the test.
_dbConnection = new SqliteConnection("DataSource=:memory:");
_dbConnection.Open();
var builder = Host.CreateDefaultBuilder()
.ConfigureServices((context, services) =>
{
services.AddSingleton(new Config
{
AllowedToDomains = new List<string> { "example.tld" },
SmtpTlsEnabled = "false"
});
services.AddSingleton(_dbConnection);
services.AddDbContextFactory<AliasServerDbContext>((sp, options) =>
{
var connection = sp.GetRequiredService<DbConnection>();
options.UseSqlite(connection);
});
services.AddTransient<IMessageStore, DatabaseMessageStore>();
services.AddSingleton<global::SmtpServer.SmtpServer>(
provider =>
{
var options = new SmtpServerOptionsBuilder()
.ServerName("aliasvault");
// Note: port 25 doesn't work in GitHub actions so we use these instead for the integration tests:
// - 2525 for the SMTP server
// - 5870 for the submission server
options.Endpoint(serverBuilder =>
serverBuilder
.Port(2525, false))
.Endpoint(serverBuilder =>
serverBuilder
.Port(5870, false)
);
return new SmtpServer(options.Build(), provider.GetRequiredService<IServiceProvider>());
}
);
services.AddHostedService<Worker>();
// Ensure the in-memory database is populated with tables
var serviceProvider = services.BuildServiceProvider();
using (var scope = serviceProvider.CreateScope())
{
var dbContextFactory = scope.ServiceProvider.GetRequiredService<IDbContextFactory<AliasServerDbContext>>();
var dbContext = dbContextFactory.CreateDbContext();
dbContext.Database.Migrate();
}
});
return builder.Build();
}
}