diff --git a/src/AndreGoepel.AppFoundation.Hosting/DataProtection/DataProtectionKeyDocument.cs b/src/AndreGoepel.AppFoundation.Hosting/DataProtection/DataProtectionKeyDocument.cs
index 2fadb92..816a446 100644
--- a/src/AndreGoepel.AppFoundation.Hosting/DataProtection/DataProtectionKeyDocument.cs
+++ b/src/AndreGoepel.AppFoundation.Hosting/DataProtection/DataProtectionKeyDocument.cs
@@ -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.
///
-public sealed class DataProtectionKeyDocument
+public sealed record DataProtectionKeyDocument
{
public required string Id { get; init; }
diff --git a/src/AndreGoepel.AppFoundation.Hosting/DataProtection/MartenXmlRepository.cs b/src/AndreGoepel.AppFoundation.Hosting/DataProtection/MartenXmlRepository.cs
index 98e2565..d3d69b6 100644
--- a/src/AndreGoepel.AppFoundation.Hosting/DataProtection/MartenXmlRepository.cs
+++ b/src/AndreGoepel.AppFoundation.Hosting/DataProtection/MartenXmlRepository.cs
@@ -5,13 +5,8 @@
namespace AndreGoepel.AppFoundation.Hosting.DataProtection;
-///
-/// Stores DataProtection key ring entries as Marten documents. The document store
-/// is resolved lazily so the repository can be wired into
-/// KeyManagementOptions before Marten itself is built.
-/// is a synchronous contract, so the async Marten
-/// calls are blocked on — key ring reads/writes are rare, startup-time operations.
-///
+// 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 GetAllElements()
diff --git a/src/AndreGoepel.AppFoundation.Hosting/Initialization.cs b/src/AndreGoepel.AppFoundation.Hosting/Initialization.cs
index 8ae51a9..eb5790f 100644
--- a/src/AndreGoepel.AppFoundation.Hosting/Initialization.cs
+++ b/src/AndreGoepel.AppFoundation.Hosting/Initialization.cs
@@ -46,40 +46,28 @@ public static WebApplicationBuilder AddAppFoundation(
var options = new AppFoundationOptions();
configure?.Invoke(options);
- // Load Docker/Kubernetes secrets (key-per-file) so sensitive configuration —
- // e.g. the connection string — can be supplied as files under the secrets
- // directory instead of plaintext environment variables. No-op when the
- // directory is absent (optional: true), so local development is unaffected.
+ // Docker/Kubernetes secrets (key-per-file); no-op when the directory is absent (e.g. local dev).
if (!string.IsNullOrWhiteSpace(options.SecretsDirectory))
{
builder.Configuration.AddKeyPerFile(options.SecretsDirectory, optional: true);
}
- // Let hosts declare trusted reverse proxies via configuration (environment
- // variables / .env / appsettings) in addition to code, so production proxy
- // CIDRs — unknown at build time — can be supplied at deploy time. Config
- // values augment anything set in the configure callback.
+ // Production proxy CIDRs (unknown at build time) can be supplied via config, augmenting code.
MergeForwardedHeaderConfiguration(builder.Configuration, options);
- // Let hosts declare (or override) the first-run default-role ladder via
- // configuration in addition to code — see AppFoundationOptions.DefaultRoles (#103).
+ // First-run default-role ladder can also be supplied via config (#103).
MergeDefaultRolesConfiguration(builder.Configuration, options);
- // Expose the resolved options to the request-pipeline side (UseAppFoundation),
- // which reads them to configure forwarded headers.
+ // Read by UseAppFoundation to configure forwarded headers.
builder.Services.AddSingleton(options);
- // Setup.razor (in AndreGoepel.AppFoundation, which this project depends on — not
- // the other way around) can't reference AppFoundationOptions without a circular
- // project reference, so the resolved default-role list is exposed separately,
- // typed on the dependency-free DefaultRole record from AppFoundation.Core (#103).
+ // Setup.razor (in AndreGoepel.AppFoundation) can't reference AppFoundationOptions without a circular
+ // project reference, so the resolved roles are exposed via the dependency-free DefaultRole record (#103).
builder.Services.AddSingleton>(
options.DefaultRoles.ToList()
);
- // UseHsts() (below, in UseAppFoundation) reads its HstsOptions from DI, so the
- // hardened default — 365-day max age, includeSubDomains, preload, replacing the
- // framework's 30-day/no-subdomains/no-preload default — is registered here (#124).
+ // Hardened HSTS default (365-day max age, includeSubDomains, preload) replacing the framework's own (#124).
builder.Services.AddHsts(hsts => ConfigureHsts(hsts, options));
builder.AddServiceDefaults();
@@ -87,10 +75,7 @@ public static WebApplicationBuilder AddAppFoundation(
builder.Services.AddMartenIdentity();
builder.Services.AddMartenIdentityBlazor(identity =>
{
- // AppFoundation default: self-service registration is off unless a host opts
- // in (#49) — two-factor and passkeys stay on. Applied before the host's
- // callback so it can override, and an administrator can still toggle any flag
- // at runtime on the Login Features page.
+ // AppFoundation default: self-service registration off unless a host opts in; 2FA/passkeys stay on (#49).
identity.EnableUserRegistration = false;
options.ConfigureIdentity?.Invoke(identity);
});
@@ -102,47 +87,27 @@ public static WebApplicationBuilder AddAppFoundation(
$"Connection string '{options.DatabaseConnectionName}' not found."
);
- // Never let the running app drop/rewrite schema to match code: default to
- // additive-only (CreateOrUpdate) outside Development, keeping the permissive All
- // only for the local inner loop. A host can override — e.g. AutoCreate.None for a
- // least-privilege role with schema applied out-of-band (#53). Shared with Quartz's
- // qrtz_ provisioning below, so both follow the same posture.
+ // Default to additive-only (CreateOrUpdate) outside Development; a host can override, e.g. AutoCreate.None
+ // for a least-privilege role with schema applied out-of-band. Shared with Quartz's provisioning below (#53).
var schemaCreation =
options.SchemaCreation
?? (builder.Environment.IsDevelopment() ? AutoCreate.All : AutoCreate.CreateOrUpdate);
- // AddMartenIdentityCleanup() (above) already called services.AddQuartz(...) to
- // register its cleanup job/trigger on the default in-memory RAMJobStore — schedules
- // and misfire state don't survive restarts. This second AddQuartz call merges into
- // the same QuartzOptions and layers a PostgreSQL-backed persistent store on top, so
- // identity's job registration keeps working unchanged while gaining durability, and
- // host apps can hang their own recurring jobs on the same scheduler via their own
- // AddQuartz call (#129). This call only configures the store — it must not
- // re-register jobs/triggers or call AddQuartzHostedService a second time. Pure
- // configuration, no I/O — the qrtz_ schema is provisioned separately in
- // UseAppFoundation (see QuartzSchemaProvisioner), not here: AddAppFoundation must
- // stay side-effect-free against the connection string, the same as AddMarten below,
- // so it can be exercised in tests with a connection string that never actually
- // resolves.
+ // Merges into the same QuartzOptions as AddMartenIdentityCleanup's own AddQuartz call, layering a
+ // Postgres-backed persistent store on top of its RAMJobStore registration. Configuration only — no I/O,
+ // no re-registering jobs — so AddAppFoundation stays testable without a reachable database; the qrtz_
+ // schema itself is provisioned later in UseAppFoundation (#129).
builder.Services.AddQuartz(quartz =>
{
quartz.UsePersistentStore(store =>
{
store.UsePostgres(connectionString);
- // Postgres folds unquoted identifiers to lowercase; the vendored schema
- // creates lowercase qrtz_* tables, so the prefix must be set explicitly —
- // Quartz's own default ("QRTZ_") would 404 every query.
+ // Postgres folds unquoted identifiers to lowercase; Quartz's own default ("QRTZ_") would 404.
store.SetProperty("quartz.jobStore.tablePrefix", "qrtz_");
- // Quartz's default serializer (BinaryObjectSerializer) uses BinaryFormatter,
- // which throws on .NET 8+ (removed for security reasons) — required, not
- // optional, for a persistent store to work at all here.
+ // Quartz's default BinaryObjectSerializer uses BinaryFormatter, removed in .NET 8+.
store.UseSystemTextJsonSerializer();
-
- // Clustering intentionally not enabled (single instance) — out of scope
- // per #129; PerformSchemaValidation is left at Quartz's own default (true),
- // a free fail-fast if provisioning above was skipped or failed.
});
});
@@ -157,17 +122,14 @@ public static WebApplicationBuilder AddAppFoundation(
marten.AutoCreateSchemaObjects = schemaCreation;
- // The alias (and thus the table name) is part of the storage
- // contract — hosts that persisted key ring entries with an
- // identically-shaped document keep their keys on upgrade.
+ // The alias (and table name) is part of the storage contract — existing key ring rows must
+ // resolve under the same name on upgrade.
marten
.Schema.For()
.DocumentAlias("dataprotectionkeydocument");
- // Every admin-configured settings record shares one table (see
- // AndreGoepel.Marten.Configuration's SettingsDocument) instead of each type
- // getting its own one-row table. Consuming apps register their own settings
- // types the same way, via AddSettingsDocument().
+ // Every admin-configured settings record shares one table; consuming apps register their own
+ // via AddSettingsDocument().
marten.AddSettingsDocument();
})
.IntegrateWithWolverine();
@@ -186,9 +148,7 @@ public static WebApplicationBuilder AddAppFoundation(
wolverine.Discovery.IncludeAssembly(typeof(SendEmailMessageHandler).Assembly);
- // Consuming apps contribute Wolverine setup here — the host owns the
- // one allowed UseWolverine call. Typically opting handler assemblies
- // into discovery. Runs inside the UseWolverine lambda so it is applied
+ // The host owns the one allowed UseWolverine call; this runs inside it so config is applied
// deterministically before handler discovery.
options.ConfigureWolverine?.Invoke(wolverine);
});
@@ -199,11 +159,8 @@ public static WebApplicationBuilder AddAppFoundation(
builder.Services.AddRadzenComponents();
- // AddMartenIdentityBlazor (above) already seeds DesignBlazorOptions.BrandName from
- // MartenIdentityBlazorOptions.ApplicationName. Registering our own Configure here —
- // after that call — runs later in the options pipeline and wins, so the dashboard,
- // login, and account pages all share one brand sourced from the host's
- // AppFoundationLayoutOptions.BrandName instead of two independently configured names.
+ // AddMartenIdentityBlazor already seeds DesignBlazorOptions.BrandName from ApplicationName; configuring
+ // here runs later and wins, so dashboard/login/account pages share AppFoundationLayoutOptions.BrandName.
builder
.Services.AddDesignBlazor()
.AddOptions()
@@ -216,14 +173,8 @@ public static WebApplicationBuilder AddAppFoundation(
return builder;
}
- ///
- /// DataProtection with a durable key ring: keys are persisted in Postgres via
- /// Marten (surviving container rebuilds) and — when a certificate is
- /// configured — encrypted at rest, so a database dump alone cannot decrypt
- /// IDataProtector-protected payloads. Without
- /// DataProtection:CertificatePath (e.g. local development) keys are
- /// stored unencrypted and ASP.NET Core logs its at-rest warning.
- ///
+ // Keys persist in Postgres via Marten and are encrypted at rest when a certificate is configured; without
+ // DataProtection:CertificatePath (e.g. local dev) keys are stored unencrypted and ASP.NET Core logs a warning.
private static void AddDataProtection(
WebApplicationBuilder builder,
AppFoundationOptions options
@@ -262,13 +213,8 @@ public static WebApplication UseAppFoundation(this WebApplication app)
var options = app.Services.GetRequiredService();
- // Idempotently provision Quartz's qrtz_ tables — mirrors Marten's own schema-creation
- // posture (skipped under AutoCreate.None, for out-of-band-provisioned deployments),
- // and must run here rather than in AddAppFoundation: it's the one real database
- // side effect this seam owns, and AddAppFoundation stays side-effect-free against
- // the connection string so it can be exercised in tests without a reachable
- // database. Runs before app.Run() starts Quartz's own hosted service, which queries
- // these tables as soon as it starts (#129).
+ // Idempotent qrtz_ provisioning must happen here, not in AddAppFoundation, which stays side-effect-free
+ // against the connection string so it's testable without a reachable database (#129).
var connectionString =
app.Configuration.GetConnectionString(options.DatabaseConnectionName)
?? throw new InvalidOperationException(
@@ -283,9 +229,6 @@ public static WebApplication UseAppFoundation(this WebApplication app)
QuartzSchemaProvisioner.Provision(connectionString);
}
- // Fail closed if the key ring would be persisted unencrypted in a non-local
- // environment: the keys live in the same Postgres as the data they protect,
- // so a database dump must not also yield the keys (#54).
EnsureKeyRingProtected(
app.Environment.IsDevelopment(),
options.AllowUnprotectedKeyRing,
@@ -322,9 +265,6 @@ public static WebApplication UseAppFoundation(this WebApplication app)
app.UseExceptionHandler("/Error", createScopeForErrors: true);
app.UseHsts();
- // X-Content-Type-Options / Referrer-Policy / Permissions-Policy are absent
- // from the framework's own defaults, so nothing else in the pipeline sets
- // them (#124).
app.Use(
(context, next) =>
{
@@ -334,13 +274,9 @@ public static WebApplication UseAppFoundation(this WebApplication app)
);
}
- // Requests that match no endpoint at all (hard 404s) never reach the Blazor
- // router, so re-execute them against the designed not-found page, passing the
- // original status code so 403s render their own copy. Interactive navigations
- // to unknown routes are handled by the Router's NotFoundPage. The re-execution
- // needs its own DI scope: when the original request already rendered a Razor
- // component (e.g. a host page that set a 4xx status), re-rendering in the same
- // scope throws "'RemoteNavigationManager' already initialized".
+ // 404s that match no endpoint never reach the Blazor router, so re-execute them against /not-found; needs
+ // its own DI scope because re-rendering in the original scope throws on an already-initialized
+ // RemoteNavigationManager.
app.UseStatusCodePagesWithReExecute(
"/not-found",
"?code={0}",
@@ -351,12 +287,8 @@ public static WebApplication UseAppFoundation(this WebApplication app)
app.UseStaticFiles();
app.UseHeaderPropagation();
- // Resolves the request culture (cookie -> Accept-Language -> default) and maps the
- // culture-switch endpoint LanguageSwitcher links to. Must run before anything that
- // renders user-facing text — that includes the identity middlewares below, which
- // redirect to localized pages, and MapRazorComponents (called by the host after this
- // method returns), because a Blazor Server circuit takes its culture from the request
- // that establishes it.
+ // Must run before anything that renders user-facing text — identity middleware below, and
+ // MapRazorComponents — since a Blazor Server circuit takes its culture from the request that creates it.
app.UseDesignBlazorLocalization();
app.UseAntiforgery();
@@ -365,30 +297,14 @@ public static WebApplication UseAppFoundation(this WebApplication app)
app.UseMartenIdentityMiddleware();
- // Enforce the identity feature flags (registration / 2FA / passkeys) at the
- // request level: a disabled feature's pages and endpoints are unreachable by
- // direct URL, not merely hidden in the nav menu.
+ // Disabled identity features (registration/2FA/passkeys) are unreachable by direct URL, not just hidden.
app.UseMartenIdentityFeatureGate();
return app;
}
- ///
- /// Builds the forwarded-headers trust configuration. Honors X-Forwarded-For
- /// and X-Forwarded-Proto, but only from trusted origins: the configured
- /// proxy networks/proxies when supplied; otherwise every origin in Development
- /// (local convenience) and only the framework default (loopback) elsewhere, so
- /// arbitrary clients cannot spoof the client IP or scheme in production (#51).
- ///
- ///
- /// Throws when the DataProtection key ring would be stored without at-rest
- /// encryption outside Development, unless the host has explicitly accepted that
- /// via .
- /// is the resolved
- /// : non-null whenever key
- /// encryption is configured (certificate, Key Vault, KMS, …), so this reflects the
- /// actual end state regardless of how protection was wired.
- ///
+ // Throws unless the key ring is encrypted (or AllowUnprotectedKeyRing is set) outside Development — a DB
+ // dump must not also yield the keys protecting the SMTP password, login tokens, and auth cookies (#54).
internal static void EnsureKeyRingProtected(
bool isDevelopment,
bool allowUnprotectedKeyRing,
@@ -411,14 +327,8 @@ internal static void EnsureKeyRingProtected(
);
}
- ///
- /// Merges reverse-proxy trust configured under AppFoundation:KnownProxyNetworks
- /// and AppFoundation:KnownProxies into . Each key
- /// accepts either a delimited scalar ("172.28.0.0/16, 10.0.0.0/8" — friendly
- /// for a single environment variable / .env) or a configuration array, so the
- /// production proxy CIDRs can be supplied at deploy time without a code change.
- /// Values augment (and de-duplicate against) any set in code.
- ///
+ // Merges AppFoundation:KnownProxyNetworks/KnownProxies from config (delimited scalar or array) into options,
+ // augmenting whatever's set in code.
internal static void MergeForwardedHeaderConfiguration(
IConfiguration configuration,
AppFoundationOptions options
@@ -452,9 +362,8 @@ static IEnumerable ReadDelimitedOrArray(IConfiguration configuration, st
.Select(value => value!.Trim());
}
- // Scalar / delimited form (a single environment variable / .env entry).
- // Split on comma/semicolon/whitespace only — never ':' — so IPv6 CIDRs
- // such as fd00::/8 stay intact.
+ // Scalar/delimited form (single env var/.env entry); split on comma/semicolon/whitespace only,
+ // never ':', so IPv6 CIDRs like fd00::/8 stay intact.
return section.Value is { Length: > 0 } scalar
? scalar.Split(
[',', ';', ' ', '\t', '\r', '\n'],
@@ -464,12 +373,8 @@ static IEnumerable ReadDelimitedOrArray(IConfiguration configuration, st
}
}
- ///
- /// Merges the first-run default-role ladder configured under
- /// AppFoundation:DefaultRoles into , so a production
- /// role list — unknown at build time — can be supplied at deploy time. Roles already
- /// present in code (matched by name) are left as-is; config only adds new entries (#103).
- ///
+ // Merges AppFoundation:DefaultRoles from config into options.DefaultRoles; roles already present by name
+ // are left as-is (#103).
internal static void MergeDefaultRolesConfiguration(
IConfiguration configuration,
AppFoundationOptions options
@@ -487,6 +392,8 @@ AppFoundationOptions options
}
}
+ // Trusts X-Forwarded-For/Proto only from configured proxies, or any origin in Development; otherwise keeps
+ // the framework's loopback-only default so arbitrary clients can't spoof the client IP/scheme (#51).
internal static ForwardedHeadersOptions BuildForwardedHeadersOptions(
AppFoundationOptions options,
bool isDevelopment
@@ -499,7 +406,6 @@ bool isDevelopment
if (options.KnownProxyNetworks.Count > 0 || options.KnownProxies.Count > 0)
{
- // Trust exactly the configured reverse proxies — and nothing else.
forwardedOptions.KnownIPNetworks.Clear();
forwardedOptions.KnownProxies.Clear();
foreach (var network in options.KnownProxyNetworks)
@@ -513,23 +419,14 @@ bool isDevelopment
}
else if (isDevelopment)
{
- // Local development: no proxy in front, so accept forwarded headers from
- // any origin for convenience.
forwardedOptions.KnownIPNetworks.Clear();
forwardedOptions.KnownProxies.Clear();
}
- // Otherwise keep the framework defaults (loopback only): without a configured
- // proxy, arbitrary clients must not be able to spoof X-Forwarded-* headers.
return forwardedOptions;
}
- ///
- /// Applies the foundation's hardened HSTS defaults —
- /// of 365 days, and
- /// both true — then lets
- /// override them (#124).
- ///
+ // Hardened HSTS defaults (365-day max age, includeSubDomains, preload) before the host's own override (#124).
internal static void ConfigureHsts(HstsOptions hsts, AppFoundationOptions options)
{
hsts.MaxAge = TimeSpan.FromDays(365);
@@ -539,13 +436,8 @@ internal static void ConfigureHsts(HstsOptions hsts, AppFoundationOptions option
options.ConfigureHsts?.Invoke(hsts);
}
- ///
- /// Sets the security response headers the ASP.NET Core framework leaves absent by
- /// default: X-Content-Type-Options: nosniff unconditionally, and
- /// Referrer-Policy / Permissions-Policy from
- /// unless a host has cleared them to
- /// null/empty (#124).
- ///
+ // Sets security headers the framework leaves absent by default; Referrer-Policy/Permissions-Policy only
+ // when configured, not cleared to null (#124).
internal static void ApplySecurityHeaders(
IHeaderDictionary headers,
AppFoundationOptions options
diff --git a/src/AndreGoepel.AppFoundation.Hosting/Quartz/QuartzSchemaProvisioner.cs b/src/AndreGoepel.AppFoundation.Hosting/Quartz/QuartzSchemaProvisioner.cs
index eeb4a22..3593b73 100644
--- a/src/AndreGoepel.AppFoundation.Hosting/Quartz/QuartzSchemaProvisioner.cs
+++ b/src/AndreGoepel.AppFoundation.Hosting/Quartz/QuartzSchemaProvisioner.cs
@@ -4,32 +4,18 @@
namespace AndreGoepel.AppFoundation.Hosting.Quartz;
-///
-/// Idempotently provisions Quartz's PostgreSQL job-store schema (qrtz_* 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
-/// () skips this too (#129).
-///
+// 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";
- ///
- /// Whether the schema should be provisioned for the given (already-resolved)
- /// mode — the same mode AddAppFoundation passes to
- /// Marten's AutoCreateSchemaObjects.
- ///
internal static bool ShouldProvision(AutoCreate schemaCreation) =>
schemaCreation != AutoCreate.None;
- ///
- /// Runs the vendored, idempotent DDL script against .
- /// Synchronous and blocking by design: it runs once, during AddAppFoundation,
- /// before WebApplicationBuilder.Build() — well before Quartz's own hosted service
- /// starts and queries these tables, so there is no ordering-dependent async startup step
- /// to get wrong.
- ///
+ // 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);
diff --git a/src/AndreGoepel.AppFoundation.MailService/MailConfiguration.cs b/src/AndreGoepel.AppFoundation.MailService/MailConfiguration.cs
index b7cf821..293005c 100644
--- a/src/AndreGoepel.AppFoundation.MailService/MailConfiguration.cs
+++ b/src/AndreGoepel.AppFoundation.MailService/MailConfiguration.cs
@@ -1,6 +1,6 @@
namespace AndreGoepel.AppFoundation.MailService;
-public record MailConfiguration
+public sealed record MailConfiguration
{
public string SenderName { get; init; } = "";
diff --git a/src/AndreGoepel.AppFoundation.MailService/MailMessage.cs b/src/AndreGoepel.AppFoundation.MailService/MailMessage.cs
index 13c5473..dbccf6c 100644
--- a/src/AndreGoepel.AppFoundation.MailService/MailMessage.cs
+++ b/src/AndreGoepel.AppFoundation.MailService/MailMessage.cs
@@ -10,13 +10,8 @@ namespace AndreGoepel.AppFoundation.MailService;
/// token-bearing row in the durable store (#55).
///
[DeliverWithin(DeliveryWindowSeconds)]
-public record MailMessage(string Recipient, string Subject, string Body)
+public sealed record MailMessage(string Recipient, string Subject, string Body)
{
- ///
- /// 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.
- ///
+ // One hour is far beyond normal delivery, so it only sheds messages stuck across an outage.
internal const int DeliveryWindowSeconds = 3600;
}
diff --git a/src/AndreGoepel.AppFoundation.MailService/SendEmailMessageHandler.cs b/src/AndreGoepel.AppFoundation.MailService/SendEmailMessageHandler.cs
index d161814..5545b9c 100644
--- a/src/AndreGoepel.AppFoundation.MailService/SendEmailMessageHandler.cs
+++ b/src/AndreGoepel.AppFoundation.MailService/SendEmailMessageHandler.cs
@@ -7,7 +7,7 @@
namespace AndreGoepel.AppFoundation.MailService;
[WolverineHandler]
-public class SendEmailMessageHandler(
+public sealed class SendEmailMessageHandler(
IEmailSender EmailSender,
ILogger Logger
)
@@ -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(
@@ -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
+ );
}
- ///
- /// A MailMessage is trusted only when published in-process: Wolverine routes such
- /// messages to a local:// 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.
- ///
+ // 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";
}
diff --git a/src/AndreGoepel.AppFoundation.ServiceDefaults/Extensions.cs b/src/AndreGoepel.AppFoundation.ServiceDefaults/Extensions.cs
index 2f562d6..7843649 100644
--- a/src/AndreGoepel.AppFoundation.ServiceDefaults/Extensions.cs
+++ b/src/AndreGoepel.AppFoundation.ServiceDefaults/Extensions.cs
@@ -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";
@@ -28,19 +25,10 @@ public static TBuilder AddServiceDefaults(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(options =>
- // {
- // options.AllowedSchemes = ["https"];
- // });
-
return builder;
}
@@ -72,8 +60,6 @@ public static TBuilder ConfigureOpenTelemetry(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();
});
@@ -94,13 +80,6 @@ private static TBuilder AddOpenTelemetryExporters(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;
}
@@ -109,7 +88,6 @@ public static TBuilder AddDefaultHealthChecks(this TBuilder builder)
{
builder
.Services.AddHealthChecks()
- // Add a default liveness check to ensure app is responsive
.AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]);
return builder;
@@ -117,14 +95,11 @@ public static TBuilder AddDefaultHealthChecks(this TBuilder 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") }
diff --git a/src/AndreGoepel.AppFoundation/Components/Administration/Pages/EmailSettingsPage.razor b/src/AndreGoepel.AppFoundation/Components/Administration/Pages/EmailSettingsPage.razor
index 229b88f..67bb141 100644
--- a/src/AndreGoepel.AppFoundation/Components/Administration/Pages/EmailSettingsPage.razor
+++ b/src/AndreGoepel.AppFoundation/Components/Administration/Pages/EmailSettingsPage.razor
@@ -138,7 +138,6 @@
};
}
- // Discard: reload the persisted settings, dropping any unsaved edits.
private Task OnDiscard() => LoadSettingsAsync();
private async Task OnValidSubmit(InputModel model)
diff --git a/src/AndreGoepel.AppFoundation/Components/Layout/MainLayout.razor b/src/AndreGoepel.AppFoundation/Components/Layout/MainLayout.razor
index 85a47fe..fa641fd 100644
--- a/src/AndreGoepel.AppFoundation/Components/Layout/MainLayout.razor
+++ b/src/AndreGoepel.AppFoundation/Components/Layout/MainLayout.razor
@@ -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()
@@ -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))
diff --git a/src/AndreGoepel.AppFoundation/Components/Pages/NotFound.razor b/src/AndreGoepel.AppFoundation/Components/Pages/NotFound.razor
index ec07cc4..9b5e752 100644
--- a/src/AndreGoepel.AppFoundation/Components/Pages/NotFound.razor
+++ b/src/AndreGoepel.AppFoundation/Components/Pages/NotFound.razor
@@ -4,8 +4,7 @@
@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; }
}
diff --git a/src/AndreGoepel.AppFoundation/Components/Pages/Setup.razor b/src/AndreGoepel.AppFoundation/Components/Pages/Setup.razor
index a5b209e..4040807 100644
--- a/src/AndreGoepel.AppFoundation/Components/Pages/Setup.razor
+++ b/src/AndreGoepel.AppFoundation/Components/Pages/Setup.razor
@@ -94,7 +94,6 @@
private async Task OnValidSubmit(InputModel model)
{
isProcessing = true;
- StateHasChanged();
try
{
@@ -242,7 +241,6 @@
finally
{
isProcessing = false;
- StateHasChanged();
}
}
diff --git a/src/AndreGoepel.AppFoundation/Components/Shared/ErrorPage.razor b/src/AndreGoepel.AppFoundation/Components/Shared/ErrorPage.razor
index 06ff2ff..15a507e 100644
--- a/src/AndreGoepel.AppFoundation/Components/Shared/ErrorPage.razor
+++ b/src/AndreGoepel.AppFoundation/Components/Shared/ErrorPage.razor
@@ -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;
diff --git a/src/AndreGoepel.AppFoundation/Resources/AppFoundationTextExtensions.cs b/src/AndreGoepel.AppFoundation/Resources/AppFoundationTextExtensions.cs
index 245fa5d..7ee6c22 100644
--- a/src/AndreGoepel.AppFoundation/Resources/AppFoundationTextExtensions.cs
+++ b/src/AndreGoepel.AppFoundation/Resources/AppFoundationTextExtensions.cs
@@ -5,33 +5,19 @@
namespace AndreGoepel.AppFoundation.Resources;
-///
-/// Resolves the AppFoundation UI's strings, tolerating a host that has not registered
-/// localization.
-///
-///
-/// Pages must not @inject IStringLocalizer<AppFoundationStrings> directly: that
-/// is a required injection, so rendering a page throws on any host — or bUnit test — that
-/// never called AddAppFoundation. 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 IdentityTextExtensions in
-/// AndreGoepel.Marten.Identity.Blazor and DesignTextExtensions in
-/// AndreGoepel.Design.Blazor.
-///
+// Resolves the AppFoundation UI's strings, tolerating a host that hasn't registered localization: a required
+// IStringLocalizer 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
);
- ///
- /// Looks up for the current UI culture. Prefers a registered
- /// so a host can substitute one; otherwise reads the
- /// embedded resources directly.
- ///
+ // Prefers a registered IStringLocalizer so a host can substitute one; otherwise reads embedded resources.
internal static string AppFoundationText(this IServiceProvider services, string key)
{
if (services.GetService>() is { } localizer)
@@ -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;
}
- ///
+ // Same as above, with format arguments applied via string.Format.
internal static string AppFoundationText(
this IServiceProvider services,
string key,
diff --git a/tests/AndreGoepel.AppFoundation.MailService.Tests/InitializerExtension.Tests.cs b/tests/AndreGoepel.AppFoundation.MailService.Tests/InitializerExtension.Tests.cs
index b0f2d10..e9f7d53 100644
--- a/tests/AndreGoepel.AppFoundation.MailService.Tests/InitializerExtension.Tests.cs
+++ b/tests/AndreGoepel.AppFoundation.MailService.Tests/InitializerExtension.Tests.cs
@@ -4,7 +4,7 @@
namespace AndreGoepel.AppFoundation.MailService.Tests;
-public class InitializerExtensionTests
+public sealed class InitializerExtensionTests
{
[Fact]
public void AddEmailService_RegistersIEmailSender_AsSmtpEmailSender()
diff --git a/tests/AndreGoepel.AppFoundation.MailService.Tests/MailMessageDurabilityTests.cs b/tests/AndreGoepel.AppFoundation.MailService.Tests/MailMessageDurabilityTests.cs
index 047f71b..4f65976 100644
--- a/tests/AndreGoepel.AppFoundation.MailService.Tests/MailMessageDurabilityTests.cs
+++ b/tests/AndreGoepel.AppFoundation.MailService.Tests/MailMessageDurabilityTests.cs
@@ -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()
diff --git a/tests/AndreGoepel.AppFoundation.MailService.Tests/MailSettingsProvider.Tests.cs b/tests/AndreGoepel.AppFoundation.MailService.Tests/MailSettingsProvider.Tests.cs
index 7e6a049..a417d4b 100644
--- a/tests/AndreGoepel.AppFoundation.MailService.Tests/MailSettingsProvider.Tests.cs
+++ b/tests/AndreGoepel.AppFoundation.MailService.Tests/MailSettingsProvider.Tests.cs
@@ -4,7 +4,7 @@
namespace AndreGoepel.AppFoundation.MailService.Tests;
-public class MailSettingsProviderTests
+public sealed class MailSettingsProviderTests
{
private readonly ISettingsStore store = Substitute.For();
private readonly EphemeralDataProtectionProvider dataProtection = new();
diff --git a/tests/AndreGoepel.AppFoundation.MailService.Tests/MartenEmailSettingsStore.Tests.cs b/tests/AndreGoepel.AppFoundation.MailService.Tests/MartenEmailSettingsStore.Tests.cs
index 281efad..28a180f 100644
--- a/tests/AndreGoepel.AppFoundation.MailService.Tests/MartenEmailSettingsStore.Tests.cs
+++ b/tests/AndreGoepel.AppFoundation.MailService.Tests/MartenEmailSettingsStore.Tests.cs
@@ -4,7 +4,7 @@
namespace AndreGoepel.AppFoundation.MailService.Tests;
-public class MartenEmailSettingsStoreTests
+public sealed class MartenEmailSettingsStoreTests
{
private readonly ISettingsStore store = Substitute.For();
private readonly EphemeralDataProtectionProvider dataProtection = new();
diff --git a/tests/AndreGoepel.AppFoundation.MailService.Tests/SendEmailMessageHandler.Tests.cs b/tests/AndreGoepel.AppFoundation.MailService.Tests/SendEmailMessageHandler.Tests.cs
index 5a722d0..cd4e767 100644
--- a/tests/AndreGoepel.AppFoundation.MailService.Tests/SendEmailMessageHandler.Tests.cs
+++ b/tests/AndreGoepel.AppFoundation.MailService.Tests/SendEmailMessageHandler.Tests.cs
@@ -5,7 +5,7 @@
namespace AndreGoepel.AppFoundation.MailService.Tests;
-public class SendEmailMessageHandlerTests
+public sealed class SendEmailMessageHandlerTests
{
[Fact]
public async Task Handle_LocalOrigin_ForwardsAllFieldsToEmailSender()
@@ -17,12 +17,13 @@ public async Task Handle_LocalOrigin_ForwardsAllFieldsToEmailSender()
NullLogger.Instance
);
var message = new MailMessage("bob@example.com", "Hello", "World");
+ using var cts = new CancellationTokenSource();
// Act
- await handler.Handle(message, LocalEnvelope(message));
+ await handler.Handle(message, LocalEnvelope(message), cts.Token);
// Assert
- await sender.Received(1).SendAsync("bob@example.com", "Hello", "World");
+ await sender.Received(1).SendAsync("bob@example.com", "Hello", "World", cts.Token);
}
[Fact]
@@ -37,7 +38,7 @@ public async Task Handle_LocalOrigin_DelegatesToEmailSender_ExactlyOnce()
var message = new MailMessage("a@b.com", "s", "b");
// Act
- await handler.Handle(message, LocalEnvelope(message));
+ await handler.Handle(message, LocalEnvelope(message), CancellationToken.None);
// Assert
await sender.ReceivedWithAnyArgs(1).SendAsync(default!, default!, default!);
@@ -56,7 +57,7 @@ public async Task Handle_ExternalOrigin_DropsMessageWithoutSending()
var external = new Envelope(message) { Destination = new Uri("rabbitmq://queue/mail") };
// Act
- await handler.Handle(message, external);
+ await handler.Handle(message, external, CancellationToken.None);
// Assert — nothing was sent.
await sender.DidNotReceiveWithAnyArgs().SendAsync(default!, default!, default!);
diff --git a/tests/AndreGoepel.AppFoundation.MailService.Tests/SmtpEmailSender.Tests.cs b/tests/AndreGoepel.AppFoundation.MailService.Tests/SmtpEmailSender.Tests.cs
index 354d637..dfd02e7 100644
--- a/tests/AndreGoepel.AppFoundation.MailService.Tests/SmtpEmailSender.Tests.cs
+++ b/tests/AndreGoepel.AppFoundation.MailService.Tests/SmtpEmailSender.Tests.cs
@@ -2,7 +2,7 @@
namespace AndreGoepel.AppFoundation.MailService.Tests;
-public class SmtpEmailSenderTests
+public sealed class SmtpEmailSenderTests
{
private static MailConfiguration Config(bool html = true) =>
new()
diff --git a/tests/AndreGoepel.AppFoundation.Tests/Account/IdentityEmailSender.Tests.cs b/tests/AndreGoepel.AppFoundation.Tests/Account/IdentityEmailSender.Tests.cs
index 4ff8e6f..f4abaf8 100644
--- a/tests/AndreGoepel.AppFoundation.Tests/Account/IdentityEmailSender.Tests.cs
+++ b/tests/AndreGoepel.AppFoundation.Tests/Account/IdentityEmailSender.Tests.cs
@@ -6,7 +6,7 @@
namespace AndreGoepel.AppFoundation.Tests.Account;
-public class IdentityEmailSenderTests
+public sealed class IdentityEmailSenderTests
{
private static User AnyUser() => new() { UserName = "alice@example.com" };
diff --git a/tests/AndreGoepel.AppFoundation.Tests/AppFoundationLayoutOptions.Tests.cs b/tests/AndreGoepel.AppFoundation.Tests/AppFoundationLayoutOptions.Tests.cs
index 6b31728..6fffb78 100644
--- a/tests/AndreGoepel.AppFoundation.Tests/AppFoundationLayoutOptions.Tests.cs
+++ b/tests/AndreGoepel.AppFoundation.Tests/AppFoundationLayoutOptions.Tests.cs
@@ -2,7 +2,7 @@
namespace AndreGoepel.AppFoundation.Tests;
-public class AppFoundationLayoutOptionsTests
+public sealed class AppFoundationLayoutOptionsTests
{
private sealed class SampleMenu { }
diff --git a/tests/AndreGoepel.AppFoundation.Tests/Components/Administration/EmailSettingsPage.Localization.Tests.cs b/tests/AndreGoepel.AppFoundation.Tests/Components/Administration/EmailSettingsPage.Localization.Tests.cs
index a42ad49..ef538e4 100644
--- a/tests/AndreGoepel.AppFoundation.Tests/Components/Administration/EmailSettingsPage.Localization.Tests.cs
+++ b/tests/AndreGoepel.AppFoundation.Tests/Components/Administration/EmailSettingsPage.Localization.Tests.cs
@@ -8,7 +8,7 @@
namespace AndreGoepel.AppFoundation.Tests.Components.Administration;
-public class EmailSettingsPageLocalizationTests : BunitContext
+public sealed class EmailSettingsPageLocalizationTests : BunitContext
{
private readonly IEmailSettingsStore store = Substitute.For();
private readonly IEmailSender emailSender = Substitute.For();
@@ -37,12 +37,15 @@ public EmailSettingsPageLocalizationTests()
[Fact]
public void Render_German_ShowsGermanCopy()
{
+ // Arrange
var original = CultureInfo.CurrentUICulture;
CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("de");
try
{
+ // Act
var cut = Render();
+ // Assert
Assert.Contains("E-Mail-Einstellungen", cut.Markup);
Assert.Contains("Absendername", cut.Markup);
Assert.Contains("Änderungen speichern", cut.Markup);
diff --git a/tests/AndreGoepel.AppFoundation.Tests/Components/Administration/EmailSettingsPage.Tests.cs b/tests/AndreGoepel.AppFoundation.Tests/Components/Administration/EmailSettingsPage.Tests.cs
index d84ad66..8bec6a2 100644
--- a/tests/AndreGoepel.AppFoundation.Tests/Components/Administration/EmailSettingsPage.Tests.cs
+++ b/tests/AndreGoepel.AppFoundation.Tests/Components/Administration/EmailSettingsPage.Tests.cs
@@ -9,7 +9,7 @@
namespace AndreGoepel.AppFoundation.Tests.Components.Administration;
-public class EmailSettingsPageTests : BunitContext
+public sealed class EmailSettingsPageTests : BunitContext
{
private readonly IEmailSettingsStore store = Substitute.For();
private readonly IEmailSender emailSender = Substitute.For();
diff --git a/tests/AndreGoepel.AppFoundation.Tests/Components/ErrorComponentContext.cs b/tests/AndreGoepel.AppFoundation.Tests/Components/ErrorComponentContext.cs
index bcf49f3..46a12aa 100644
--- a/tests/AndreGoepel.AppFoundation.Tests/Components/ErrorComponentContext.cs
+++ b/tests/AndreGoepel.AppFoundation.Tests/Components/ErrorComponentContext.cs
@@ -28,7 +28,7 @@ public ValueTask InvokeAsync(string identifier, object?[]? args)
return new ValueTask((TValue)state!);
}
- return new ValueTask(default(TValue)!);
+ return new ValueTask(result: default!);
}
public ValueTask InvokeAsync(
diff --git a/tests/AndreGoepel.AppFoundation.Tests/Components/Layout/ReconnectModal.Localization.Tests.cs b/tests/AndreGoepel.AppFoundation.Tests/Components/Layout/ReconnectModal.Localization.Tests.cs
index bb40b17..d87e20b 100644
--- a/tests/AndreGoepel.AppFoundation.Tests/Components/Layout/ReconnectModal.Localization.Tests.cs
+++ b/tests/AndreGoepel.AppFoundation.Tests/Components/Layout/ReconnectModal.Localization.Tests.cs
@@ -4,13 +4,15 @@
namespace AndreGoepel.AppFoundation.Tests.Components.Layout;
-public class ReconnectModalLocalizationTests : BunitContext
+public sealed class ReconnectModalLocalizationTests : BunitContext
{
[Fact]
public void Render_English_ShowsEnglishCopy()
{
+ // Arrange / Act
var cut = Render();
+ // Assert
Assert.Contains("Rejoining the server...", cut.Markup);
Assert.Contains("Retry", cut.Markup);
}
@@ -18,12 +20,15 @@ public void Render_English_ShowsEnglishCopy()
[Fact]
public void Render_German_ShowsGermanCopy()
{
+ // Arrange
var original = CultureInfo.CurrentUICulture;
CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("de");
try
{
+ // Act
var cut = Render();
+ // Assert
Assert.Contains("Verbindung zum Server wird wiederhergestellt", cut.Markup);
Assert.Contains("Erneut versuchen", cut.Markup);
}
diff --git a/tests/AndreGoepel.AppFoundation.Tests/Components/Pages/ErrorLocalization.Tests.cs b/tests/AndreGoepel.AppFoundation.Tests/Components/Pages/ErrorLocalization.Tests.cs
index a54f202..31c748d 100644
--- a/tests/AndreGoepel.AppFoundation.Tests/Components/Pages/ErrorLocalization.Tests.cs
+++ b/tests/AndreGoepel.AppFoundation.Tests/Components/Pages/ErrorLocalization.Tests.cs
@@ -4,13 +4,15 @@
namespace AndreGoepel.AppFoundation.Tests.Components.Pages;
-public class ErrorLocalizationTests : BunitContext
+public sealed class ErrorLocalizationTests : BunitContext
{
[Fact]
public void Render_English_ShowsEnglishCopy()
{
+ // Arrange / Act
var cut = Render();
+ // Assert
Assert.Contains("An error occurred while processing your request.", cut.Markup);
Assert.Contains("Development mode", cut.Markup);
Assert.Contains("Development", cut.Markup);
@@ -19,12 +21,15 @@ public void Render_English_ShowsEnglishCopy()
[Fact]
public void Render_German_ShowsGermanCopy()
{
+ // Arrange
var original = CultureInfo.CurrentUICulture;
CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("de");
try
{
+ // Act
var cut = Render();
+ // Assert
Assert.Contains(
"Bei der Verarbeitung Ihrer Anfrage ist ein Fehler aufgetreten.",
cut.Markup
diff --git a/tests/AndreGoepel.AppFoundation.Tests/Components/Pages/HomeLocalization.Tests.cs b/tests/AndreGoepel.AppFoundation.Tests/Components/Pages/HomeLocalization.Tests.cs
index a76fa2d..58c1101 100644
--- a/tests/AndreGoepel.AppFoundation.Tests/Components/Pages/HomeLocalization.Tests.cs
+++ b/tests/AndreGoepel.AppFoundation.Tests/Components/Pages/HomeLocalization.Tests.cs
@@ -4,13 +4,15 @@
namespace AndreGoepel.AppFoundation.Tests.Components.Pages;
-public class HomeLocalizationTests : BunitContext
+public sealed class HomeLocalizationTests : BunitContext
{
[Fact]
public void Render_English_ShowsEnglishCopy()
{
+ // Arrange / Act
var cut = Render();
+ // Assert
Assert.Contains("Dashboard", cut.Markup);
Assert.Contains("Welcome!", cut.Markup);
}
@@ -18,12 +20,15 @@ public void Render_English_ShowsEnglishCopy()
[Fact]
public void Render_German_ShowsGermanCopy()
{
+ // Arrange
var original = CultureInfo.CurrentUICulture;
CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("de");
try
{
+ // Act
var cut = Render();
+ // Assert
Assert.Contains("Willkommen!", cut.Markup);
}
finally
diff --git a/tests/AndreGoepel.AppFoundation.Tests/Components/Pages/NotFound.Tests.cs b/tests/AndreGoepel.AppFoundation.Tests/Components/Pages/NotFound.Tests.cs
index cd5febe..d57f635 100644
--- a/tests/AndreGoepel.AppFoundation.Tests/Components/Pages/NotFound.Tests.cs
+++ b/tests/AndreGoepel.AppFoundation.Tests/Components/Pages/NotFound.Tests.cs
@@ -5,7 +5,7 @@
namespace AndreGoepel.AppFoundation.Tests.Components.Pages;
-public class NotFoundTests : BunitContext
+public sealed class NotFoundTests : BunitContext
{
[Fact]
public void Render_ShowsNotFoundMessage()
diff --git a/tests/AndreGoepel.AppFoundation.Tests/Components/Pages/SetupValidatorConversion.Tests.cs b/tests/AndreGoepel.AppFoundation.Tests/Components/Pages/SetupValidatorConversion.Tests.cs
index dc2826b..16049ca 100644
--- a/tests/AndreGoepel.AppFoundation.Tests/Components/Pages/SetupValidatorConversion.Tests.cs
+++ b/tests/AndreGoepel.AppFoundation.Tests/Components/Pages/SetupValidatorConversion.Tests.cs
@@ -18,7 +18,7 @@ namespace AndreGoepel.AppFoundation.Tests.Components.Pages;
/// in. Setup.razor's actual page path is covered end-to-end by the E2E suite's
/// ProvisionAdminAsync, which submits real (valid) data through the live, compiled form.
///
-public class SetupValidatorConversionTests : BunitContext
+public sealed class SetupValidatorConversionTests : BunitContext
{
public SetupValidatorConversionTests()
{
@@ -28,6 +28,7 @@ public SetupValidatorConversionTests()
[Fact]
public void InvalidModel_DoesNotInvokeSubmit()
{
+ // Arrange
var submitted = false;
var model = new ProbeModel { Password = "abc", ConfirmPassword = "xyz" };
@@ -40,14 +41,17 @@ public void InvalidModel_DoesNotInvokeSubmit()
.Add(f => f.ChildContent, ValidatorOnly)
);
+ // Act
cut.Find("form").Submit();
+ // Assert
Assert.False(submitted);
}
[Fact]
public void ValidModel_InvokesSubmit()
{
+ // Arrange
var submitted = false;
var model = new ProbeModel { Password = "abcdefghijkl", ConfirmPassword = "abcdefghijkl" };
@@ -60,8 +64,10 @@ public void ValidModel_InvokesSubmit()
.Add(f => f.ChildContent, ValidatorOnly)
);
+ // Act
cut.Find("form").Submit();
+ // Assert
Assert.True(submitted);
}
diff --git a/tests/AndreGoepel.AppFoundation.Tests/Components/Shared/ErrorPage.Tests.cs b/tests/AndreGoepel.AppFoundation.Tests/Components/Shared/ErrorPage.Tests.cs
index de74afa..1cc15f5 100644
--- a/tests/AndreGoepel.AppFoundation.Tests/Components/Shared/ErrorPage.Tests.cs
+++ b/tests/AndreGoepel.AppFoundation.Tests/Components/Shared/ErrorPage.Tests.cs
@@ -8,7 +8,7 @@
namespace AndreGoepel.AppFoundation.Tests.Components.Shared;
-public class ErrorPageTests : BunitContext
+public sealed class ErrorPageTests : BunitContext
{
private NavigationManager Nav => Services.GetRequiredService();
diff --git a/tests/AndreGoepel.AppFoundation.Tests/Hosting/AddAppFoundationDataProtectionTests.cs b/tests/AndreGoepel.AppFoundation.Tests/Hosting/AddAppFoundationDataProtectionTests.cs
index 5199838..658a8d4 100644
--- a/tests/AndreGoepel.AppFoundation.Tests/Hosting/AddAppFoundationDataProtectionTests.cs
+++ b/tests/AndreGoepel.AppFoundation.Tests/Hosting/AddAppFoundationDataProtectionTests.cs
@@ -12,7 +12,7 @@
namespace AndreGoepel.AppFoundation.Tests.Hosting;
-public class AddAppFoundationDataProtectionTests
+public sealed class AddAppFoundationDataProtectionTests
{
[Fact]
public void AddAppFoundation_PersistsKeyRingViaMartenRepository()
diff --git a/tests/AndreGoepel.AppFoundation.Tests/Hosting/AddAppFoundationIdentityTests.cs b/tests/AndreGoepel.AppFoundation.Tests/Hosting/AddAppFoundationIdentityTests.cs
index 8c9d8bf..2715dca 100644
--- a/tests/AndreGoepel.AppFoundation.Tests/Hosting/AddAppFoundationIdentityTests.cs
+++ b/tests/AndreGoepel.AppFoundation.Tests/Hosting/AddAppFoundationIdentityTests.cs
@@ -7,7 +7,7 @@
namespace AndreGoepel.AppFoundation.Tests.Hosting;
-public class AddAppFoundationIdentityTests
+public sealed class AddAppFoundationIdentityTests
{
[Fact]
public void AddAppFoundation_ConfigureIdentity_FlowsToBlazorOptions()
diff --git a/tests/AndreGoepel.AppFoundation.Tests/Hosting/AddAppFoundationQuartzTests.cs b/tests/AndreGoepel.AppFoundation.Tests/Hosting/AddAppFoundationQuartzTests.cs
index 4c35032..c576bba 100644
--- a/tests/AndreGoepel.AppFoundation.Tests/Hosting/AddAppFoundationQuartzTests.cs
+++ b/tests/AndreGoepel.AppFoundation.Tests/Hosting/AddAppFoundationQuartzTests.cs
@@ -7,7 +7,7 @@
namespace AndreGoepel.AppFoundation.Tests.Hosting;
-public class AddAppFoundationQuartzTests
+public sealed class AddAppFoundationQuartzTests
{
[Fact]
public void AddAppFoundation_ConfiguresPersistentPostgresJobStore()
diff --git a/tests/AndreGoepel.AppFoundation.Tests/Hosting/AddAppFoundationSchemaCreationTests.cs b/tests/AndreGoepel.AppFoundation.Tests/Hosting/AddAppFoundationSchemaCreationTests.cs
index 6097bef..287c725 100644
--- a/tests/AndreGoepel.AppFoundation.Tests/Hosting/AddAppFoundationSchemaCreationTests.cs
+++ b/tests/AndreGoepel.AppFoundation.Tests/Hosting/AddAppFoundationSchemaCreationTests.cs
@@ -7,7 +7,7 @@
namespace AndreGoepel.AppFoundation.Tests.Hosting;
-public class AddAppFoundationSchemaCreationTests
+public sealed class AddAppFoundationSchemaCreationTests
{
[Fact]
public void AddAppFoundation_NonDevelopmentEnvironment_UsesCreateOrUpdate()
diff --git a/tests/AndreGoepel.AppFoundation.Tests/Hosting/AddAppFoundationSecretsTests.cs b/tests/AndreGoepel.AppFoundation.Tests/Hosting/AddAppFoundationSecretsTests.cs
index 7f4d3f1..4663617 100644
--- a/tests/AndreGoepel.AppFoundation.Tests/Hosting/AddAppFoundationSecretsTests.cs
+++ b/tests/AndreGoepel.AppFoundation.Tests/Hosting/AddAppFoundationSecretsTests.cs
@@ -4,7 +4,7 @@
namespace AndreGoepel.AppFoundation.Tests.Hosting;
-public class AddAppFoundationSecretsTests
+public sealed class AddAppFoundationSecretsTests
{
[Fact]
public void AddAppFoundation_ReadsConnectionStringFromSecretsDirectory()
diff --git a/tests/AndreGoepel.AppFoundation.Tests/Hosting/DefaultRoleConfigurationTests.cs b/tests/AndreGoepel.AppFoundation.Tests/Hosting/DefaultRoleConfigurationTests.cs
index 02428bc..45f46a3 100644
--- a/tests/AndreGoepel.AppFoundation.Tests/Hosting/DefaultRoleConfigurationTests.cs
+++ b/tests/AndreGoepel.AppFoundation.Tests/Hosting/DefaultRoleConfigurationTests.cs
@@ -3,7 +3,7 @@
namespace AndreGoepel.AppFoundation.Tests.Hosting;
-public class DefaultRoleConfigurationTests
+public sealed class DefaultRoleConfigurationTests
{
[Fact]
public void Merge_ArrayForm_BindsEachRole()
diff --git a/tests/AndreGoepel.AppFoundation.Tests/Hosting/EnsureKeyRingProtectedTests.cs b/tests/AndreGoepel.AppFoundation.Tests/Hosting/EnsureKeyRingProtectedTests.cs
index 844aabc..f9afb2d 100644
--- a/tests/AndreGoepel.AppFoundation.Tests/Hosting/EnsureKeyRingProtectedTests.cs
+++ b/tests/AndreGoepel.AppFoundation.Tests/Hosting/EnsureKeyRingProtectedTests.cs
@@ -4,7 +4,7 @@
namespace AndreGoepel.AppFoundation.Tests.Hosting;
-public class EnsureKeyRingProtectedTests
+public sealed class EnsureKeyRingProtectedTests
{
[Fact]
public void NonDevelopment_WithoutEncryptor_NotAllowed_Throws()
diff --git a/tests/AndreGoepel.AppFoundation.Tests/Hosting/ForwardedHeaderConfigurationTests.cs b/tests/AndreGoepel.AppFoundation.Tests/Hosting/ForwardedHeaderConfigurationTests.cs
index 90967b2..528ad32 100644
--- a/tests/AndreGoepel.AppFoundation.Tests/Hosting/ForwardedHeaderConfigurationTests.cs
+++ b/tests/AndreGoepel.AppFoundation.Tests/Hosting/ForwardedHeaderConfigurationTests.cs
@@ -3,7 +3,7 @@
namespace AndreGoepel.AppFoundation.Tests.Hosting;
-public class ForwardedHeaderConfigurationTests
+public sealed class ForwardedHeaderConfigurationTests
{
[Fact]
public void Merge_DelimitedScalar_SplitsIntoEntries()
diff --git a/tests/AndreGoepel.AppFoundation.Tests/Hosting/ForwardedHeadersOptionsTests.cs b/tests/AndreGoepel.AppFoundation.Tests/Hosting/ForwardedHeadersOptionsTests.cs
index d44d9e6..d5c25ca 100644
--- a/tests/AndreGoepel.AppFoundation.Tests/Hosting/ForwardedHeadersOptionsTests.cs
+++ b/tests/AndreGoepel.AppFoundation.Tests/Hosting/ForwardedHeadersOptionsTests.cs
@@ -4,7 +4,7 @@
namespace AndreGoepel.AppFoundation.Tests.Hosting;
-public class ForwardedHeadersOptionsTests
+public sealed class ForwardedHeadersOptionsTests
{
[Fact]
public void Build_AlwaysHonorsForwardedForAndProto()
diff --git a/tests/AndreGoepel.AppFoundation.Tests/Hosting/MartenXmlRepository.Tests.cs b/tests/AndreGoepel.AppFoundation.Tests/Hosting/MartenXmlRepository.Tests.cs
index 3599e98..b6a0a65 100644
--- a/tests/AndreGoepel.AppFoundation.Tests/Hosting/MartenXmlRepository.Tests.cs
+++ b/tests/AndreGoepel.AppFoundation.Tests/Hosting/MartenXmlRepository.Tests.cs
@@ -5,7 +5,7 @@
namespace AndreGoepel.AppFoundation.Tests.Hosting;
-public class MartenXmlRepositoryTests
+public sealed class MartenXmlRepositoryTests
{
[Fact]
public void ToDocument_WithFriendlyName_UsesFriendlyNameAsId()
diff --git a/tests/AndreGoepel.AppFoundation.Tests/Hosting/QuartzSchemaProvisionerTests.cs b/tests/AndreGoepel.AppFoundation.Tests/Hosting/QuartzSchemaProvisionerTests.cs
index c6cea97..28a4382 100644
--- a/tests/AndreGoepel.AppFoundation.Tests/Hosting/QuartzSchemaProvisionerTests.cs
+++ b/tests/AndreGoepel.AppFoundation.Tests/Hosting/QuartzSchemaProvisionerTests.cs
@@ -3,7 +3,7 @@
namespace AndreGoepel.AppFoundation.Tests.Hosting;
-public class QuartzSchemaProvisionerTests
+public sealed class QuartzSchemaProvisionerTests
{
private static readonly string[] ExpectedTables =
[
diff --git a/tests/AndreGoepel.AppFoundation.Tests/Hosting/SecurityHeadersTests.cs b/tests/AndreGoepel.AppFoundation.Tests/Hosting/SecurityHeadersTests.cs
index 8134e7e..a2c8f35 100644
--- a/tests/AndreGoepel.AppFoundation.Tests/Hosting/SecurityHeadersTests.cs
+++ b/tests/AndreGoepel.AppFoundation.Tests/Hosting/SecurityHeadersTests.cs
@@ -4,7 +4,7 @@
namespace AndreGoepel.AppFoundation.Tests.Hosting;
-public class SecurityHeadersTests
+public sealed class SecurityHeadersTests
{
[Fact]
public void ConfigureHsts_Defaults_HardensBeyondFrameworkDefault()