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()