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