diff --git a/src/Yavsc.Api.Test/EstimateApiControllerTests.cs b/src/Yavsc.Api.Test/EstimateApiControllerTests.cs index cb945f51a..d8cae0265 100644 --- a/src/Yavsc.Api.Test/EstimateApiControllerTests.cs +++ b/src/Yavsc.Api.Test/EstimateApiControllerTests.cs @@ -209,9 +209,12 @@ public sealed class EstimateApiControllerTests : IClassFixture /// Seeds an estimate owned by alice (the provider) for the given - /// client, optionally already validated by the client. + /// client, optionally already validated by the client. By default the + /// estimate is also provider-validated (i.e. the provider has submitted + /// it); pass = false to seed a + /// freshly inserted request the provider hasn't worked on yet. /// - private long SeedEstimate(string clientId, bool clientValidated) + private long SeedEstimate(string clientId, bool clientValidated, bool providerValidated = true) { using var scope = _fixture.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); @@ -229,6 +232,7 @@ public sealed class EstimateApiControllerTests : IClassFixture e.Bill) - .Where(e => e.ClientId == uid && e.ClientValidationDate == default)); + .Where(e => e.ClientId == uid + && e.ProviderValidationDate != default + && e.ClientValidationDate == default)); } // GET: api/estimate/asprovider diff --git a/src/Yavsc.Org.Tests/Smoke/AccountSmokeTests.cs b/src/Yavsc.Org.Tests/Smoke/AccountSmokeTests.cs index 61d774c57..5e7ab7776 100644 --- a/src/Yavsc.Org.Tests/Smoke/AccountSmokeTests.cs +++ b/src/Yavsc.Org.Tests/Smoke/AccountSmokeTests.cs @@ -1,4 +1,7 @@ +using System.Net; +using System.Net.Http; using IdentityServer8.Stores; +using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.DependencyInjection; using Xunit; @@ -40,4 +43,26 @@ public class AccountSmokeTests : IClassFixture Assert.Null(exception); } + + // Regression: anonymous access to a restricted page used to redirect to + // the ASP.NET Identity default LoginPath "/Account/Login", which has no + // controller here. The application cookie must redirect to the real + // sign-in endpoint at /signin instead. + [Fact] + public async Task Anonymous_access_to_protected_page_redirects_to_signin() + { + var client = _factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + }); + + // EstimateController is [Authorize]; GET /Estimate hits Index(). + var response = await client.GetAsync("/Estimate", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); + var location = response.Headers.Location; + Assert.NotNull(location); + Assert.Equal("/signin", location.AbsolutePath); + Assert.Contains("ReturnUrl=%2FEstimate", location.Query); + } } diff --git a/src/Yavsc.Org/Extensions/HostingExtensions.cs b/src/Yavsc.Org/Extensions/HostingExtensions.cs index 8c1db2a73..ea035001c 100644 --- a/src/Yavsc.Org/Extensions/HostingExtensions.cs +++ b/src/Yavsc.Org/Extensions/HostingExtensions.cs @@ -215,6 +215,18 @@ public static class HostingExtensions services.AddScoped, UserClaimsPrincipalFactory>(); + // The default application cookie LoginPath is "/Account/Login", which + // has no controller here — anonymous access to a restricted page must + // redirect to the actual sign-in endpoint instead. The paths mirror + // the route attributes on AccountController (Constants.SigninPath etc., + // which carry a "~/" prefix that the cookie middleware does not want). + services.ConfigureApplicationCookie(options => + { + options.LoginPath = "/signin"; + options.LogoutPath = "/signout"; + options.AccessDeniedPath = "/Account/AccessDenied"; + }); + // Dev-only: Chromium rejects SameSite=None without Secure on http:// // (e.g. http://localhost:5000). The default Identity cookie policy // sets SameSite=None, which is invalid without Secure. Force Lax in