From 2fe154d2699c7fa4d6c712d680648759b5393564 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 12:53:35 +0000 Subject: [PATCH 1/5] fix(api): fail loudly when Cors:Origins is empty outside Development MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit appsettings.json ships "Cors:Origins": [], so the previous policy built AllowAnyHeader/AllowAnyMethod with no WithOrigins call — a no-op CORS policy that silently rejects the frontend in production while working locally (Development ships real origins). Promote the section to a typed CorsOptions class and validate it on start: empty Origins fails the boot in any non-Development environment, and only logs a warning under Development. Builds the FrontendCors policy from the bound options so there is a single source for the origin list. The test web factory now injects a default Cors:Origins entry (it runs under the non-Development "Testing" environment) and a new integration test covers both the fail-loud boot and the configured-origin path. Closes #209 --- backend/Api/Options/CorsOptions.cs | 27 +++++ backend/Api/Program.cs | 33 +++++- .../CorsStartupIntegrationTests.cs | 101 ++++++++++++++++++ .../TrueMainWebApplicationFactory.cs | 14 ++- 4 files changed, 170 insertions(+), 5 deletions(-) create mode 100644 backend/Api/Options/CorsOptions.cs create mode 100644 backend/tests/TrueMain.IntegrationTests/CorsStartupIntegrationTests.cs diff --git a/backend/Api/Options/CorsOptions.cs b/backend/Api/Options/CorsOptions.cs new file mode 100644 index 00000000..151a24b1 --- /dev/null +++ b/backend/Api/Options/CorsOptions.cs @@ -0,0 +1,27 @@ +namespace TrueMain.Options; + +/// +/// Cross-origin policy for the public API, bound from Cors:*. The single +/// list drives the FrontendCors policy: the browser +/// receives Access-Control-Allow-Origin only for the hosts listed here. +/// +/// +/// An empty list is a silent-failure trap — the policy still builds, but with no +/// allowed origins the frontend's cross-origin requests are rejected by the +/// browser even though the API itself answers. It reads as "works locally, +/// broken in prod" because Development ships real origins while +/// appsettings.json ships an empty array. Startup therefore validates the +/// list: empty fails the boot in every non-Development environment (see +/// Program.cs) and only logs a warning under Development. +/// +public sealed class CorsOptions +{ + public const string SectionName = "Cors"; + + /// + /// Exact origins (scheme + host + port) allowed to make cross-origin browser + /// requests, e.g. https://truemain.app. Must be non-empty outside + /// Development. + /// + public string[] Origins { get; set; } = []; +} diff --git a/backend/Api/Program.cs b/backend/Api/Program.cs index bf0c1b22..210b42f4 100644 --- a/backend/Api/Program.cs +++ b/backend/Api/Program.cs @@ -40,15 +40,30 @@ tags: ["ready"]); } -var corsOrigins = builder.Configuration.GetSection("Cors:Origins").Get() ?? []; +// CORS origins must be present outside Development: an empty list still builds a +// valid (but no-op) policy, so without this guard production silently ships a +// CORS policy that allows no cross-origin browser request — the frontend appears +// to work locally (Development ships real origins) but breaks in prod, where +// appsettings.json ships an empty array. Fail the boot when empty in any +// non-Development environment; only warn under Development (handled after build). +var isDevelopment = builder.Environment.IsDevelopment(); +var corsOptions = builder.Configuration.GetSection(CorsOptions.SectionName).Get() + ?? new CorsOptions(); +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(CorsOptions.SectionName)) + .Validate( + options => isDevelopment || options.Origins.Length > 0, + "Cors:Origins must contain at least one origin outside the Development environment; " + + "an empty list ships a no-op CORS policy that silently rejects the frontend.") + .ValidateOnStart(); builder.Services.AddCors(options => { options.AddPolicy(frontendCorsPolicy, policy => { var builderPolicy = policy.AllowAnyHeader().AllowAnyMethod(); - if (corsOrigins.Length > 0) + if (corsOptions.Origins.Length > 0) { - builderPolicy.WithOrigins(corsOrigins); + builderPolicy.WithOrigins(corsOptions.Origins); } }); }); @@ -183,6 +198,18 @@ // the Ingestor's. builder.Services.AddMongoLogging(builder.Configuration, processName: "Api"); var app = builder.Build(); + +// Non-Development boots already fail in ValidateOnStart when Origins is empty; +// this only fires under Development, where an empty list is tolerated but still +// worth flagging so a missing local override doesn't read as a working CORS setup. +if (app.Environment.IsDevelopment() && corsOptions.Origins.Length == 0) +{ + app.Logger.LogWarning( + "Cors:Origins is empty; the {Policy} policy allows no cross-origin browser request. " + + "Set Cors:Origins in configuration to let the frontend reach the API.", + frontendCorsPolicy); +} + await DatabaseMigrator.ApplyPendingMigrationsAsync(app.Services); // Wrap unhandled exceptions in RFC 7807 ProblemDetails so clients diff --git a/backend/tests/TrueMain.IntegrationTests/CorsStartupIntegrationTests.cs b/backend/tests/TrueMain.IntegrationTests/CorsStartupIntegrationTests.cs new file mode 100644 index 00000000..f5efa8d4 --- /dev/null +++ b/backend/tests/TrueMain.IntegrationTests/CorsStartupIntegrationTests.cs @@ -0,0 +1,101 @@ +using System.Net; +using AwesomeAssertions; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using TrueMain.TestKit; + +namespace TrueMain.IntegrationTests; + +/// +/// Covers the startup CORS guard added for issue #209: outside Development an +/// empty Cors:Origins must fail the boot loudly (it would otherwise ship a +/// no-op CORS policy that silently rejects the frontend in production), while a +/// populated list boots and actually allows the configured origin. +/// +[Collection(IntegrationCollection.Name)] +public sealed class CorsStartupIntegrationTests +{ + private readonly PostgresFixture _fixture; + + public CorsStartupIntegrationTests(PostgresFixture fixture) + { + _fixture = fixture; + } + + [Fact] + public void Startup_InNonDevelopment_FailsWhenCorsOriginsEmpty() + { + // appsettings.json ships "Cors:Origins": [], so withholding the override + // leaves the list empty. Production is non-Development, so ValidateOnStart + // must reject the boot rather than silently run with a no-op policy. + using var factory = new CorsStartupFactory(_fixture, environment: "Production", origin: null); + + var startup = () => factory.CreateClient(); + + startup.Should().Throw( + "an empty Cors:Origins outside Development must fail the boot, not run a no-op policy") + .WithMessage("*Cors:Origins*"); + } + + [Fact] + public async Task Startup_InNonDevelopment_AllowsConfiguredOriginWhenPresent() + { + await _fixture.ResetDatabaseAsync(); + + const string allowedOrigin = "https://app.truemain.test"; + await using var factory = new CorsStartupFactory(_fixture, environment: "Production", origin: allowedOrigin); + using var client = factory.CreateClient(new WebApplicationFactoryClientOptions + { + BaseAddress = new Uri("https://localhost") + }); + + using var request = new HttpRequestMessage(HttpMethod.Get, "/champions"); + request.Headers.Add("Origin", allowedOrigin); + + var response = await client.SendAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Headers.GetValues("Access-Control-Allow-Origin").Should().ContainSingle() + .Which.Should().Be(allowedOrigin, + "the configured origin must be echoed back so the browser accepts the cross-origin response"); + } + + /// + /// A that — unlike + /// — does not inject a + /// default Cors:Origins, so a test can drive the host with the origin + /// list either empty ( null) or populated, and pick + /// the environment that decides whether the guard fails or warns. + /// + private sealed class CorsStartupFactory : WebApplicationFactory + { + private readonly string _environment; + private readonly List> _configuration; + + public CorsStartupFactory(PostgresFixture fixture, string environment, string? origin) + { + _environment = environment; + _configuration = + [ + new KeyValuePair("ConnectionStrings:TrueMain", fixture.ConnectionString), + new KeyValuePair( + "Ops:ApiKey", + TrueMainWebApplicationFactory.DefaultOpsApiKey) + ]; + + if (origin is not null) + { + _configuration.Add(new KeyValuePair("Cors:Origins:0", origin)); + } + } + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment(_environment); + builder.ConfigureAppConfiguration((_, configurationBuilder) => + configurationBuilder.AddInMemoryCollection(_configuration)); + } + } +} diff --git a/backend/tests/TrueMain.TestKit/TrueMainWebApplicationFactory.cs b/backend/tests/TrueMain.TestKit/TrueMainWebApplicationFactory.cs index 895439ec..6fad1eb9 100644 --- a/backend/tests/TrueMain.TestKit/TrueMainWebApplicationFactory.cs +++ b/backend/tests/TrueMain.TestKit/TrueMainWebApplicationFactory.cs @@ -9,7 +9,9 @@ namespace TrueMain.TestKit; /// against a : sets the Testing /// environment, injects ConnectionStrings:TrueMain, a test /// Ops:ApiKey that satisfies the [MinLength(32)] validation, -/// plus any additional overrides the test wants to add. +/// a default Cors:Origins entry (Testing is non-Development, so the +/// startup CORS guard fails the boot when the list is empty), plus any +/// additional overrides the test wants to add. /// public class TrueMainWebApplicationFactory( PostgresFixture fixture, @@ -24,6 +26,13 @@ public class TrueMainWebApplicationFactory( /// public const string DefaultOpsApiKey = "test-kit-ops-key-0123456789-abcdefghijklmnop"; + /// + /// A single allowed origin so the startup CORS guard (which fails the boot + /// outside Development when Cors:Origins is empty) is satisfied for + /// the Testing environment tests run under. + /// + public const string DefaultCorsOrigin = "https://frontend.test.truemain.local"; + protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.UseEnvironment("Testing"); @@ -32,7 +41,8 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) var baseline = new List> { new("ConnectionStrings:TrueMain", fixture.ConnectionString), - new("Ops:ApiKey", DefaultOpsApiKey) + new("Ops:ApiKey", DefaultOpsApiKey), + new("Cors:Origins:0", DefaultCorsOrigin) }; if (extraConfiguration is { Count: > 0 }) From 5c2066e885b40519413bb769fd68d8a0e09a232f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 13:38:21 +0000 Subject: [PATCH 2/5] fix(api): resolve build errors and review feedback on CORS guard - Fix CS1734: drop the tag from the test factory's class doc comment (a class has no parameters to reference). - Fix IDE0005: remove the redundant TrueMain.TestKit using (it is a global using) and the now-unused System.Net using. - Consolidate the CORS config to a single source: bind CorsOptions once and build the FrontendCors policy from the bound options via Configure>, instead of reading configuration a second time eagerly for AddCors. - Decouple the CORS integration test from /champions business logic by asserting the Access-Control-Allow-Origin header on a preflight request rather than on a 200 from the endpoint. --- backend/Api/Program.cs | 27 ++++++++++--------- .../CorsStartupIntegrationTests.cs | 20 +++++++------- 2 files changed, 24 insertions(+), 23 deletions(-) diff --git a/backend/Api/Program.cs b/backend/Api/Program.cs index 210b42f4..60a7e5af 100644 --- a/backend/Api/Program.cs +++ b/backend/Api/Program.cs @@ -47,8 +47,6 @@ // appsettings.json ships an empty array. Fail the boot when empty in any // non-Development environment; only warn under Development (handled after build). var isDevelopment = builder.Environment.IsDevelopment(); -var corsOptions = builder.Configuration.GetSection(CorsOptions.SectionName).Get() - ?? new CorsOptions(); builder.Services.AddOptions() .Bind(builder.Configuration.GetSection(CorsOptions.SectionName)) .Validate( @@ -56,17 +54,19 @@ "Cors:Origins must contain at least one origin outside the Development environment; " + "an empty list ships a no-op CORS policy that silently rejects the frontend.") .ValidateOnStart(); -builder.Services.AddCors(options => -{ - options.AddPolicy(frontendCorsPolicy, policy => - { - var builderPolicy = policy.AllowAnyHeader().AllowAnyMethod(); - if (corsOptions.Origins.Length > 0) +builder.Services.AddCors(); +// Build the FrontendCors policy from the bound CorsOptions (single source — no +// separate config read) so the validated origins are the ones the policy uses. +builder.Services.AddOptions() + .Configure>((corsPolicies, appCors) => + corsPolicies.AddPolicy(frontendCorsPolicy, policy => { - builderPolicy.WithOrigins(corsOptions.Origins); - } - }); -}); + var builderPolicy = policy.AllowAnyHeader().AllowAnyMethod(); + if (appCors.Value.Origins.Length > 0) + { + builderPolicy.WithOrigins(appCors.Value.Origins); + } + })); builder.Services.AddOptions() .Bind(builder.Configuration.GetSection("MainAnalysis")) @@ -202,7 +202,8 @@ // Non-Development boots already fail in ValidateOnStart when Origins is empty; // this only fires under Development, where an empty list is tolerated but still // worth flagging so a missing local override doesn't read as a working CORS setup. -if (app.Environment.IsDevelopment() && corsOptions.Origins.Length == 0) +if (app.Environment.IsDevelopment() + && app.Services.GetRequiredService>().Value.Origins.Length == 0) { app.Logger.LogWarning( "Cors:Origins is empty; the {Policy} policy allows no cross-origin browser request. " diff --git a/backend/tests/TrueMain.IntegrationTests/CorsStartupIntegrationTests.cs b/backend/tests/TrueMain.IntegrationTests/CorsStartupIntegrationTests.cs index f5efa8d4..9ec63021 100644 --- a/backend/tests/TrueMain.IntegrationTests/CorsStartupIntegrationTests.cs +++ b/backend/tests/TrueMain.IntegrationTests/CorsStartupIntegrationTests.cs @@ -1,10 +1,8 @@ -using System.Net; using AwesomeAssertions; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Options; -using TrueMain.TestKit; namespace TrueMain.IntegrationTests; @@ -42,8 +40,6 @@ public void Startup_InNonDevelopment_FailsWhenCorsOriginsEmpty() [Fact] public async Task Startup_InNonDevelopment_AllowsConfiguredOriginWhenPresent() { - await _fixture.ResetDatabaseAsync(); - const string allowedOrigin = "https://app.truemain.test"; await using var factory = new CorsStartupFactory(_fixture, environment: "Production", origin: allowedOrigin); using var client = factory.CreateClient(new WebApplicationFactoryClientOptions @@ -51,12 +47,16 @@ public async Task Startup_InNonDevelopment_AllowsConfiguredOriginWhenPresent() BaseAddress = new Uri("https://localhost") }); - using var request = new HttpRequestMessage(HttpMethod.Get, "/champions"); - request.Headers.Add("Origin", allowedOrigin); + // A CORS preflight exercises the policy directly through the CORS + // middleware, so the assertion stays on the header that matters + // (Access-Control-Allow-Origin) without coupling to any endpoint's + // business logic or status code. + using var preflight = new HttpRequestMessage(HttpMethod.Options, "/champions"); + preflight.Headers.Add("Origin", allowedOrigin); + preflight.Headers.Add("Access-Control-Request-Method", "GET"); - var response = await client.SendAsync(request); + var response = await client.SendAsync(preflight); - response.StatusCode.Should().Be(HttpStatusCode.OK); response.Headers.GetValues("Access-Control-Allow-Origin").Should().ContainSingle() .Which.Should().Be(allowedOrigin, "the configured origin must be echoed back so the browser accepts the cross-origin response"); @@ -66,8 +66,8 @@ public async Task Startup_InNonDevelopment_AllowsConfiguredOriginWhenPresent() /// A that — unlike /// — does not inject a /// default Cors:Origins, so a test can drive the host with the origin - /// list either empty ( null) or populated, and pick - /// the environment that decides whether the guard fails or warns. + /// list either empty (a null origin argument) or populated, and pick the + /// environment that decides whether the guard fails or warns. /// private sealed class CorsStartupFactory : WebApplicationFactory { From 324a118d19f6f15b18c88d40cb8e07db2a4c9245 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 13:44:18 +0000 Subject: [PATCH 3/5] refactor(api): rename CorsOptions to FrontendCorsOptions; clarify guard Address non-blocking review feedback: - Rename the typed options class to FrontendCorsOptions so it no longer collides with ASP.NET Core's Microsoft.AspNetCore.Cors.Infrastructure.CorsOptions, removing the full-qualification burden for readers. - Document that the Origins.Length > 0 guard in the policy builder is a Development-only path: ValidateOnStart already guarantees a non-empty list everywhere else. --- .../{CorsOptions.cs => FrontendCorsOptions.cs} | 4 +++- backend/Api/Program.cs | 16 ++++++++++------ 2 files changed, 13 insertions(+), 7 deletions(-) rename backend/Api/Options/{CorsOptions.cs => FrontendCorsOptions.cs} (84%) diff --git a/backend/Api/Options/CorsOptions.cs b/backend/Api/Options/FrontendCorsOptions.cs similarity index 84% rename from backend/Api/Options/CorsOptions.cs rename to backend/Api/Options/FrontendCorsOptions.cs index 151a24b1..401483d0 100644 --- a/backend/Api/Options/CorsOptions.cs +++ b/backend/Api/Options/FrontendCorsOptions.cs @@ -4,6 +4,8 @@ namespace TrueMain.Options; /// Cross-origin policy for the public API, bound from Cors:*. The single /// list drives the FrontendCors policy: the browser /// receives Access-Control-Allow-Origin only for the hosts listed here. +/// Named FrontendCorsOptions (not CorsOptions) to avoid colliding +/// with ASP.NET Core's own Microsoft.AspNetCore.Cors.Infrastructure.CorsOptions. /// /// /// An empty list is a silent-failure trap — the policy still builds, but with no @@ -14,7 +16,7 @@ namespace TrueMain.Options; /// list: empty fails the boot in every non-Development environment (see /// Program.cs) and only logs a warning under Development. /// -public sealed class CorsOptions +public sealed class FrontendCorsOptions { public const string SectionName = "Cors"; diff --git a/backend/Api/Program.cs b/backend/Api/Program.cs index 60a7e5af..30979813 100644 --- a/backend/Api/Program.cs +++ b/backend/Api/Program.cs @@ -47,21 +47,25 @@ // appsettings.json ships an empty array. Fail the boot when empty in any // non-Development environment; only warn under Development (handled after build). var isDevelopment = builder.Environment.IsDevelopment(); -builder.Services.AddOptions() - .Bind(builder.Configuration.GetSection(CorsOptions.SectionName)) +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(FrontendCorsOptions.SectionName)) .Validate( options => isDevelopment || options.Origins.Length > 0, "Cors:Origins must contain at least one origin outside the Development environment; " + "an empty list ships a no-op CORS policy that silently rejects the frontend.") .ValidateOnStart(); builder.Services.AddCors(); -// Build the FrontendCors policy from the bound CorsOptions (single source — no -// separate config read) so the validated origins are the ones the policy uses. +// Build the FrontendCors policy from the bound FrontendCorsOptions (single +// source — no separate config read) so the validated origins are the ones the +// policy uses. builder.Services.AddOptions() - .Configure>((corsPolicies, appCors) => + .Configure>((corsPolicies, appCors) => corsPolicies.AddPolicy(frontendCorsPolicy, policy => { var builderPolicy = policy.AllowAnyHeader().AllowAnyMethod(); + // Origins is guaranteed non-empty outside Development by + // ValidateOnStart; this guard only matters under Development, where an + // empty list is tolerated (and the policy then allows no origin). if (appCors.Value.Origins.Length > 0) { builderPolicy.WithOrigins(appCors.Value.Origins); @@ -203,7 +207,7 @@ // this only fires under Development, where an empty list is tolerated but still // worth flagging so a missing local override doesn't read as a working CORS setup. if (app.Environment.IsDevelopment() - && app.Services.GetRequiredService>().Value.Origins.Length == 0) + && app.Services.GetRequiredService>().Value.Origins.Length == 0) { app.Logger.LogWarning( "Cors:Origins is empty; the {Policy} policy allows no cross-origin browser request. " From 6e05ce20cb2b7892b823bf129c4cb65471db2bd3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 13:53:53 +0000 Subject: [PATCH 4/5] test(api): run CORS startup tests under Testing, not Production develop added a readiness-health-check guard that fails fast on a missing ConnectionStrings:TrueMain only under Production. The test host injects the connection string after Program reads it at startup, so a Production host trips that guard before reaching the CORS validation. Use the Testing environment (still non-Development, so the CORS guard is exercised) which is exempt from the Production-only connection-string check, matching TrueMainWebApplicationFactory. --- .../CorsStartupIntegrationTests.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/backend/tests/TrueMain.IntegrationTests/CorsStartupIntegrationTests.cs b/backend/tests/TrueMain.IntegrationTests/CorsStartupIntegrationTests.cs index 9ec63021..c5f616a5 100644 --- a/backend/tests/TrueMain.IntegrationTests/CorsStartupIntegrationTests.cs +++ b/backend/tests/TrueMain.IntegrationTests/CorsStartupIntegrationTests.cs @@ -26,9 +26,12 @@ public CorsStartupIntegrationTests(PostgresFixture fixture) public void Startup_InNonDevelopment_FailsWhenCorsOriginsEmpty() { // appsettings.json ships "Cors:Origins": [], so withholding the override - // leaves the list empty. Production is non-Development, so ValidateOnStart + // leaves the list empty. Testing is non-Development, so ValidateOnStart // must reject the boot rather than silently run with a no-op policy. - using var factory = new CorsStartupFactory(_fixture, environment: "Production", origin: null); + // (Testing, not Production: the readiness-health-check guard fails fast on + // a missing connection string only under Production, and the test host + // injects that string after Program reads it.) + using var factory = new CorsStartupFactory(_fixture, environment: "Testing", origin: null); var startup = () => factory.CreateClient(); @@ -41,7 +44,7 @@ public void Startup_InNonDevelopment_FailsWhenCorsOriginsEmpty() public async Task Startup_InNonDevelopment_AllowsConfiguredOriginWhenPresent() { const string allowedOrigin = "https://app.truemain.test"; - await using var factory = new CorsStartupFactory(_fixture, environment: "Production", origin: allowedOrigin); + await using var factory = new CorsStartupFactory(_fixture, environment: "Testing", origin: allowedOrigin); using var client = factory.CreateClient(new WebApplicationFactoryClientOptions { BaseAddress = new Uri("https://localhost") From af8d53d534d7bbf7294abe756974306d6c16682f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 13:59:37 +0000 Subject: [PATCH 5/5] refactor(api): apply non-blocking review nits on CORS guard - Program.cs: introduce an AspNetCorsOptions using-alias instead of the fully-qualified Microsoft.AspNetCore.Cors.Infrastructure.CorsOptions. - Program.cs: collapse the Development CORS warning into a single log template literal (friendlier to logging analyzers). - CorsStartupIntegrationTests: assert the preflight returns 204 No Content for clearer diagnostics, and make the empty-origins test async with await using for consistency with the other test. --- backend/Api/Program.cs | 6 +++--- .../CorsStartupIntegrationTests.cs | 7 +++++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/backend/Api/Program.cs b/backend/Api/Program.cs index 30979813..01193710 100644 --- a/backend/Api/Program.cs +++ b/backend/Api/Program.cs @@ -12,6 +12,7 @@ using TrueMain.Services.Champions; using TrueMain.Services.Ops; using TrueMain.Services.Truemains; +using AspNetCorsOptions = Microsoft.AspNetCore.Cors.Infrastructure.CorsOptions; var builder = WebApplication.CreateBuilder(args); const string frontendCorsPolicy = "FrontendCors"; @@ -58,7 +59,7 @@ // Build the FrontendCors policy from the bound FrontendCorsOptions (single // source — no separate config read) so the validated origins are the ones the // policy uses. -builder.Services.AddOptions() +builder.Services.AddOptions() .Configure>((corsPolicies, appCors) => corsPolicies.AddPolicy(frontendCorsPolicy, policy => { @@ -210,8 +211,7 @@ && app.Services.GetRequiredService>().Value.Origins.Length == 0) { app.Logger.LogWarning( - "Cors:Origins is empty; the {Policy} policy allows no cross-origin browser request. " - + "Set Cors:Origins in configuration to let the frontend reach the API.", + "Cors:Origins is empty; the {Policy} policy allows no cross-origin browser request. Set Cors:Origins in configuration to let the frontend reach the API.", frontendCorsPolicy); } diff --git a/backend/tests/TrueMain.IntegrationTests/CorsStartupIntegrationTests.cs b/backend/tests/TrueMain.IntegrationTests/CorsStartupIntegrationTests.cs index c5f616a5..94391e76 100644 --- a/backend/tests/TrueMain.IntegrationTests/CorsStartupIntegrationTests.cs +++ b/backend/tests/TrueMain.IntegrationTests/CorsStartupIntegrationTests.cs @@ -1,3 +1,4 @@ +using System.Net; using AwesomeAssertions; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; @@ -23,7 +24,7 @@ public CorsStartupIntegrationTests(PostgresFixture fixture) } [Fact] - public void Startup_InNonDevelopment_FailsWhenCorsOriginsEmpty() + public async Task Startup_InNonDevelopment_FailsWhenCorsOriginsEmpty() { // appsettings.json ships "Cors:Origins": [], so withholding the override // leaves the list empty. Testing is non-Development, so ValidateOnStart @@ -31,7 +32,7 @@ public void Startup_InNonDevelopment_FailsWhenCorsOriginsEmpty() // (Testing, not Production: the readiness-health-check guard fails fast on // a missing connection string only under Production, and the test host // injects that string after Program reads it.) - using var factory = new CorsStartupFactory(_fixture, environment: "Testing", origin: null); + await using var factory = new CorsStartupFactory(_fixture, environment: "Testing", origin: null); var startup = () => factory.CreateClient(); @@ -60,6 +61,8 @@ public async Task Startup_InNonDevelopment_AllowsConfiguredOriginWhenPresent() var response = await client.SendAsync(preflight); + response.StatusCode.Should().Be(HttpStatusCode.NoContent, + "a handled CORS preflight is short-circuited by the middleware with 204 No Content"); response.Headers.GetValues("Access-Control-Allow-Origin").Should().ContainSingle() .Which.Should().Be(allowedOrigin, "the configured origin must be echoed back so the browser accepts the cross-origin response");