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
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ namespace AndreGoepel.AppFoundation.Hosting.DataProtection;
/// finance-app) keep their key ring on upgrade. Renaming this type or its
/// properties requires a data migration.
/// </remarks>
public sealed class DataProtectionKeyDocument
public sealed record DataProtectionKeyDocument
{
public required string Id { get; init; }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,8 @@

namespace AndreGoepel.AppFoundation.Hosting.DataProtection;

/// <summary>
/// Stores DataProtection key ring entries as Marten documents. The document store
/// is resolved lazily so the repository can be wired into
/// <c>KeyManagementOptions</c> before Marten itself is built.
/// <see cref="IXmlRepository"/> is a synchronous contract, so the async Marten
/// calls are blocked on — key ring reads/writes are rare, startup-time operations.
/// </summary>
// The document store is resolved lazily so this can be wired into KeyManagementOptions before Marten is built.
// IXmlRepository is synchronous, so the async Marten calls are blocked on — key ring I/O is rare and startup-time.
internal sealed class MartenXmlRepository(IServiceProvider services) : IXmlRepository
{
public IReadOnlyCollection<XElement> GetAllElements()
Expand Down
200 changes: 46 additions & 154 deletions src/AndreGoepel.AppFoundation.Hosting/Initialization.cs

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -4,32 +4,18 @@

namespace AndreGoepel.AppFoundation.Hosting.Quartz;

/// <summary>
/// Idempotently provisions Quartz's PostgreSQL job-store schema (<c>qrtz_*</c> tables) at
/// startup, mirroring Marten's own schema-creation posture — a fresh database must come up
/// with no manual steps, and a host that provisions schema out-of-band
/// (<see cref="AutoCreate.None"/>) skips this too (#129).
/// </summary>
// Idempotently provisions Quartz's PostgreSQL job-store schema (qrtz_* tables) at startup, mirroring Marten's
// own schema-creation posture; a host that provisions schema out-of-band (AutoCreate.None) skips this too (#129).
internal static class QuartzSchemaProvisioner
{
private const string ScriptResourceName =
"AndreGoepel.AppFoundation.Hosting.Quartz.qrtz_tables_postgres.sql";

/// <summary>
/// Whether the schema should be provisioned for the given (already-resolved)
/// <see cref="AutoCreate"/> mode — the same mode <c>AddAppFoundation</c> passes to
/// Marten's <c>AutoCreateSchemaObjects</c>.
/// </summary>
internal static bool ShouldProvision(AutoCreate schemaCreation) =>
schemaCreation != AutoCreate.None;

/// <summary>
/// Runs the vendored, idempotent DDL script against <paramref name="connectionString"/>.
/// Synchronous and blocking by design: it runs once, during <c>AddAppFoundation</c>,
/// before <c>WebApplicationBuilder.Build()</c> — well before Quartz's own hosted service
/// starts and queries these tables, so there is no ordering-dependent async startup step
/// to get wrong.
/// </summary>
// Synchronous and blocking by design: runs once, before WebApplicationBuilder.Build(), well before Quartz's
// own hosted service starts and queries these tables.
internal static void Provision(string connectionString)
{
using var connection = new NpgsqlConnection(connectionString);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
namespace AndreGoepel.AppFoundation.MailService;

public record MailConfiguration
public sealed record MailConfiguration
{
public string SenderName { get; init; } = "";

Expand Down
9 changes: 2 additions & 7 deletions src/AndreGoepel.AppFoundation.MailService/MailMessage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,8 @@ namespace AndreGoepel.AppFoundation.MailService;
/// token-bearing row in the durable store (#55).
/// </summary>
[DeliverWithin(DeliveryWindowSeconds)]
public record MailMessage(string Recipient, string Subject, string Body)
public sealed record MailMessage(string Recipient, string Subject, string Body)
{
/// <summary>
/// Maximum time a queued email may wait for delivery before Wolverine discards it.
/// One hour is far beyond normal (sub-second) delivery, so it never affects the
/// happy path — it only sheds messages stuck across an outage, whose token is
/// likely expired anyway.
/// </summary>
// One hour is far beyond normal delivery, so it only sheds messages stuck across an outage.
internal const int DeliveryWindowSeconds = 3600;
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
namespace AndreGoepel.AppFoundation.MailService;

[WolverineHandler]
public class SendEmailMessageHandler(
public sealed class SendEmailMessageHandler(
IEmailSender EmailSender,
ILogger<SendEmailMessageHandler> Logger
)
Expand All @@ -30,13 +30,14 @@ public static void Configure(HandlerChain chain) =>
)
.Then.Discard();

public async Task Handle(MailMessage message, Envelope envelope)
public async Task Handle(
MailMessage message,
Envelope envelope,
CancellationToken cancellationToken
)
{
// MailMessage is an internal, in-process contract. Refuse to act on one that
// arrived over an external transport, so a consumer that (accidentally) exposes
// this message type on an untrusted transport cannot turn it into an
// arbitrary-email / phishing primitive (#57). Messages published in-process are
// routed to a local:// queue; anything else is dropped.
// MailMessage is an internal, in-process contract; refuse to act on one that arrived over an external
// transport, so an accidental exposure can't be turned into an arbitrary-email/phishing primitive (#57).
if (!IsLocalOrigin(envelope.Destination))
{
Logger.LogWarning(
Expand All @@ -47,15 +48,16 @@ public async Task Handle(MailMessage message, Envelope envelope)
return;
}

await EmailSender.SendAsync(message.Recipient, message.Subject, message.Body);
await EmailSender.SendAsync(
message.Recipient,
message.Subject,
message.Body,
cancellationToken
);
}

/// <summary>
/// A MailMessage is trusted only when published in-process: Wolverine routes such
/// messages to a <c>local://</c> queue, whereas an external transport carries its
/// own scheme. A null destination (e.g. direct in-process invocation) is treated as
/// local so the normal send path is never blocked.
/// </summary>
// Trusted only when published in-process (routed to a local:// queue); a null destination (direct in-process
// invocation) is treated as local so the normal send path is never blocked.
internal static bool IsLocalOrigin(Uri? destination) =>
destination is null || destination.Scheme == "local";
}
27 changes: 1 addition & 26 deletions src/AndreGoepel.AppFoundation.ServiceDefaults/Extensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,6 @@

namespace Microsoft.Extensions.Hosting;

// Adds common Aspire services: service discovery, resilience, health checks, and OpenTelemetry.
// This project should be referenced by each service project in your solution.
// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults
public static class Extensions
{
private const string HealthEndpointPath = "/health";
Expand All @@ -28,19 +25,10 @@ public static TBuilder AddServiceDefaults<TBuilder>(this TBuilder builder)

builder.Services.ConfigureHttpClientDefaults(http =>
{
// Turn on resilience by default
http.AddStandardResilienceHandler();

// Turn on service discovery by default
http.AddServiceDiscovery();
});

// Uncomment the following to restrict the allowed schemes for service discovery.
// builder.Services.Configure<ServiceDiscoveryOptions>(options =>
// {
// options.AllowedSchemes = ["https"];
// });

return builder;
}

Expand Down Expand Up @@ -72,8 +60,6 @@ public static TBuilder ConfigureOpenTelemetry<TBuilder>(this TBuilder builder)
!context.Request.Path.StartsWithSegments(HealthEndpointPath)
&& !context.Request.Path.StartsWithSegments(AlivenessEndpointPath)
)
// Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package)
//.AddGrpcClientInstrumentation()
.AddHttpClientInstrumentation();
});

Expand All @@ -94,13 +80,6 @@ private static TBuilder AddOpenTelemetryExporters<TBuilder>(this TBuilder builde
builder.Services.AddOpenTelemetry().UseOtlpExporter();
}

// Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package)
//if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"]))
//{
// builder.Services.AddOpenTelemetry()
// .UseAzureMonitor();
//}

return builder;
}

Expand All @@ -109,22 +88,18 @@ public static TBuilder AddDefaultHealthChecks<TBuilder>(this TBuilder builder)
{
builder
.Services.AddHealthChecks()
// Add a default liveness check to ensure app is responsive
.AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]);

return builder;
}

public static WebApplication MapDefaultEndpoints(this WebApplication app)
{
// Adding health checks endpoints to applications in non-development environments has security implications.
// See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments.
// Exposing health endpoints outside development has security implications: https://aka.ms/dotnet/aspire/healthchecks
if (app.Environment.IsDevelopment())
{
// All health checks must pass for app to be considered ready to accept traffic after starting
app.MapHealthChecks(HealthEndpointPath);

// Only health checks tagged with the "live" tag must pass for app to be considered alive
app.MapHealthChecks(
AlivenessEndpointPath,
new HealthCheckOptions { Predicate = r => r.Tags.Contains("live") }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,6 @@
};
}

// Discard: reload the persisted settings, dropping any unsaved edits.
private Task OnDiscard() => LoadSettingsAsync();

private async Task OnValidSubmit(InputModel model)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,7 @@

private AppFoundationLayoutOptions Layout => LayoutOptions.Value;

// MainLayout must inherit LayoutComponentBase (for @Body), so it can't also inherit
// LocalizedComponentBase — resolve through IServiceProvider directly instead, same
// reasoning as LocalizedComponentBase itself (tolerates a host that never registered
// localization).
// Can't inherit LocalizedComponentBase too (already inherits LayoutComponentBase for @Body).
private string T(string key) => Services.AppFoundationText(key);

protected override async Task OnInitializedAsync()
Expand All @@ -96,7 +93,6 @@
NavigationManager.NavigateTo("/Account/SignOutAndRedirect", forceLoad: true);
}

// Two-letter avatar initials derived from the account name / email local part.
private static string Initials(string? name)
{
if (string.IsNullOrWhiteSpace(name))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,7 @@
<ErrorPage Code="@(Code ?? "404")" />

@code {
// Original status code when the status-code-pages middleware re-executes a bare
// 4xx response here (e.g. "?code=403"); absent for router-level not-found.
// Set when re-executed by the status-code-pages middleware (e.g. "?code=403"); absent for router-level 404s.
[SupplyParameterFromQuery(Name = "code")]
public string? Code { get; set; }
}
2 changes: 0 additions & 2 deletions src/AndreGoepel.AppFoundation/Components/Pages/Setup.razor
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,6 @@
private async Task OnValidSubmit(InputModel model)
{
isProcessing = true;
StateHasChanged();

try
{
Expand Down Expand Up @@ -242,7 +241,6 @@
finally
{
isProcessing = false;
StateHasChanged();
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,7 @@
_ => new ErrorCopy(T("Error.NotFoundTitle"), T("Error.NotFoundMessage")),
};

// Only takes effect during the static-SSR render of the first response for this
// page — once the interactive circuit takes over (or a host reaches this component
// via client-side navigation within an existing circuit), headers are already sent
// and HasStarted guards against writing to a closed response (#128).
// No-op once headers are already sent (interactive circuit / client-side navigation) (#128).
if (HttpContextAccessor.HttpContext is { Response.HasStarted: false } context)
{
context.Response.StatusCode = Code == "403" ? 403 : 404;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,33 +5,19 @@

namespace AndreGoepel.AppFoundation.Resources;

/// <summary>
/// Resolves the AppFoundation UI's strings, tolerating a host that has not registered
/// localization.
/// </summary>
/// <remarks>
/// Pages must not <c>@inject IStringLocalizer&lt;AppFoundationStrings&gt;</c> directly: that
/// is a required injection, so rendering a page throws on any host — or bUnit test — that
/// never called <c>AddAppFoundation</c>. This library ships routable pages that consuming
/// apps render in their own tests, so the failure would land in code the consumer never
/// touched. Same reasoning, and same shape, as <c>IdentityTextExtensions</c> in
/// AndreGoepel.Marten.Identity.Blazor and <c>DesignTextExtensions</c> in
/// AndreGoepel.Design.Blazor.
/// </remarks>
// Resolves the AppFoundation UI's strings, tolerating a host that hasn't registered localization: a required
// IStringLocalizer<AppFoundationStrings> injection would throw on any host (or bUnit test) that never called
// AddAppFoundation, since this library's routable pages render in consumers' own tests (same shape as
// IdentityTextExtensions in AndreGoepel.Marten.Identity.Blazor and DesignTextExtensions in AndreGoepel.Design.Blazor).
internal static class AppFoundationTextExtensions
{
// Same base name the IStringLocalizer path uses, so both routes read one resx pair and
// no English text is duplicated in code.
// Same base name the IStringLocalizer path uses, so both routes read one resx pair.
private static readonly ResourceManager Fallback = new(
typeof(AppFoundationStrings).FullName!,
typeof(AppFoundationStrings).Assembly
);

/// <summary>
/// Looks <paramref name="key"/> up for the current UI culture. Prefers a registered
/// <see cref="IStringLocalizer{T}"/> so a host can substitute one; otherwise reads the
/// embedded resources directly.
/// </summary>
// Prefers a registered IStringLocalizer<T> so a host can substitute one; otherwise reads embedded resources.
internal static string AppFoundationText(this IServiceProvider services, string key)
{
if (services.GetService<IStringLocalizer<AppFoundationStrings>>() is { } localizer)
Expand All @@ -43,12 +29,11 @@ internal static string AppFoundationText(this IServiceProvider services, string
}
}

// CurrentUICulture is what request localization sets per request, so the fallback
// stays culture-aware without any DI involvement.
// CurrentUICulture is set per request, so the fallback stays culture-aware without DI.
return Fallback.GetString(key, CultureInfo.CurrentUICulture) ?? key;
}

/// <inheritdoc cref="AppFoundationText(IServiceProvider, string)"/>
// Same as above, with format arguments applied via string.Format.
internal static string AppFoundationText(
this IServiceProvider services,
string key,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

namespace AndreGoepel.AppFoundation.MailService.Tests;

public class InitializerExtensionTests
public sealed class InitializerExtensionTests
{
[Fact]
public void AddEmailService_RegistersIEmailSender_AsSmtpEmailSender()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ namespace AndreGoepel.AppFoundation.MailService.Tests;
// in the durable store. The two controls are framework-driven (a Wolverine attribute
// and the Configure convention), so these guard that they stay present and correctly
// shaped — a silent removal would re-open the issue without any compile error.
public class MailMessageDurabilityTests
public sealed class MailMessageDurabilityTests
{
[Fact]
public void MailMessage_IsCappedWithDeliverWithin()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

namespace AndreGoepel.AppFoundation.MailService.Tests;

public class MailSettingsProviderTests
public sealed class MailSettingsProviderTests
{
private readonly ISettingsStore store = Substitute.For<ISettingsStore>();
private readonly EphemeralDataProtectionProvider dataProtection = new();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

namespace AndreGoepel.AppFoundation.MailService.Tests;

public class MartenEmailSettingsStoreTests
public sealed class MartenEmailSettingsStoreTests
{
private readonly ISettingsStore store = Substitute.For<ISettingsStore>();
private readonly EphemeralDataProtectionProvider dataProtection = new();
Expand All @@ -31,7 +31,7 @@
store.LoadAsync<EmailSettingsDocument>(Arg.Any<CancellationToken>()).Returns(Document());

// Act
var settings = await BuildStore().LoadAsync();

Check warning on line 34 in tests/AndreGoepel.AppFoundation.MailService.Tests/MartenEmailSettingsStore.Tests.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Calls to methods which accept CancellationToken should use TestContext.Current.CancellationToken to allow test cancellation to be more responsive. (https://xunit.net/xunit.analyzers/rules/xUnit1051)

// Assert
Assert.Equal("DB Sender", settings.SenderName);
Expand All @@ -57,7 +57,7 @@
// Arrange
var emailStore = BuildStore();
EmailSettingsDocument? stored = null;
store.SaveAsync(

Check warning on line 60 in tests/AndreGoepel.AppFoundation.MailService.Tests/MartenEmailSettingsStore.Tests.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Because this call is not awaited, execution of the current method continues before the call is completed. Consider applying the 'await' operator to the result of the call.
Arg.Do<EmailSettingsDocument>(document => stored = document),
Arg.Any<CancellationToken>()
);
Expand Down Expand Up @@ -95,7 +95,7 @@
var existing = Document();
store.LoadAsync<EmailSettingsDocument>(Arg.Any<CancellationToken>()).Returns(existing);
EmailSettingsDocument? stored = null;
store.SaveAsync(

Check warning on line 98 in tests/AndreGoepel.AppFoundation.MailService.Tests/MartenEmailSettingsStore.Tests.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Because this call is not awaited, execution of the current method continues before the call is completed. Consider applying the 'await' operator to the result of the call.
Arg.Do<EmailSettingsDocument>(document => stored = document),
Arg.Any<CancellationToken>()
);
Expand Down
Loading
Loading