Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions AndreGoepel.AppFoundation.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
<Folder Name="/tests/">
<Project Path="tests/AndreGoepel.AppFoundation.Tests/AndreGoepel.AppFoundation.Tests.csproj" />
<Project Path="tests/AndreGoepel.AppFoundation.MailService.Tests/AndreGoepel.AppFoundation.MailService.Tests.csproj" />
<Project Path="tests/AndreGoepel.AppFoundation.E2ETests/AndreGoepel.AppFoundation.E2ETests.csproj" />
</Folder>
<Folder Name="/samples/">
<Project Path="samples/AndreGoepel.AppFoundation.AppHost/AndreGoepel.AppFoundation.AppHost.csproj" />
Expand Down
3 changes: 3 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
<ItemGroup Label="Aspire">
<PackageVersion Include="Aspire.Hosting.Docker" Version="13.4.6" />
<PackageVersion Include="Aspire.Hosting.PostgreSQL" Version="13.4.6" />
<PackageVersion Include="Aspire.Hosting.Testing" Version="13.4.6" />
</ItemGroup>

<ItemGroup Label="Marten">
Expand Down Expand Up @@ -52,6 +53,8 @@

<ItemGroup Label="Testing">
<PackageVersion Include="bunit" Version="2.7.2" />
<PackageVersion Include="Microsoft.Playwright" Version="1.49.0" />
<PackageVersion Include="Otp.NET" Version="1.4.0" />
<PackageVersion Include="coverlet.collector" Version="10.0.1" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.8" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
Expand Down
21 changes: 18 additions & 3 deletions samples/AndreGoepel.AppFoundation.AppHost/AppHost.cs
Original file line number Diff line number Diff line change
@@ -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<bool>("E2E"))
{
postgres.WithDataVolume();
}

// The database resource name is the connection-string name the foundation reads by default
// (AppFoundationOptions.DatabaseConnectionName == "appfoundation-database").
Expand All @@ -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");
Expand Down
6 changes: 5 additions & 1 deletion samples/AndreGoepel.AppFoundation.Sample/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ namespace AndreGoepel.AppFoundation.MailService;
/// Resolves the effective <see cref="MailConfiguration"/> for sending. Looked up
/// per send so database changes take effect without an application restart.
/// </summary>
internal interface IMailSettingsProvider
public interface IMailSettingsProvider
{
Task<MailConfiguration> GetAsync(CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

namespace AndreGoepel.AppFoundation.MailService;

internal record MailConfiguration
public record MailConfiguration
{
[Required]
public required string SenderName { get; init; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ namespace AndreGoepel.AppFoundation.MailService;
/// without one the <c>EmailSender</c> configuration section applies (bootstrap
/// path — same behaviour as before database-backed settings existed).
/// </summary>
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<MailConfiguration> fallback,
IDataProtectionProvider dataProtectionProvider
Expand Down
6 changes: 5 additions & 1 deletion src/AndreGoepel.AppFoundation.MailService/SmtpEmailSender.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<!-- Aspire.Hosting.Testing pulls RID-specific orchestration packages, so a committed
lock file would be OS-dependent and break CI's locked-mode restore. The sample
AppHost opts out of lock files for the same reason. -->
<RestorePackagesWithLockFile>false</RestorePackagesWithLockFile>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Aspire.Hosting.Testing" />
<PackageReference Include="Microsoft.Playwright" />
<PackageReference Include="Otp.NET" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.runner.visualstudio">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>

<ItemGroup>
<!-- The sample AppHost boots the real app graph (Postgres + MailHog + the sample web app). -->
<ProjectReference Include="..\..\samples\AndreGoepel.AppFoundation.AppHost\AndreGoepel.AppFoundation.AppHost.csproj" />
</ItemGroup>
</Project>
3 changes: 3 additions & 0 deletions tests/AndreGoepel.AppFoundation.E2ETests/GlobalUsings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
global using Microsoft.Playwright;
global using static Microsoft.Playwright.Assertions;
global using Xunit;
Original file line number Diff line number Diff line change
@@ -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

/// <summary>
/// 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.
/// </summary>
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;

/// <summary>Base address of the sample web app, e.g. <c>https://localhost:1234/</c>.</summary>
public string AppBaseUrl { get; private set; } = default!;

/// <summary>Base address of the MailHog HTTP API used to read delivered emails.</summary>
public string MailHogApiUrl { get; private set; } = default!;

/// <summary>The single browser shared by the collection. Tests create their own context.</summary>
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<Projects.AndreGoepel_AppFoundation_AppHost>([
"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<ResourceNotificationService>();
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);

/// <summary>
/// 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.
/// </summary>
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();
}
}

/// <summary>Creates an isolated browser context (fresh cookies/storage) for a single test.</summary>
public Task<IBrowserContext> 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<E2EAppFixture>
{
public const string Name = "e2e";
}

#endregion
Loading
Loading