diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d55145a..bf5081a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -35,8 +35,10 @@ jobs:
- name: Build
run: dotnet build --no-restore -c Release
- - name: Test
- run: dotnet test --no-build -c Release
+ - name: Test (unit & component)
+ # Exclude the E2E project here — it needs a container runtime + Playwright and runs in
+ # its own workflow. Filtering out every E2E test means its collection fixture never boots.
+ run: dotnet test --no-build -c Release --filter "FullyQualifiedName!~E2ETests"
# Fail the build on any known-vulnerable package (direct or transitive).
- name: Vulnerability scan
diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
new file mode 100644
index 0000000..e9c34b7
--- /dev/null
+++ b/.github/workflows/e2e.yml
@@ -0,0 +1,33 @@
+name: E2E
+
+on:
+ push:
+ branches: ["main"]
+ pull_request:
+ branches: ["main"]
+ workflow_dispatch:
+
+# Actions are pinned to commit SHAs (matching ci.yml) so a retagged or compromised
+# action version cannot silently enter the pipeline.
+jobs:
+ end-to-end:
+ runs-on: ubuntu-latest
+ # GitHub-hosted Linux runners ship with Docker, which Aspire.Hosting.Testing uses to
+ # start the Postgres and MailHog containers for the real end-to-end run.
+ timeout-minutes: 30
+
+ steps:
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+
+ - uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
+ with:
+ dotnet-version: "10.0.x"
+
+ - name: Build E2E project
+ run: dotnet build tests/AndreGoepel.AppFoundation.E2ETests/AndreGoepel.AppFoundation.E2ETests.csproj -c Release
+
+ - name: Install Playwright browsers
+ run: pwsh tests/AndreGoepel.AppFoundation.E2ETests/bin/Release/net10.0/playwright.ps1 install --with-deps chromium
+
+ - name: Run E2E tests
+ run: dotnet test tests/AndreGoepel.AppFoundation.E2ETests/AndreGoepel.AppFoundation.E2ETests.csproj --no-build -c Release
diff --git a/AndreGoepel.AppFoundation.slnx b/AndreGoepel.AppFoundation.slnx
index 606a5d2..6575a4f 100644
--- a/AndreGoepel.AppFoundation.slnx
+++ b/AndreGoepel.AppFoundation.slnx
@@ -13,6 +13,7 @@
+
diff --git a/Directory.Packages.props b/Directory.Packages.props
index a8159b9..c3e0838 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -6,6 +6,7 @@
+
@@ -52,6 +53,8 @@
+
+
diff --git a/samples/AndreGoepel.AppFoundation.AppHost/AppHost.cs b/samples/AndreGoepel.AppFoundation.AppHost/AppHost.cs
index 5eec580..23b7594 100644
--- a/samples/AndreGoepel.AppFoundation.AppHost/AppHost.cs
+++ b/samples/AndreGoepel.AppFoundation.AppHost/AppHost.cs
@@ -1,7 +1,15 @@
+using Microsoft.Extensions.Configuration;
+
var builder = DistributedApplication.CreateBuilder(args);
// A PostgreSQL container with a persistent volume so setup and accounts survive restarts.
-var postgres = builder.AddPostgres("postgres").WithDataVolume();
+// The E2E suite passes E2E=true to skip the volume: tests need a throwaway database, and
+// sharing the developer's volume would leak their local admin account into the test run.
+var postgres = builder.AddPostgres("postgres");
+if (!builder.Configuration.GetValue("E2E"))
+{
+ postgres.WithDataVolume();
+}
// The database resource name is the connection-string name the foundation reads by default
// (AppFoundationOptions.DatabaseConnectionName == "appfoundation-database").
@@ -26,8 +34,15 @@
// credentials, but the settings are required, so placeholders are supplied.
.WithEnvironment("EmailSender__SenderName", "AppFoundation Dev")
.WithEnvironment("EmailSender__SenderEmail", "dev@appfoundation.local")
- .WithEnvironment("EmailSender__Server", "localhost")
- .WithEnvironment("EmailSender__Port", "1025")
+ // Resolve MailHog's SMTP endpoint at run time instead of hardcoding localhost:1025:
+ // under `dotnet run` that is what it resolves to anyway, but the E2E testing host
+ // assigns random proxy ports and a hardcoded port would send email into the void.
+ .WithEnvironment(context =>
+ {
+ var smtp = mailhog.GetEndpoint("smtp");
+ context.EnvironmentVariables["EmailSender__Server"] = smtp.Property(EndpointProperty.Host);
+ context.EnvironmentVariables["EmailSender__Port"] = smtp.Property(EndpointProperty.Port);
+ })
.WithEnvironment("EmailSender__UseSsl", "false")
.WithEnvironment("EmailSender__Username", "dev")
.WithEnvironment("EmailSender__Password", "dev");
diff --git a/samples/AndreGoepel.AppFoundation.Sample/Program.cs b/samples/AndreGoepel.AppFoundation.Sample/Program.cs
index 9ca5921..f346f77 100644
--- a/samples/AndreGoepel.AppFoundation.Sample/Program.cs
+++ b/samples/AndreGoepel.AppFoundation.Sample/Program.cs
@@ -8,7 +8,11 @@
// One call wires the data store, identity, messaging, email, data protection, and the
// shared request pipeline. The connection string named "appfoundation-database" is
// supplied by the Aspire AppHost (or Docker secrets in production).
-builder.AddAppFoundation();
+builder.AddAppFoundation(options =>
+ // Self-service registration is off by default (#49); the sample opts in — the same
+ // way a real host would — so the E2E suite can exercise the registration flows.
+ options.ConfigureIdentity = identity => identity.EnableUserRegistration = true
+);
builder.Services.AddRazorComponents().AddInteractiveServerComponents();
diff --git a/src/AndreGoepel.AppFoundation.MailService/IMailSettingsProvider.cs b/src/AndreGoepel.AppFoundation.MailService/IMailSettingsProvider.cs
index 4492fde..8016ccb 100644
--- a/src/AndreGoepel.AppFoundation.MailService/IMailSettingsProvider.cs
+++ b/src/AndreGoepel.AppFoundation.MailService/IMailSettingsProvider.cs
@@ -4,7 +4,7 @@ namespace AndreGoepel.AppFoundation.MailService;
/// Resolves the effective for sending. Looked up
/// per send so database changes take effect without an application restart.
///
-internal interface IMailSettingsProvider
+public interface IMailSettingsProvider
{
Task GetAsync(CancellationToken cancellationToken = default);
}
diff --git a/src/AndreGoepel.AppFoundation.MailService/MailConfiguration.cs b/src/AndreGoepel.AppFoundation.MailService/MailConfiguration.cs
index 5e16508..1644df7 100644
--- a/src/AndreGoepel.AppFoundation.MailService/MailConfiguration.cs
+++ b/src/AndreGoepel.AppFoundation.MailService/MailConfiguration.cs
@@ -2,7 +2,7 @@
namespace AndreGoepel.AppFoundation.MailService;
-internal record MailConfiguration
+public record MailConfiguration
{
[Required]
public required string SenderName { get; init; }
diff --git a/src/AndreGoepel.AppFoundation.MailService/MailSettingsProvider.cs b/src/AndreGoepel.AppFoundation.MailService/MailSettingsProvider.cs
index db96fd5..e603b49 100644
--- a/src/AndreGoepel.AppFoundation.MailService/MailSettingsProvider.cs
+++ b/src/AndreGoepel.AppFoundation.MailService/MailSettingsProvider.cs
@@ -9,7 +9,9 @@ namespace AndreGoepel.AppFoundation.MailService;
/// without one the EmailSender configuration section applies (bootstrap
/// path — same behaviour as before database-backed settings existed).
///
-internal sealed class MailSettingsProvider(
+// Public for the same Wolverine codegen reason as SmtpEmailSender: it is constructed
+// inside the generated MailMessage handler as SmtpEmailSender's dependency.
+public sealed class MailSettingsProvider(
IDocumentStore store,
IOptions fallback,
IDataProtectionProvider dataProtectionProvider
diff --git a/src/AndreGoepel.AppFoundation.MailService/SmtpEmailSender.cs b/src/AndreGoepel.AppFoundation.MailService/SmtpEmailSender.cs
index d089fd4..85e7c7d 100644
--- a/src/AndreGoepel.AppFoundation.MailService/SmtpEmailSender.cs
+++ b/src/AndreGoepel.AppFoundation.MailService/SmtpEmailSender.cs
@@ -3,7 +3,11 @@
namespace AndreGoepel.AppFoundation.MailService;
-internal class SmtpEmailSender(IMailSettingsProvider settingsProvider) : IEmailSender
+// Public (not internal) so Wolverine's generated MailMessage handler code can construct
+// it directly: ServiceLocationPolicy.NotAllowed (Wolverine 6 default) rejects handlers
+// whose dependencies resolve to non-public concrete types, which silently killed every
+// outgoing email.
+public class SmtpEmailSender(IMailSettingsProvider settingsProvider) : IEmailSender
{
public async Task SendAsync(
string recipient,
diff --git a/tests/AndreGoepel.AppFoundation.E2ETests/AndreGoepel.AppFoundation.E2ETests.csproj b/tests/AndreGoepel.AppFoundation.E2ETests/AndreGoepel.AppFoundation.E2ETests.csproj
new file mode 100644
index 0000000..85b271b
--- /dev/null
+++ b/tests/AndreGoepel.AppFoundation.E2ETests/AndreGoepel.AppFoundation.E2ETests.csproj
@@ -0,0 +1,29 @@
+
+
+ net10.0
+ enable
+ enable
+ false
+
+ false
+
+
+
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
diff --git a/tests/AndreGoepel.AppFoundation.E2ETests/GlobalUsings.cs b/tests/AndreGoepel.AppFoundation.E2ETests/GlobalUsings.cs
new file mode 100644
index 0000000..54dffa0
--- /dev/null
+++ b/tests/AndreGoepel.AppFoundation.E2ETests/GlobalUsings.cs
@@ -0,0 +1,3 @@
+global using Microsoft.Playwright;
+global using static Microsoft.Playwright.Assertions;
+global using Xunit;
diff --git a/tests/AndreGoepel.AppFoundation.E2ETests/Infrastructure/E2EAppFixture.cs b/tests/AndreGoepel.AppFoundation.E2ETests/Infrastructure/E2EAppFixture.cs
new file mode 100644
index 0000000..e3065d4
--- /dev/null
+++ b/tests/AndreGoepel.AppFoundation.E2ETests/Infrastructure/E2EAppFixture.cs
@@ -0,0 +1,190 @@
+using Aspire.Hosting;
+using Aspire.Hosting.ApplicationModel;
+using Aspire.Hosting.Testing;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace AndreGoepel.AppFoundation.E2ETests.Infrastructure;
+
+#region Fixture
+
+///
+/// Boots the whole Aspire application graph (Postgres + MailHog + the sample web app) once for the
+/// entire test collection via the sample AppHost, then launches a single Chromium browser that every
+/// test shares. The AppHost is started with E2E=true so Postgres runs without its persistent
+/// volume — every suite run gets a throwaway, empty database, locally and on CI alike.
+///
+public sealed class E2EAppFixture : IAsyncLifetime
+{
+ // Resource names as declared in samples/AndreGoepel.AppFoundation.AppHost/AppHost.cs.
+ private const string WebResource = "web";
+ private const string MailHogResource = "mailhog";
+
+ private DistributedApplication? _app;
+ private IPlaywright? _playwright;
+ private IBrowser? _browser;
+
+ /// Base address of the sample web app, e.g. https://localhost:1234/.
+ public string AppBaseUrl { get; private set; } = default!;
+
+ /// Base address of the MailHog HTTP API used to read delivered emails.
+ public string MailHogApiUrl { get; private set; } = default!;
+
+ /// The single browser shared by the collection. Tests create their own context.
+ public IBrowser Browser =>
+ _browser ?? throw new InvalidOperationException("Browser not started.");
+
+ public MailHogClient MailHog { get; private set; } = default!;
+
+ public async ValueTask InitializeAsync()
+ {
+ // E2E=true tells the AppHost to run Postgres without its persistent volume, so every
+ // suite run starts from an empty database instead of the developer's local data.
+ var appHostBuilder =
+ await DistributedApplicationTestingBuilder.CreateAsync([
+ "E2E=true",
+ ]);
+
+ _app = await appHostBuilder.BuildAsync();
+
+ var startupTimeout = TimeSpan.FromMinutes(5);
+ using var startupCts = new CancellationTokenSource(startupTimeout);
+
+ await _app.StartAsync(startupCts.Token);
+
+ var notifications = _app.Services.GetRequiredService();
+ await notifications.WaitForResourceHealthyAsync(WebResource, startupCts.Token);
+
+ AppBaseUrl = _app.GetEndpoint(WebResource, "https").ToString();
+ MailHogApiUrl = _app.GetEndpoint(MailHogResource, "http").ToString();
+ MailHog = new MailHogClient(MailHogApiUrl);
+
+ _playwright = await Playwright.CreateAsync();
+ _browser = await _playwright.Chromium.LaunchAsync(
+ new BrowserTypeLaunchOptions { Headless = !IsHeaded }
+ );
+ }
+
+ private bool _adminProvisioned;
+ private readonly SemaphoreSlim _provisionGate = new(1, 1);
+
+ ///
+ /// Runs the one-time Setup flow (create root admin + default roles) exactly once per app instance,
+ /// in its own throwaway context so it never disturbs a test's session. Idempotent: if the admin
+ /// already exists, Setup server-redirects away and nothing is created.
+ ///
+ public async Task ProvisionAdminAsync()
+ {
+ if (_adminProvisioned)
+ {
+ return;
+ }
+
+ await _provisionGate.WaitAsync();
+ try
+ {
+ if (_adminProvisioned)
+ {
+ return;
+ }
+
+ await using var context = await NewContextAsync();
+ var page = await context.NewPageAsync();
+ await page.GotoAsync("/Setup");
+ await page.WaitForBlazorAsync();
+
+ if (
+ new Uri(page.Url)
+ .AbsolutePath.Trim('/')
+ .Equals("Setup", StringComparison.OrdinalIgnoreCase)
+ )
+ {
+ // On the app's very first circuit, interactivity can attach a beat after
+ // window.Blazor exists (cold JIT); a click landing in that gap silently
+ // does nothing. Retry fill+submit, reloading in between — once the setup
+ // succeeded server-side, the reload redirects away by itself.
+ for (var attempt = 0; ; attempt++)
+ {
+ await page.FillFieldAsync("Email", TestData.AdminEmail);
+ await page.FillFieldAsync("Password", TestData.DefaultPassword);
+ await page.FillFieldAsync("ConfirmPassword", TestData.DefaultPassword);
+ await page.ClickButtonAsync("Create admin");
+ try
+ {
+ await page.WaitForURLAsync(
+ url =>
+ !new Uri(url).AbsolutePath.Contains(
+ "Setup",
+ StringComparison.OrdinalIgnoreCase
+ ),
+ new PageWaitForURLOptions { Timeout = 15_000 }
+ );
+ break;
+ }
+ catch (TimeoutException) when (attempt < 7)
+ {
+ await page.ReloadAsync();
+ await page.WaitForBlazorAsync();
+ if (
+ !new Uri(page.Url)
+ .AbsolutePath.Trim('/')
+ .Equals("Setup", StringComparison.OrdinalIgnoreCase)
+ )
+ {
+ break;
+ }
+ }
+ }
+ }
+
+ _adminProvisioned = true;
+ }
+ finally
+ {
+ _provisionGate.Release();
+ }
+ }
+
+ /// Creates an isolated browser context (fresh cookies/storage) for a single test.
+ public Task NewContextAsync() =>
+ Browser.NewContextAsync(
+ new BrowserNewContextOptions
+ {
+ BaseURL = AppBaseUrl,
+ IgnoreHTTPSErrors = true, // dev cert on the https endpoint
+ }
+ );
+
+ public async ValueTask DisposeAsync()
+ {
+ if (_browser is not null)
+ {
+ await _browser.DisposeAsync();
+ }
+
+ _playwright?.Dispose();
+
+ if (_app is not null)
+ {
+ await _app.DisposeAsync();
+ }
+ }
+
+ private static bool IsHeaded =>
+ string.Equals(
+ Environment.GetEnvironmentVariable("E2E_HEADED"),
+ "true",
+ StringComparison.OrdinalIgnoreCase
+ );
+}
+
+#endregion
+
+#region Collection
+
+[CollectionDefinition(Name)]
+public sealed class E2ECollection : ICollectionFixture
+{
+ public const string Name = "e2e";
+}
+
+#endregion
diff --git a/tests/AndreGoepel.AppFoundation.E2ETests/Infrastructure/E2ETestBase.cs b/tests/AndreGoepel.AppFoundation.E2ETests/Infrastructure/E2ETestBase.cs
new file mode 100644
index 0000000..1aa95ad
--- /dev/null
+++ b/tests/AndreGoepel.AppFoundation.E2ETests/Infrastructure/E2ETestBase.cs
@@ -0,0 +1,106 @@
+namespace AndreGoepel.AppFoundation.E2ETests.Infrastructure;
+
+///
+/// Base class for every E2E test: one fresh browser context + page per test (so cookies never leak
+/// between tests) plus the common account flows expressed as intent-revealing helpers.
+///
+[Collection(E2ECollection.Name)]
+public abstract class E2ETestBase(E2EAppFixture fixture) : IAsyncLifetime
+{
+ protected E2EAppFixture Fixture { get; } = fixture;
+ protected IBrowserContext Context { get; private set; } = default!;
+ protected IPage Page { get; private set; } = default!;
+
+ public async ValueTask InitializeAsync()
+ {
+ Context = await Fixture.NewContextAsync();
+ Page = await Context.NewPageAsync();
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await Context.DisposeAsync();
+ }
+
+ #region Account flows
+
+ /// Logs the current page's session in via the real cookie-login flow.
+ protected async Task LoginAsync(string email, string password, IPage? page = null)
+ {
+ page ??= Page;
+ await page.GotoAsync("/Account/Login");
+ await page.WaitForBlazorAsync();
+ await page.FillFieldAsync("Email", email);
+ await page.FillFieldAsync("Password", password);
+ await ClickAndLeaveLoginAsync(page);
+ }
+
+ ///
+ /// Clicks "Log in" and waits to leave the login page. A click can land in the gap between the
+ /// circuit connecting and the Radzen form's submit handler attaching — it is then silently lost —
+ /// so the click is retried until the cookie middleware redirects away. Exact-path equality keeps a
+ /// redirect to /Account/LoginWith2fa (which *contains* /Account/Login) counting as "left".
+ ///
+ private static async Task ClickAndLeaveLoginAsync(IPage page)
+ {
+ for (var attempt = 0; ; attempt++)
+ {
+ await page.ClickButtonAsync("Log in");
+ try
+ {
+ await page.WaitForURLAsync(
+ url =>
+ !new Uri(url).AbsolutePath.Equals(
+ "/Account/Login",
+ StringComparison.OrdinalIgnoreCase
+ ),
+ new PageWaitForURLOptions { Timeout = 5_000 }
+ );
+ return;
+ }
+ catch (TimeoutException) when (attempt < 5)
+ {
+ // Submit was lost before the handler attached — click again.
+ }
+ }
+ }
+
+ /// Ensures the root admin exists, then logs this page in as that admin.
+ protected async Task LoginAsAdminAsync(IPage? page = null)
+ {
+ await Fixture.ProvisionAdminAsync();
+ await LoginAsync(TestData.AdminEmail, TestData.DefaultPassword, page);
+ }
+
+ /// Registers a new user and returns the generated email; the account still needs confirmation.
+ protected async Task RegisterAsync(string? email = null, string? password = null)
+ {
+ email ??= TestData.NewEmail();
+ password ??= TestData.DefaultPassword;
+
+ await Page.GotoAsync("/Account/Register");
+ await Page.WaitForBlazorAsync();
+ await Page.FillFieldAsync("Email", email);
+ await Page.FillFieldAsync("NewPassword", password);
+ await Page.FillFieldAsync("ConfirmPassword", password);
+ await Page.ClickButtonAsync("Register");
+ return email;
+ }
+
+ /// Reads the confirmation link MailHog captured and follows it to activate the account.
+ protected async Task ConfirmEmailAsync(string email)
+ {
+ var link = await Fixture.MailHog.WaitForLinkAsync(email, "Account/ConfirmEmail");
+ await Page.GotoAsync(link);
+ await Page.WaitForBlazorAsync();
+ }
+
+ /// Signs the current session out through the app's sign-out endpoint.
+ protected async Task LogoutAsync(IPage? page = null)
+ {
+ page ??= Page;
+ await page.GotoAsync("/Account/SignOutAndRedirect");
+ }
+
+ #endregion
+}
diff --git a/tests/AndreGoepel.AppFoundation.E2ETests/Infrastructure/MailHogClient.cs b/tests/AndreGoepel.AppFoundation.E2ETests/Infrastructure/MailHogClient.cs
new file mode 100644
index 0000000..0936864
--- /dev/null
+++ b/tests/AndreGoepel.AppFoundation.E2ETests/Infrastructure/MailHogClient.cs
@@ -0,0 +1,170 @@
+using System.Net;
+using System.Text;
+using System.Text.Json;
+using System.Text.RegularExpressions;
+
+namespace AndreGoepel.AppFoundation.E2ETests.Infrastructure;
+
+///
+/// Thin client over the MailHog HTTP API so tests can assert on emails the app actually delivered
+/// (confirmation links, password-reset links, etc.).
+///
+public sealed partial class MailHogClient(string baseUrl)
+{
+ private readonly HttpClient _http = new() { BaseAddress = new Uri(baseUrl) };
+
+ /// Deletes every message so a test starts from a known-empty inbox.
+ public async Task ClearAsync(CancellationToken ct = default)
+ {
+ using var response = await _http.DeleteAsync("api/v1/messages", ct);
+ response.EnsureSuccessStatusCode();
+ }
+
+ ///
+ /// Polls until an email addressed to arrives, returning its decoded body.
+ ///
+ public async Task WaitForMessageBodyAsync(
+ string toEmail,
+ TimeSpan? timeout = null,
+ CancellationToken ct = default
+ )
+ {
+ var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(30));
+
+ while (DateTime.UtcNow < deadline)
+ {
+ var body = await TryGetLatestBodyAsync(toEmail, ct);
+ if (body is not null)
+ {
+ return body;
+ }
+
+ await Task.Delay(500, ct);
+ }
+
+ throw new TimeoutException($"No email delivered to '{toEmail}' within the timeout.");
+ }
+
+ /// Waits for the newest email to and extracts the first link matching a filter.
+ public async Task WaitForLinkAsync(
+ string toEmail,
+ string mustContain,
+ TimeSpan? timeout = null,
+ CancellationToken ct = default
+ )
+ {
+ var body = await WaitForMessageBodyAsync(toEmail, timeout, ct);
+ var links = LinkRegex()
+ .Matches(body)
+ .Select(m => WebUtility.HtmlDecode(m.Value))
+ .Where(url => url.Contains(mustContain, StringComparison.OrdinalIgnoreCase))
+ .ToList();
+
+ if (links.Count == 0)
+ {
+ throw new InvalidOperationException(
+ $"No link containing '{mustContain}' found in email to '{toEmail}'. Body was:\n{body}"
+ );
+ }
+
+ return links[0];
+ }
+
+ private async Task TryGetLatestBodyAsync(string toEmail, CancellationToken ct)
+ {
+ var json = await _http.GetStringAsync(
+ "api/v2/search?kind=to&query=" + Uri.EscapeDataString(toEmail),
+ ct
+ );
+
+ using var doc = JsonDocument.Parse(json);
+
+ if (!doc.RootElement.TryGetProperty("items", out var items) || items.GetArrayLength() == 0)
+ {
+ return null;
+ }
+
+ // MailHog returns newest first.
+ var message = items[0];
+ return DecodeBody(message);
+ }
+
+ private static string DecodeBody(JsonElement message)
+ {
+ var builder = new StringBuilder();
+
+ if (
+ message.TryGetProperty("Content", out var content)
+ && content.ValueKind == JsonValueKind.Object
+ )
+ {
+ AppendPart(builder, content);
+ }
+
+ // MailHog serializes "MIME": null for non-multipart messages.
+ if (
+ message.TryGetProperty("MIME", out var mime)
+ && mime.ValueKind == JsonValueKind.Object
+ && mime.TryGetProperty("Parts", out var parts)
+ && parts.ValueKind == JsonValueKind.Array
+ )
+ {
+ foreach (var part in parts.EnumerateArray())
+ {
+ AppendPart(builder, part);
+ }
+ }
+
+ return builder.ToString();
+ }
+
+ ///
+ /// Appends one MIME part's decoded body. Quoted-printable decoding is applied only when
+ /// the part actually declares that transfer encoding — decoding unconditionally corrupts
+ /// URLs whose = is followed by two hex digits (e.g. userId=a1… → userId¡…).
+ ///
+ private static void AppendPart(StringBuilder builder, JsonElement part)
+ {
+ if (!part.TryGetProperty("Body", out var body))
+ {
+ return;
+ }
+
+ var text = body.GetString() ?? string.Empty;
+ builder.AppendLine(IsQuotedPrintable(part) ? DecodeQuotedPrintable(text) : text);
+ }
+
+ /// True when the part's Content-Transfer-Encoding header is quoted-printable.
+ private static bool IsQuotedPrintable(JsonElement part)
+ {
+ if (
+ !part.TryGetProperty("Headers", out var headers)
+ || headers.ValueKind != JsonValueKind.Object
+ || !headers.TryGetProperty("Content-Transfer-Encoding", out var cte)
+ || cte.ValueKind != JsonValueKind.Array
+ )
+ {
+ return false;
+ }
+
+ return cte.EnumerateArray()
+ .Any(v =>
+ string.Equals(v.GetString(), "quoted-printable", StringComparison.OrdinalIgnoreCase)
+ );
+ }
+
+ /// Undoes the quoted-printable encoding MailHog stores so URLs are reassembled.
+ private static string DecodeQuotedPrintable(string input)
+ {
+ // Remove soft line breaks, then decode =XX escapes.
+ var unfolded = input.Replace("=\r\n", string.Empty).Replace("=\n", string.Empty);
+ return QuotedPrintableRegex()
+ .Replace(unfolded, match => ((char)Convert.ToInt32(match.Value[1..], 16)).ToString());
+ }
+
+ [GeneratedRegex(@"https?://[^\s""'<>]+")]
+ private static partial Regex LinkRegex();
+
+ [GeneratedRegex("=[0-9A-Fa-f]{2}")]
+ private static partial Regex QuotedPrintableRegex();
+}
diff --git a/tests/AndreGoepel.AppFoundation.E2ETests/Infrastructure/PageExtensions.cs b/tests/AndreGoepel.AppFoundation.E2ETests/Infrastructure/PageExtensions.cs
new file mode 100644
index 0000000..69227bb
--- /dev/null
+++ b/tests/AndreGoepel.AppFoundation.E2ETests/Infrastructure/PageExtensions.cs
@@ -0,0 +1,61 @@
+namespace AndreGoepel.AppFoundation.E2ETests.Infrastructure;
+
+///
+/// Helpers that hide the Blazor-Server timing and Radzen markup details from individual tests, so a
+/// test reads as intent ("fill Email, click Log in") rather than selector plumbing.
+///
+public static class PageExtensions
+{
+ ///
+ /// Waits until the interactive Server circuit is live. Radzen forms submit through Blazor event
+ /// handlers, so clicking before the circuit connects silently does nothing — this prevents flakes.
+ ///
+ public static async Task WaitForBlazorAsync(this IPage page)
+ {
+ await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
+ await page.WaitForFunctionAsync(
+ "() => window.Blazor !== undefined && !document.querySelector('#components-reconnect-modal.components-reconnect-show')"
+ );
+ }
+
+ /// Navigates to a relative path (resolved against the fixture base URL) and waits for interactivity.
+ public static async Task GotoAsync(this IPage page, string path)
+ {
+ await page.GotoAsync(path);
+ await page.WaitForBlazorAsync();
+ }
+
+ /// Fills a Radzen input rendered with Name="...".
+ public static Task FillFieldAsync(this IPage page, string name, string value) =>
+ page.FillAsync($"[name='{name}']", value);
+
+ /// Clicks a Radzen button by its visible text.
+ public static Task ClickButtonAsync(this IPage page, string text) =>
+ page.GetByRole(AriaRole.Button, new() { Name = text, Exact = false }).First.ClickAsync();
+
+ /// Clicks a link by its visible text.
+ public static Task ClickLinkAsync(this IPage page, string text) =>
+ page.GetByRole(AriaRole.Link, new() { Name = text, Exact = false }).First.ClickAsync();
+
+ /// Asserts the current URL path matches (ignoring query string and trailing slash).
+ public static async Task AssertOnPathAsync(this IPage page, string expectedPath)
+ {
+ try
+ {
+ await page.WaitForURLAsync(
+ url =>
+ NormalizePath(url)
+ .Contains(expectedPath.Trim('/'), StringComparison.OrdinalIgnoreCase),
+ new PageWaitForURLOptions { Timeout = 15_000 }
+ );
+ }
+ catch (TimeoutException)
+ {
+ throw new TimeoutException(
+ $"Expected path to contain '{expectedPath}' but was '{page.Url}'."
+ );
+ }
+ }
+
+ private static string NormalizePath(string url) => new Uri(url).AbsolutePath.Trim('/');
+}
diff --git a/tests/AndreGoepel.AppFoundation.E2ETests/Infrastructure/TestData.cs b/tests/AndreGoepel.AppFoundation.E2ETests/Infrastructure/TestData.cs
new file mode 100644
index 0000000..9a0f1ae
--- /dev/null
+++ b/tests/AndreGoepel.AppFoundation.E2ETests/Infrastructure/TestData.cs
@@ -0,0 +1,21 @@
+namespace AndreGoepel.AppFoundation.E2ETests.Infrastructure;
+
+/// Shared constants and generators so tests don't reinvent credentials.
+public static class TestData
+{
+ /// Canonical administrator created by the one-time Setup flow and reused across admin tests.
+ public const string AdminEmail = "admin@e2e.test";
+
+ ///
+ /// Password for the admin and, by default, generated users. Meets Identity complexity rules and
+ /// the Setup form's 12-character minimum.
+ ///
+ public const string DefaultPassword = "E2e-Passw0rd!23";
+
+ /// A second valid password (also 12+ chars) used when a flow needs to change away from the default.
+ public const string AlternatePassword = "Ch4nged!Passw0rd";
+
+ /// Produces a unique, valid email so parallel-ish tests never collide on identity.
+ public static string NewEmail(string prefix = "user") =>
+ $"{prefix}-{Guid.NewGuid():N}@e2e.test";
+}
diff --git a/tests/AndreGoepel.AppFoundation.E2ETests/Infrastructure/Totp.cs b/tests/AndreGoepel.AppFoundation.E2ETests/Infrastructure/Totp.cs
new file mode 100644
index 0000000..3c327b9
--- /dev/null
+++ b/tests/AndreGoepel.AppFoundation.E2ETests/Infrastructure/Totp.cs
@@ -0,0 +1,18 @@
+using OtpNet;
+
+namespace AndreGoepel.AppFoundation.E2ETests.Infrastructure;
+
+/// Generates authenticator codes from the shared key the EnableAuthenticator page displays.
+public static class Totp
+{
+ ///
+ /// Computes the current 6-digit code for a base32 shared key. The UI shows the key in
+ /// space-separated groups, so whitespace is stripped and the value upper-cased first.
+ ///
+ public static string Compute(string sharedKey)
+ {
+ var normalized = sharedKey.Replace(" ", string.Empty).ToUpperInvariant();
+ var totp = new OtpNet.Totp(Base32Encoding.ToBytes(normalized));
+ return totp.ComputeTotp();
+ }
+}
diff --git a/tests/AndreGoepel.AppFoundation.E2ETests/Infrastructure/VirtualAuthenticator.cs b/tests/AndreGoepel.AppFoundation.E2ETests/Infrastructure/VirtualAuthenticator.cs
new file mode 100644
index 0000000..1dbdccb
--- /dev/null
+++ b/tests/AndreGoepel.AppFoundation.E2ETests/Infrastructure/VirtualAuthenticator.cs
@@ -0,0 +1,47 @@
+namespace AndreGoepel.AppFoundation.E2ETests.Infrastructure;
+
+///
+/// Installs a Chromium CDP virtual authenticator so passkey (WebAuthn) ceremonies complete without a
+/// physical device. It auto-simulates user presence/verification, so create/get calls just succeed.
+///
+public sealed class VirtualAuthenticator
+{
+ private readonly ICDPSession _session;
+
+ private VirtualAuthenticator(ICDPSession session) => _session = session;
+
+ public static async Task EnableAsync(IBrowserContext context, IPage page)
+ {
+ var session = await context.NewCDPSessionAsync(page);
+ await session.SendAsync("WebAuthn.enable");
+ await session.SendAsync(
+ "WebAuthn.addVirtualAuthenticator",
+ new Dictionary
+ {
+ ["options"] = new Dictionary
+ {
+ ["protocol"] = "ctap2",
+ ["transport"] = "internal",
+ ["hasResidentKey"] = true,
+ ["hasUserVerification"] = true,
+ ["isUserVerified"] = true,
+ ["automaticPresenceSimulation"] = true,
+ },
+ }
+ );
+
+ return new VirtualAuthenticator(session);
+ }
+
+ public async Task DisableAsync()
+ {
+ try
+ {
+ await _session.SendAsync("WebAuthn.disable");
+ }
+ catch
+ {
+ // Circuit/context may already be torn down; nothing to clean up then.
+ }
+ }
+}
diff --git a/tests/AndreGoepel.AppFoundation.E2ETests/README.md b/tests/AndreGoepel.AppFoundation.E2ETests/README.md
new file mode 100644
index 0000000..abc69ce
--- /dev/null
+++ b/tests/AndreGoepel.AppFoundation.E2ETests/README.md
@@ -0,0 +1,110 @@
+# AndreGoepel.AppFoundation.E2ETests
+
+End-to-end tests that drive the **real** application — the Blazor UI, the cookie-login
+middleware, PostgreSQL, and email delivery — through a Chromium browser.
+
+## How it works
+
+- **Aspire.Hosting.Testing** boots the sample `AppHost`
+ (`samples/AndreGoepel.AppFoundation.AppHost`) once per test run: PostgreSQL, MailHog, and the
+ sample web app (Aspire resource `web`). The fixture waits for `web` to become healthy, then
+ reads its `https` endpoint and MailHog's `http` endpoint.
+- **Microsoft.Playwright** (Chromium) drives the browser. Each test gets a fresh
+ `IBrowserContext` so cookies never leak between tests.
+- **MailHog** captures every outgoing email. `MailHogClient` reads the inbox over MailHog's HTTP
+ API so confirmation / password-reset links are followed for real.
+- **Otp.NET** generates TOTP codes for the authenticator (2FA) flows.
+- A Chromium **CDP virtual authenticator** satisfies WebAuthn ceremonies, so passkey
+ register/login run headlessly with no physical device.
+
+The suite runs **serially** inside one xUnit collection because it shares a single app instance
+and database. The first test that needs it provisions the root admin exactly once via the
+`/Setup` flow (`E2EAppFixture.ProvisionAdminAsync`, idempotent). On CI's fresh runners the
+Postgres volume starts empty; because the flows are idempotent (admin provisioned once, unique
+emails per test) they also tolerate reused local state.
+
+The account pages under test (`/Account/*`, `/Administration/Users`, `/Administration/Roles`)
+ship in the **`AndreGoepel.Marten.Identity.Blazor`** NuGet package the app consumes; the
+foundation pages (`/Setup`, `/dashboard`, `/Administration/EmailSettings`,
+`/Administration/LoginFeatures`) live in `src/AndreGoepel.AppFoundation`.
+
+## Prerequisites
+
+1. **A container runtime** must be running — Docker **or** Podman. The tests start real
+ containers; if none is reachable the fixture fails fast.
+2. **.NET 10 SDK**.
+3. **Playwright browsers** installed once:
+
+ ```bash
+ # after a build, from the repo root:
+ pwsh tests/AndreGoepel.AppFoundation.E2ETests/bin/Debug/net10.0/playwright.ps1 install chromium
+ ```
+
+### Using Podman instead of Docker
+
+Aspire's orchestrator auto-detects the runtime, but if Docker Desktop's `docker.exe` is on your
+PATH (even with its daemon stopped) it may be picked first. Force Podman:
+
+1. Start a Podman machine (one-time init on Windows/macOS):
+
+ ```powershell
+ podman machine init # only if `podman machine list` shows none
+ podman machine start
+ podman version # should show a Server section
+ ```
+
+2. Select the Podman runtime, either via a user env var:
+
+ ```powershell
+ setx DOTNET_ASPIRE_CONTAINER_RUNTIME podman # new terminals pick it up
+ ```
+
+ …or per-run with the provided settings file:
+
+ ```bash
+ dotnet test tests/AndreGoepel.AppFoundation.E2ETests --settings tests/AndreGoepel.AppFoundation.E2ETests/podman.runsettings
+ ```
+
+> First run pulls the `postgres` and `mailhog/mailhog` images. If Podman prompts to choose a
+> registry, add `unqualified-search-registries = ["docker.io"]` to your `containers.conf`, or
+> pre-pull: `podman pull docker.io/mailhog/mailhog:v1.0.1` and `podman pull docker.io/library/postgres`.
+
+## Running
+
+```bash
+# from the repo root
+dotnet test tests/AndreGoepel.AppFoundation.E2ETests
+```
+
+Watch the browser (debugging locally):
+
+```bash
+E2E_HEADED=true dotnet test tests/AndreGoepel.AppFoundation.E2ETests
+```
+
+The main `CI` workflow skips these (`--filter "FullyQualifiedName!~E2ETests"`); they run in the
+dedicated `E2E` GitHub Actions workflow, which has Docker available.
+
+## Coverage
+
+| Area | Tests |
+| --- | --- |
+| Smoke | app boots, `/Setup` runs once, admin login → dashboard |
+| Registration | register → email confirmation → login; login blocked before confirmation; password-mismatch validation |
+| Login | wrong password, lockout after repeated failures, logout → protected page redirects |
+| Password reset | forgot → emailed link → reset → login; invalid link; resend confirmation |
+| Two-factor (TOTP) | enable via authenticator, login with generated code, login with recovery code, disable |
+| Passkeys (WebAuthn) | register + rename + list, login with passkey (virtual authenticator) |
+| Account management | update profile, change password, delete account |
+| Administration | list users, create role, non-admin authorization boundary |
+| App pages | sample home, Email Settings, Login Features (admin-only) |
+
+## Tuning notes
+
+The UI is built with **Radzen**, whose markup can shift between versions. Selectors are
+centralized in `Infrastructure/PageExtensions.cs` and the page flows in `E2ETestBase`, so if a
+selector drifts you fix it in one place. The account-flow selectors target the current
+`AndreGoepel.Marten.Identity.Blazor` package markup; verify them on the first live run after a
+package bump. The 2FA and passkey tests are the most timing-sensitive (JS-driven ceremonies); if
+they flake, check `WaitForBlazorAsync` and the module-load delay in
+`PasskeyTests.RegisterPasskeyAsync` first.
diff --git a/tests/AndreGoepel.AppFoundation.E2ETests/Tests/AccountManagementTests.cs b/tests/AndreGoepel.AppFoundation.E2ETests/Tests/AccountManagementTests.cs
new file mode 100644
index 0000000..2b4d336
--- /dev/null
+++ b/tests/AndreGoepel.AppFoundation.E2ETests/Tests/AccountManagementTests.cs
@@ -0,0 +1,83 @@
+using AndreGoepel.AppFoundation.E2ETests.Infrastructure;
+
+namespace AndreGoepel.AppFoundation.E2ETests.Tests;
+
+/// Covers the self-service account management pages under /Account/Manage.
+public sealed class AccountManagementTests(E2EAppFixture fixture) : E2ETestBase(fixture)
+{
+ [Fact]
+ public async Task Profile_UpdatePhoneNumber_ShowsSuccess()
+ {
+ // Arrange
+ await CreateConfirmedUserAndLoginAsync();
+ await Page.GotoAsync("/Account/Manage/Profile");
+ await Page.WaitForBlazorAsync();
+
+ // Act
+ await Page.FillFieldAsync("PhoneNumber", "+49 123 4567890");
+ await Page.ClickButtonAsync("Save Changes");
+
+ // Assert
+ await Expect(Page.GetByText("Your profile has been updated")).ToBeVisibleAsync();
+ }
+
+ [Fact]
+ public async Task ChangePassword_ThenLoginWithNewPassword_Succeeds()
+ {
+ // Arrange
+ var email = await CreateConfirmedUserAndLoginAsync();
+ await Page.GotoAsync("/Account/Manage/ChangePassword");
+ await Page.WaitForBlazorAsync();
+
+ // Act
+ await Page.FillFieldAsync("OldPassword", TestData.DefaultPassword);
+ await Page.FillFieldAsync("NewPassword", TestData.AlternatePassword);
+ await Page.FillFieldAsync("ConfirmPassword", TestData.AlternatePassword);
+ await Page.ClickButtonAsync("Update Password");
+ await Expect(Page.GetByText("password has been updated")).ToBeVisibleAsync();
+
+ // Assert — the new password is the one that now works.
+ await LogoutAsync();
+ await LoginAsync(email, TestData.AlternatePassword);
+ Assert.NotEqual("/account/login", new Uri(Page.Url).AbsolutePath.ToLowerInvariant());
+ }
+
+ [Fact]
+ public async Task DeleteAccount_WithPassword_RemovesAccount()
+ {
+ // Arrange
+ var email = await CreateConfirmedUserAndLoginAsync();
+ await Page.GotoAsync("/Account/Manage/DeletePersonalData");
+ await Page.WaitForBlazorAsync();
+
+ // Act — submit triggers a Radzen confirm dialog that must be accepted.
+ await Page.FillFieldAsync("Password", TestData.DefaultPassword);
+ await Page.ClickButtonAsync("Permanently Delete My Account");
+ await Page.ClickButtonAsync("Yes, Delete My Account");
+
+ // Assert — the deleted account can no longer log in.
+ await Page.GotoAsync("/Account/Login");
+ await Page.WaitForBlazorAsync();
+ await Page.FillFieldAsync("Email", email);
+ await Page.FillFieldAsync("Password", TestData.DefaultPassword);
+ await Page.ClickButtonAsync("Log in");
+ await Expect(Page.GetByText("Invalid login attempt")).ToBeVisibleAsync();
+ }
+
+ #region Helpers
+
+ private async Task CreateConfirmedUserAndLoginAsync()
+ {
+ await Fixture.ProvisionAdminAsync();
+ await Fixture.MailHog.ClearAsync();
+ var email = await RegisterAsync();
+ await Page.WaitForURLAsync(url =>
+ url.Contains("RegisterConfirmation", StringComparison.OrdinalIgnoreCase)
+ );
+ await ConfirmEmailAsync(email);
+ await LoginAsync(email, TestData.DefaultPassword);
+ return email;
+ }
+
+ #endregion
+}
diff --git a/tests/AndreGoepel.AppFoundation.E2ETests/Tests/AdministrationTests.cs b/tests/AndreGoepel.AppFoundation.E2ETests/Tests/AdministrationTests.cs
new file mode 100644
index 0000000..de6415f
--- /dev/null
+++ b/tests/AndreGoepel.AppFoundation.E2ETests/Tests/AdministrationTests.cs
@@ -0,0 +1,69 @@
+using AndreGoepel.AppFoundation.E2ETests.Infrastructure;
+
+namespace AndreGoepel.AppFoundation.E2ETests.Tests;
+
+/// Covers the Administrator-only management area and its authorization boundary.
+public sealed class AdministrationTests(E2EAppFixture fixture) : E2ETestBase(fixture)
+{
+ [Fact]
+ public async Task Admin_CanViewUsers_ListingIncludesAdminAccount()
+ {
+ // Arrange
+ await LoginAsAdminAsync();
+
+ // Act
+ await Page.GotoAsync("/Administration/Users");
+ await Page.WaitForBlazorAsync();
+
+ // Assert — the admin's own account is listed in the grid. Scoped to the grid
+ // because the topbar user chip shows the same email.
+ await Expect(Page.Locator(".rz-data-grid").GetByText(TestData.AdminEmail))
+ .ToBeVisibleAsync();
+ }
+
+ [Fact]
+ public async Task Admin_CanCreateRole_AppearsInGrid()
+ {
+ // Arrange
+ await LoginAsAdminAsync();
+ await Page.GotoAsync("/Administration/Roles");
+ await Page.WaitForBlazorAsync();
+ var roleName = "QA-Role-" + Guid.NewGuid().ToString("N")[..8];
+
+ // Act — the "New role" button opens a dialog with a name field.
+ await Page.ClickButtonAsync("New role");
+ await Page.FillFieldAsync("Rolename", roleName);
+ await Page.ClickButtonAsync("Save");
+
+ // Assert
+ await Expect(Page.GetByText(roleName)).ToBeVisibleAsync();
+ }
+
+ [Fact]
+ public async Task NonAdmin_AccessingAdministration_IsBouncedAway()
+ {
+ // Arrange — a confirmed non-admin user.
+ await Fixture.ProvisionAdminAsync();
+ await Fixture.MailHog.ClearAsync();
+ var email = await RegisterAsync();
+ await Page.WaitForURLAsync(url =>
+ url.Contains("RegisterConfirmation", StringComparison.OrdinalIgnoreCase)
+ );
+ await ConfirmEmailAsync(email);
+ await LoginAsync(email, TestData.DefaultPassword);
+
+ // Act
+ await Page.GotoAsync("/Administration/Roles");
+
+ // Assert — the Administrator-only page never renders for a non-admin: the
+ // authorization redirect bounces them off the /Administration path (to the app
+ // home, since they are already authenticated — an anonymous visitor would instead
+ // be sent to the login page).
+ await Page.WaitForURLAsync(url =>
+ !new Uri(url).AbsolutePath.Contains(
+ "Administration",
+ StringComparison.OrdinalIgnoreCase
+ )
+ );
+ }
+}
diff --git a/tests/AndreGoepel.AppFoundation.E2ETests/Tests/AppPagesTests.cs b/tests/AndreGoepel.AppFoundation.E2ETests/Tests/AppPagesTests.cs
new file mode 100644
index 0000000..6310169
--- /dev/null
+++ b/tests/AndreGoepel.AppFoundation.E2ETests/Tests/AppPagesTests.cs
@@ -0,0 +1,51 @@
+using AndreGoepel.AppFoundation.E2ETests.Infrastructure;
+
+namespace AndreGoepel.AppFoundation.E2ETests.Tests;
+
+/// Covers the host/application pages: the sample home and the AppFoundation admin screens.
+public sealed class AppPagesTests(E2EAppFixture fixture) : E2ETestBase(fixture)
+{
+ [Fact]
+ public async Task SampleHome_Renders()
+ {
+ // Arrange
+ await Fixture.ProvisionAdminAsync();
+
+ // Act
+ await Page.GotoAsync("/");
+ await Page.WaitForBlazorAsync();
+
+ // Assert
+ Assert.Contains("AppFoundation Sample", await Page.TitleAsync());
+ }
+
+ [Fact]
+ public async Task EmailSettings_LoadsForAdministrator()
+ {
+ // Arrange
+ await LoginAsAdminAsync();
+
+ // Act
+ await Page.GotoAsync("/Administration/EmailSettings");
+ await Page.WaitForBlazorAsync();
+
+ // Assert
+ await Expect(Page.GetByRole(AriaRole.Heading, new() { Name = "Email Settings" }))
+ .ToBeVisibleAsync();
+ }
+
+ [Fact]
+ public async Task LoginFeatures_LoadsForAdministrator()
+ {
+ // Arrange
+ await LoginAsAdminAsync();
+
+ // Act
+ await Page.GotoAsync("/Administration/LoginFeatures");
+ await Page.WaitForBlazorAsync();
+
+ // Assert
+ await Expect(Page.GetByRole(AriaRole.Heading, new() { Name = "Login Features" }))
+ .ToBeVisibleAsync();
+ }
+}
diff --git a/tests/AndreGoepel.AppFoundation.E2ETests/Tests/LoginTests.cs b/tests/AndreGoepel.AppFoundation.E2ETests/Tests/LoginTests.cs
new file mode 100644
index 0000000..64149e2
--- /dev/null
+++ b/tests/AndreGoepel.AppFoundation.E2ETests/Tests/LoginTests.cs
@@ -0,0 +1,72 @@
+using AndreGoepel.AppFoundation.E2ETests.Infrastructure;
+
+namespace AndreGoepel.AppFoundation.E2ETests.Tests;
+
+/// Covers login success/failure, account lockout, and logout.
+public sealed class LoginTests(E2EAppFixture fixture) : E2ETestBase(fixture)
+{
+ [Fact]
+ public async Task Login_WithWrongPassword_ShowsInvalidAndStaysOnPage()
+ {
+ // Arrange
+ await LoginAsAdminAsync(); // ensures the admin exists
+ await LogoutAsync();
+ await Page.GotoAsync("/Account/Login");
+ await Page.WaitForBlazorAsync();
+
+ // Act
+ await Page.FillFieldAsync("Email", TestData.AdminEmail);
+ await Page.FillFieldAsync("Password", "totally-wrong-1!");
+ await Page.ClickButtonAsync("Log in");
+
+ // Assert
+ await Expect(Page.GetByText("Invalid login attempt")).ToBeVisibleAsync();
+ Assert.Equal("/Account/Login", new Uri(Page.Url).AbsolutePath);
+ }
+
+ [Fact]
+ public async Task Login_AfterRepeatedFailures_LocksAccount()
+ {
+ // Arrange — a confirmed user (lockout only applies once sign-in is otherwise allowed).
+ await Fixture.ProvisionAdminAsync();
+ await Fixture.MailHog.ClearAsync();
+ var email = await RegisterAsync();
+ await Page.WaitForURLAsync(url =>
+ url.Contains("RegisterConfirmation", StringComparison.OrdinalIgnoreCase)
+ );
+ await ConfirmEmailAsync(email);
+
+ // Act — hammer wrong passwords until the middleware redirects to the lockout page.
+ var lockedOut = false;
+ for (var attempt = 0; attempt < 7 && !lockedOut; attempt++)
+ {
+ await Page.GotoAsync("/Account/Login");
+ await Page.WaitForBlazorAsync();
+ await Page.FillFieldAsync("Email", email);
+ await Page.FillFieldAsync("Password", "wrong-password-1!");
+ await Page.ClickButtonAsync("Log in");
+ await Page.WaitForTimeoutAsync(300);
+ lockedOut = Page.Url.Contains("/Account/Lockout", StringComparison.OrdinalIgnoreCase);
+ }
+
+ // Assert
+ Assert.True(lockedOut, "Account should have been locked out after repeated failures.");
+ }
+
+ [Fact]
+ public async Task Logout_ThenAccessingProtectedPage_RedirectsToLogin()
+ {
+ // Arrange
+ await LoginAsAdminAsync();
+ await Page.GotoAsync("/Account/Manage/Profile");
+ await Page.WaitForBlazorAsync();
+
+ // Act
+ await LogoutAsync();
+ await Page.GotoAsync("/Account/Manage/Profile");
+ await Page.WaitForBlazorAsync();
+
+ // Assert — the protected page bounces an anonymous visitor to login.
+ await Page.AssertOnPathAsync("Account/Login");
+ }
+}
diff --git a/tests/AndreGoepel.AppFoundation.E2ETests/Tests/PasskeyTests.cs b/tests/AndreGoepel.AppFoundation.E2ETests/Tests/PasskeyTests.cs
new file mode 100644
index 0000000..2cfd225
--- /dev/null
+++ b/tests/AndreGoepel.AppFoundation.E2ETests/Tests/PasskeyTests.cs
@@ -0,0 +1,99 @@
+using AndreGoepel.AppFoundation.E2ETests.Infrastructure;
+
+namespace AndreGoepel.AppFoundation.E2ETests.Tests;
+
+///
+/// Covers the WebAuthn/passkey journeys using a Chromium CDP virtual authenticator that
+/// auto-satisfies user presence/verification, so create/get ceremonies complete headlessly.
+///
+public sealed class PasskeyTests(E2EAppFixture fixture) : E2ETestBase(fixture)
+{
+ [Fact]
+ public async Task RegisterPasskey_ThenRename_AppearsInList()
+ {
+ // Arrange
+ await CreateConfirmedUserAndLoginAsync();
+ await VirtualAuthenticator.EnableAsync(Context, Page);
+
+ // Act
+ await RegisterPasskeyAsync("My Test Key");
+
+ // Assert — the named passkey shows up in the management grid.
+ await Page.AssertOnPathAsync("Account/Manage/Passkeys");
+ await Expect(Page.GetByText("My Test Key")).ToBeVisibleAsync();
+ }
+
+ [Fact]
+ public async Task RegisterPasskey_ThenLoginWithPasskey_Succeeds()
+ {
+ // Arrange — create a passkey, then sign out (credential stays in the virtual authenticator).
+ await CreateConfirmedUserAndLoginAsync();
+ await VirtualAuthenticator.EnableAsync(Context, Page);
+ await RegisterPasskeyAsync("Login Key");
+ await LogoutAsync();
+
+ // Act — sign in with the passkey. On the login page the browser's conditional
+ // mediation often picks up the resident credential from the virtual authenticator
+ // and completes the assertion on its own; if it hasn't, click the passkey button
+ // to trigger it explicitly. Either way the ceremony ends by leaving the login page.
+ await Page.GotoAsync("/Account/Login");
+ await Page.WaitForBlazorAsync();
+ try
+ {
+ await Page.GetByText("Log in with a passkey")
+ .ClickAsync(new LocatorClickOptions { Timeout = 5_000 });
+ }
+ catch (TimeoutException)
+ {
+ // Conditional mediation already signed in and navigated away — nothing to click.
+ }
+
+ // Assert — the assertion ceremony logs us in and leaves the login page.
+ await Page.WaitForURLAsync(url =>
+ !new Uri(url).AbsolutePath.StartsWith(
+ "/Account/Login",
+ StringComparison.OrdinalIgnoreCase
+ )
+ );
+ }
+
+ #region Helpers
+
+ private async Task CreateConfirmedUserAndLoginAsync()
+ {
+ await Fixture.ProvisionAdminAsync();
+ await Fixture.MailHog.ClearAsync();
+ var email = await RegisterAsync();
+ await Page.WaitForURLAsync(url =>
+ url.Contains("RegisterConfirmation", StringComparison.OrdinalIgnoreCase)
+ );
+ await ConfirmEmailAsync(email);
+ await LoginAsync(email, TestData.DefaultPassword);
+ return email;
+ }
+
+ private async Task RegisterPasskeyAsync(string name)
+ {
+ await Page.GotoAsync("/Account/Manage/Passkeys/Create");
+ await Page.WaitForBlazorAsync();
+ // Let the PasskeySubmit JS module finish importing before triggering the ceremony.
+ await Page.WaitForTimeoutAsync(500);
+ await Page.GetByText("Register Passkey").ClickAsync();
+
+ // On success the app routes to the rename page for the new credential.
+ await Page.WaitForURLAsync(url =>
+ url.Contains("Passkeys/Rename", StringComparison.OrdinalIgnoreCase)
+ );
+ await Page.WaitForBlazorAsync();
+ await Page.FillFieldAsync("NameField", name);
+ await Page.ClickButtonAsync("Save Name");
+ await Page.WaitForURLAsync(url =>
+ new Uri(url).AbsolutePath.EndsWith(
+ "/Account/Manage/Passkeys",
+ StringComparison.OrdinalIgnoreCase
+ )
+ );
+ }
+
+ #endregion
+}
diff --git a/tests/AndreGoepel.AppFoundation.E2ETests/Tests/PasswordResetTests.cs b/tests/AndreGoepel.AppFoundation.E2ETests/Tests/PasswordResetTests.cs
new file mode 100644
index 0000000..76de784
--- /dev/null
+++ b/tests/AndreGoepel.AppFoundation.E2ETests/Tests/PasswordResetTests.cs
@@ -0,0 +1,71 @@
+using AndreGoepel.AppFoundation.E2ETests.Infrastructure;
+
+namespace AndreGoepel.AppFoundation.E2ETests.Tests;
+
+/// Covers the forgot-password / reset-password / resend-confirmation flows end to end via MailHog.
+public sealed class PasswordResetTests(E2EAppFixture fixture) : E2ETestBase(fixture)
+{
+ [Fact]
+ public async Task ForgotPassword_ResetLink_AllowsLoginWithNewPassword()
+ {
+ // Arrange — a confirmed user we can safely change (never the shared admin).
+ await Fixture.ProvisionAdminAsync();
+ await Fixture.MailHog.ClearAsync();
+ var email = await RegisterAsync();
+ await Page.WaitForURLAsync(url =>
+ url.Contains("RegisterConfirmation", StringComparison.OrdinalIgnoreCase)
+ );
+ await ConfirmEmailAsync(email);
+
+ // Act — request the reset, follow the emailed link, set a new password.
+ await Fixture.MailHog.ClearAsync();
+ await Page.GotoAsync("/Account/ForgotPassword");
+ await Page.WaitForBlazorAsync();
+ await Page.FillFieldAsync("Email", email);
+ await Page.ClickButtonAsync("Reset password");
+ await Page.AssertOnPathAsync("Account/ForgotPasswordConfirmation");
+
+ var resetLink = await Fixture.MailHog.WaitForLinkAsync(email, "Account/ResetPassword");
+ await Page.GotoAsync(resetLink);
+ await Page.WaitForBlazorAsync();
+ await Page.FillFieldAsync("Email", email);
+ await Page.FillFieldAsync("Password", TestData.AlternatePassword);
+ await Page.FillFieldAsync("ConfirmPassword", TestData.AlternatePassword);
+ await Page.ClickButtonAsync("Reset password");
+ await Page.AssertOnPathAsync("Account/ResetPasswordConfirmation");
+
+ // Assert — the new password works, the old one is irrelevant.
+ await LoginAsync(email, TestData.AlternatePassword);
+ Assert.NotEqual("/account/login", new Uri(Page.Url).AbsolutePath.ToLowerInvariant());
+ }
+
+ [Fact]
+ public async Task ResetPassword_WithoutCode_ShowsInvalidLink()
+ {
+ // Arrange
+ await Fixture.ProvisionAdminAsync();
+
+ // Act
+ await Page.GotoAsync("/Account/ResetPassword");
+ await Page.WaitForBlazorAsync();
+
+ // Assert
+ await Expect(Page.GetByText("password reset link is invalid")).ToBeVisibleAsync();
+ }
+
+ [Fact]
+ public async Task ResendEmailConfirmation_ShowsSentMessage()
+ {
+ // Arrange
+ await Fixture.ProvisionAdminAsync();
+
+ // Act
+ await Page.GotoAsync("/Account/ResendEmailConfirmation");
+ await Page.WaitForBlazorAsync();
+ await Page.FillFieldAsync("Email", TestData.NewEmail());
+ await Page.ClickButtonAsync("Resend");
+
+ // Assert — the app never reveals whether the address exists.
+ await Expect(Page.GetByText("Verification email sent")).ToBeVisibleAsync();
+ }
+}
diff --git a/tests/AndreGoepel.AppFoundation.E2ETests/Tests/RegistrationTests.cs b/tests/AndreGoepel.AppFoundation.E2ETests/Tests/RegistrationTests.cs
new file mode 100644
index 0000000..cab74ee
--- /dev/null
+++ b/tests/AndreGoepel.AppFoundation.E2ETests/Tests/RegistrationTests.cs
@@ -0,0 +1,66 @@
+using AndreGoepel.AppFoundation.E2ETests.Infrastructure;
+
+namespace AndreGoepel.AppFoundation.E2ETests.Tests;
+
+/// Covers the self-service registration path and its email-confirmation gate.
+public sealed class RegistrationTests(E2EAppFixture fixture) : E2ETestBase(fixture)
+{
+ [Fact]
+ public async Task Register_ThenConfirmEmail_AllowsLogin()
+ {
+ // Arrange — the setup gate must be past so /Account/Register is reachable.
+ await Fixture.ProvisionAdminAsync();
+ await Fixture.MailHog.ClearAsync();
+
+ // Act
+ var email = await RegisterAsync();
+ await Page.WaitForURLAsync(url =>
+ url.Contains("RegisterConfirmation", StringComparison.OrdinalIgnoreCase)
+ );
+ await ConfirmEmailAsync(email);
+ await LoginAsync(email, TestData.DefaultPassword);
+
+ // Assert — landed somewhere authenticated, not back on the login page.
+ Assert.NotEqual("/account/login", new Uri(Page.Url).AbsolutePath.ToLowerInvariant());
+ }
+
+ [Fact]
+ public async Task Register_BeforeConfirmingEmail_CannotLogIn()
+ {
+ // Arrange
+ await Fixture.ProvisionAdminAsync();
+ var email = await RegisterAsync();
+ await Page.WaitForURLAsync(url =>
+ url.Contains("RegisterConfirmation", StringComparison.OrdinalIgnoreCase)
+ );
+
+ // Act — attempt login without confirming.
+ await Page.GotoAsync("/Account/Login");
+ await Page.WaitForBlazorAsync();
+ await Page.FillFieldAsync("Email", email);
+ await Page.FillFieldAsync("Password", TestData.DefaultPassword);
+ await Page.ClickButtonAsync("Log in");
+
+ // Assert — still on login and told it was invalid; account not usable yet.
+ await Expect(Page.GetByText("Invalid login attempt")).ToBeVisibleAsync();
+ Assert.Equal("/Account/Login", new Uri(Page.Url).AbsolutePath);
+ }
+
+ [Fact]
+ public async Task Register_WithMismatchedPasswords_ShowsValidationError()
+ {
+ // Arrange
+ await Fixture.ProvisionAdminAsync();
+ await Page.GotoAsync("/Account/Register");
+ await Page.WaitForBlazorAsync();
+
+ // Act
+ await Page.FillFieldAsync("Email", TestData.NewEmail());
+ await Page.FillFieldAsync("NewPassword", TestData.DefaultPassword);
+ await Page.FillFieldAsync("ConfirmPassword", "different-Pass1!");
+ await Page.ClickButtonAsync("Register");
+
+ // Assert
+ await Expect(Page.GetByText("The passwords do not match")).ToBeVisibleAsync();
+ }
+}
diff --git a/tests/AndreGoepel.AppFoundation.E2ETests/Tests/SmokeTests.cs b/tests/AndreGoepel.AppFoundation.E2ETests/Tests/SmokeTests.cs
new file mode 100644
index 0000000..aebe52a
--- /dev/null
+++ b/tests/AndreGoepel.AppFoundation.E2ETests/Tests/SmokeTests.cs
@@ -0,0 +1,37 @@
+using AndreGoepel.AppFoundation.E2ETests.Infrastructure;
+
+namespace AndreGoepel.AppFoundation.E2ETests.Tests;
+
+/// Fast confidence checks that the harness boots the app and the core happy path works.
+public sealed class SmokeTests(E2EAppFixture fixture) : E2ETestBase(fixture)
+{
+ [Fact]
+ public async Task Setup_ProvisionsAdmin_AndSetupPageIsShownOnlyOnce()
+ {
+ // Arrange
+ await Fixture.ProvisionAdminAsync();
+
+ // Act
+ await Page.GotoAsync("/Setup");
+ await Page.WaitForBlazorAsync();
+
+ // Assert — once an admin exists, Setup redirects away from itself.
+ Assert.DoesNotContain(
+ "/Setup",
+ new Uri(Page.Url).AbsolutePath,
+ StringComparison.OrdinalIgnoreCase
+ );
+ }
+
+ [Fact]
+ public async Task Admin_CanLogIn_AndReachDashboard()
+ {
+ // Arrange / Act
+ await LoginAsAdminAsync();
+ await Page.GotoAsync("/dashboard");
+ await Page.WaitForBlazorAsync();
+
+ // Assert
+ await Page.AssertOnPathAsync("dashboard");
+ }
+}
diff --git a/tests/AndreGoepel.AppFoundation.E2ETests/Tests/TwoFactorTests.cs b/tests/AndreGoepel.AppFoundation.E2ETests/Tests/TwoFactorTests.cs
new file mode 100644
index 0000000..1b204d8
--- /dev/null
+++ b/tests/AndreGoepel.AppFoundation.E2ETests/Tests/TwoFactorTests.cs
@@ -0,0 +1,117 @@
+using AndreGoepel.AppFoundation.E2ETests.Infrastructure;
+
+namespace AndreGoepel.AppFoundation.E2ETests.Tests;
+
+///
+/// Covers the TOTP two-factor lifecycle: enabling via authenticator, logging in with a generated code,
+/// logging in with a recovery code, and disabling. Each test uses its own user so enabling 2FA never
+/// affects the shared admin account.
+///
+public sealed class TwoFactorTests(E2EAppFixture fixture) : E2ETestBase(fixture)
+{
+ [Fact]
+ public async Task Enable2fa_ThenLogin_RequiresAuthenticatorCode()
+ {
+ // Arrange
+ var (email, sharedKey, _) = await CreateUserWithTwoFactorAsync();
+
+ // Act — a fresh login must be challenged for a code, which we compute from the shared key.
+ await LogoutAsync();
+ await LoginAsync(email, TestData.DefaultPassword);
+ await Page.AssertOnPathAsync("Account/LoginWith2fa");
+ await Page.WaitForBlazorAsync();
+ await Page.FillFieldAsync("TwoFactorCode", Totp.Compute(sharedKey));
+ await Page.ClickButtonAsync("Log in");
+
+ // Assert — challenge cleared, no longer on any login page.
+ await Page.WaitForURLAsync(url =>
+ !new Uri(url).AbsolutePath.StartsWith(
+ "/Account/Login",
+ StringComparison.OrdinalIgnoreCase
+ )
+ );
+ }
+
+ [Fact]
+ public async Task Enable2fa_ThenLoginWithRecoveryCode_Succeeds()
+ {
+ // Arrange
+ var (email, _, recoveryCodes) = await CreateUserWithTwoFactorAsync();
+ Assert.NotEmpty(recoveryCodes);
+
+ // Act
+ await LogoutAsync();
+ await LoginAsync(email, TestData.DefaultPassword);
+ await Page.AssertOnPathAsync("Account/LoginWith2fa");
+ await Page.WaitForBlazorAsync();
+ await Page.ClickLinkAsync("Use a recovery code instead");
+ await Page.AssertOnPathAsync("Account/LoginWithRecoveryCode");
+ await Page.WaitForBlazorAsync();
+ await Page.FillFieldAsync("RecoveryCode", recoveryCodes[0]);
+ await Page.ClickButtonAsync("Log in");
+
+ // Assert
+ await Page.WaitForURLAsync(url =>
+ !new Uri(url).AbsolutePath.StartsWith(
+ "/Account/Login",
+ StringComparison.OrdinalIgnoreCase
+ )
+ );
+ }
+
+ [Fact]
+ public async Task Disable2fa_RemovesTheChallenge()
+ {
+ // Arrange
+ var (email, _, _) = await CreateUserWithTwoFactorAsync();
+
+ // Act — disable, then log in fresh.
+ await Page.GotoAsync("/Account/Manage/Disable2fa");
+ await Page.WaitForBlazorAsync();
+ await Page.ClickButtonAsync("Disable 2FA");
+ await Page.AssertOnPathAsync("Account/Manage/TwoFactorAuthentication");
+ await LogoutAsync();
+ await LoginAsync(email, TestData.DefaultPassword);
+
+ // Assert — login completes without a 2FA challenge.
+ Assert.DoesNotContain("LoginWith2fa", Page.Url, StringComparison.OrdinalIgnoreCase);
+ }
+
+ #region Helpers
+
+ /// Registers & confirms a user, logs them in, enables TOTP 2FA, and returns its secrets.
+ private async Task<(
+ string Email,
+ string SharedKey,
+ IReadOnlyList RecoveryCodes
+ )> CreateUserWithTwoFactorAsync()
+ {
+ await Fixture.ProvisionAdminAsync();
+ await Fixture.MailHog.ClearAsync();
+ var email = await RegisterAsync();
+ await Page.WaitForURLAsync(url =>
+ url.Contains("RegisterConfirmation", StringComparison.OrdinalIgnoreCase)
+ );
+ await ConfirmEmailAsync(email);
+ await LoginAsync(email, TestData.DefaultPassword);
+
+ await Page.GotoAsync("/Account/Manage/EnableAuthenticator");
+ await Page.WaitForBlazorAsync();
+
+ var sharedKey = (await Page.Locator("strong").First.InnerTextAsync()).Trim();
+ await Page.FillFieldAsync("VerificationCode", Totp.Compute(sharedKey));
+ await Page.ClickButtonAsync("Verify");
+
+ await Expect(Page.GetByText("Put these codes in a safe place")).ToBeVisibleAsync();
+ var recoveryCodes = await Page.Locator("[style*='font-family: monospace']")
+ .AllInnerTextsAsync();
+
+ return (
+ email,
+ sharedKey,
+ recoveryCodes.Select(c => c.Trim()).Where(c => c.Length > 0).ToList()
+ );
+ }
+
+ #endregion
+}
diff --git a/tests/AndreGoepel.AppFoundation.E2ETests/podman.runsettings b/tests/AndreGoepel.AppFoundation.E2ETests/podman.runsettings
new file mode 100644
index 0000000..ac258d0
--- /dev/null
+++ b/tests/AndreGoepel.AppFoundation.E2ETests/podman.runsettings
@@ -0,0 +1,14 @@
+
+
+
+
+
+ podman
+
+
+