diff --git a/src/Business/Grand.Business.Authentication/Services/CookieAuthenticationService.cs b/src/Business/Grand.Business.Authentication/Services/CookieAuthenticationService.cs index f200fb988..aaccb7ea1 100644 --- a/src/Business/Grand.Business.Authentication/Services/CookieAuthenticationService.cs +++ b/src/Business/Grand.Business.Authentication/Services/CookieAuthenticationService.cs @@ -207,6 +207,19 @@ await _httpContextAccessor.HttpContext.SignOutAsync(GrandCookieAuthenticationDef } } + /// + /// Whether the current sign-in cookie references a session started at the identity provider + /// + public virtual async Task HasOidcSession() + { + if (_httpContextAccessor.HttpContext == null) + return false; + + var authenticateResult = await _httpContextAccessor.HttpContext.AuthenticateAsync( + GrandCookieAuthenticationDefaults.AuthenticationScheme); + return authenticateResult?.Succeeded == true && !string.IsNullOrEmpty(OidcSessionId(authenticateResult.Principal)); + } + /// /// Get an authenticated customer /// diff --git a/src/Business/Grand.Business.Core/Interfaces/Authentication/IExternalSignOutProvider.cs b/src/Business/Grand.Business.Core/Interfaces/Authentication/IExternalSignOutProvider.cs new file mode 100644 index 000000000..55960e44d --- /dev/null +++ b/src/Business/Grand.Business.Core/Interfaces/Authentication/IExternalSignOutProvider.cs @@ -0,0 +1,11 @@ +namespace Grand.Business.Core.Interfaces.Authentication; + +/// An external authentication provider that can end the user's session at the provider (RP-initiated logout) +public interface IExternalSignOutProvider +{ + /// + /// Absolute URL that ends the session at the provider and returns to + /// (registered at the provider); null when the provider cannot do it now + /// + Task GetSignOutUrl(string postLogoutRedirectUri); +} diff --git a/src/Business/Grand.Business.Core/Interfaces/Authentication/IGrandAuthenticationService.cs b/src/Business/Grand.Business.Core/Interfaces/Authentication/IGrandAuthenticationService.cs index 71e0c1b4c..01cd7254e 100644 --- a/src/Business/Grand.Business.Core/Interfaces/Authentication/IGrandAuthenticationService.cs +++ b/src/Business/Grand.Business.Core/Interfaces/Authentication/IGrandAuthenticationService.cs @@ -28,6 +28,12 @@ public interface IGrandAuthenticationService /// Task SignOut(); + /// + /// Whether the current sign-in cookie references a session started at the identity provider; an implementation + /// without provider sessions keeps the default (no) + /// + Task HasOidcSession() => Task.FromResult(false); + /// /// Get authenticated customer /// diff --git a/src/Tests/Grand.Business.Authentication.Tests/Services/CookieAuthenticationServiceTests.cs b/src/Tests/Grand.Business.Authentication.Tests/Services/CookieAuthenticationServiceTests.cs index b5e9e289f..85066d763 100644 --- a/src/Tests/Grand.Business.Authentication.Tests/Services/CookieAuthenticationServiceTests.cs +++ b/src/Tests/Grand.Business.Authentication.Tests/Services/CookieAuthenticationServiceTests.cs @@ -305,6 +305,22 @@ public async Task SignOut_EndsOidcSession() _oidcSessionService.Verify(x => x.End("session-1", OidcSessionEndReason.SignOut), Times.Once); } + [TestMethod] + public async Task HasOidcSession_SessionCookie_True() + { + _authServiceMock.Setup(x => x.AuthenticateAsync(It.IsAny(), GrandCookieAuthenticationDefaults.AuthenticationScheme)) + .ReturnsAsync(Ticket(new Claim(OidcSessionDefaults.SessionIdClaimType, "session-1", ClaimValueTypes.String, "grandnode"))); + Assert.IsTrue(await _cookieAuthService.HasOidcSession()); + } + + [TestMethod] + public async Task HasOidcSession_PlainCookie_False() + { + _authServiceMock.Setup(x => x.AuthenticateAsync(It.IsAny(), GrandCookieAuthenticationDefaults.AuthenticationScheme)) + .ReturnsAsync(Ticket(new Claim(ClaimTypes.Email, "a@b.c", ClaimValueTypes.Email, "grandnode"))); + Assert.IsFalse(await _cookieAuthService.HasOidcSession()); + } + private (Customer customer, Func signedIn) PrepareSignIn(string customerId) { ClaimsPrincipal signedIn = null; diff --git a/src/Tests/Grand.Business.Authentication.Tests/Services/GrandAuthenticationServiceDefaultsTests.cs b/src/Tests/Grand.Business.Authentication.Tests/Services/GrandAuthenticationServiceDefaultsTests.cs new file mode 100644 index 000000000..119b1616a --- /dev/null +++ b/src/Tests/Grand.Business.Authentication.Tests/Services/GrandAuthenticationServiceDefaultsTests.cs @@ -0,0 +1,33 @@ +using Grand.Business.Core.Interfaces.Authentication; +using Grand.Domain.Customers; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Grand.Business.Authentication.Tests.Services; + +[TestClass] +public class GrandAuthenticationServiceDefaultsTests +{ + [TestMethod] + public async Task HasOidcSession_ImplementationWithoutIt_ReturnsFalse() + { + IGrandAuthenticationService service = new LegacyAuthenticationService(); + + Assert.IsFalse(await service.HasOidcSession()); + } + + //an implementation written before HasOidcSession existed + private sealed class LegacyAuthenticationService : IGrandAuthenticationService + { + public Task SignIn(Customer customer, bool createPersistentCookie) => Task.CompletedTask; + + public Task SignIn(Customer customer, bool createPersistentCookie, string oidcSessionId) => Task.CompletedTask; + + public Task SignOut() => Task.CompletedTask; + + public Task GetAuthenticatedCustomer() => Task.FromResult(null); + + public Task GetCustomerGuid() => Task.FromResult(null); + + public Task SetCustomerGuid(Guid customerGuid) => Task.CompletedTask; + } +} diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseHomeControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseHomeControllerTests.cs index 44d93440a..598343998 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseHomeControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseHomeControllerTests.cs @@ -1,4 +1,3 @@ -using Grand.Business.Core.Interfaces.Authentication; using Grand.Business.Core.Interfaces.Common.Directory; using Grand.Business.Core.Interfaces.Common.Localization; using Grand.Business.Core.Interfaces.Customers; @@ -8,6 +7,7 @@ using Grand.Domain.Stores; using Grand.Infrastructure; using Grand.Web.AdminShared.Controllers; +using Grand.Web.Common.Security.ExternalRegistration; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Routing; @@ -24,10 +24,9 @@ public class BaseHomeControllerTests private class TestHomeController( ICountryService countryService, ITranslationService translationService, - IGrandAuthenticationService authenticationService, string selectStateKey, string logoutRoute) - : BaseHomeController(countryService, translationService, authenticationService) + : BaseHomeController(countryService, translationService) { protected override string SelectStateResourceKey => selectStateKey; protected override string LogoutRouteName => logoutRoute; @@ -36,12 +35,11 @@ private class TestHomeController( private class TestHomeControllerWithSetLanguage( ICountryService countryService, ITranslationService translationService, - IGrandAuthenticationService authenticationService, IContextAccessor contextAccessor, string selectStateKey, string logoutRoute, string areaName) - : BaseHomeControllerWithSetLanguage(countryService, translationService, authenticationService, contextAccessor) + : BaseHomeControllerWithSetLanguage(countryService, translationService, contextAccessor) { protected override string SelectStateResourceKey => selectStateKey; protected override string LogoutRouteName => logoutRoute; @@ -50,7 +48,6 @@ private class TestHomeControllerWithSetLanguage( private Mock _countryServiceMock = null!; private Mock _translationServiceMock = null!; - private Mock _authenticationServiceMock = null!; [TestInitialize] public void Setup() @@ -58,7 +55,6 @@ public void Setup() _countryServiceMock = new Mock(); _translationServiceMock = new Mock(); _translationServiceMock.Setup(t => t.GetResource(It.IsAny())).Returns((string key) => key); - _authenticationServiceMock = new Mock(); } private static void WireControllerInfrastructure(Controller controller) @@ -79,7 +75,7 @@ private static void WireControllerInfrastructure(Controller controller) private TestHomeController CreateController(string selectStateKey, string logoutRoute) { var controller = new TestHomeController(_countryServiceMock.Object, _translationServiceMock.Object, - _authenticationServiceMock.Object, selectStateKey, logoutRoute); + selectStateKey, logoutRoute); WireControllerInfrastructure(controller); return controller; } @@ -150,12 +146,26 @@ public async Task Logout_SignsOutAndRedirectsToSubclassRoute() { var controller = CreateController("Admin.Address.SelectState", "VendorLogin"); - var result = (RedirectToRouteResult)await controller.Logout(); + var providerSignOut = new Mock(); - _authenticationServiceMock.Verify(a => a.SignOut(), Times.Once); + var result = (RedirectToRouteResult)await controller.Logout(providerSignOut.Object); + + providerSignOut.Verify(a => a.SignOut(), Times.Once); Assert.AreEqual("VendorLogin", result.RouteName); } + [TestMethod] + public async Task Logout_ProviderUrl_RedirectsToProvider() + { + var controller = CreateController("Admin.Address.SelectState", "VendorLogin"); + var providerSignOut = new Mock(); + providerSignOut.Setup(a => a.SignOut()).ReturnsAsync("https://idp.example/logout"); + + var result = (RedirectResult)await controller.Logout(providerSignOut.Object); + + Assert.AreEqual("https://idp.example/logout", result.Url); + } + [TestMethod] public async Task SetLanguage_EmptyReturnUrl_RedirectsToSubclassArea() { @@ -173,7 +183,7 @@ public async Task SetLanguage_EmptyReturnUrl_RedirectsToSubclassArea() languageServiceMock.Setup(l => l.GetLanguageById("lang-1")).ReturnsAsync(new Language { Id = "lang-1" }); var controller = new TestHomeControllerWithSetLanguage(_countryServiceMock.Object, - _translationServiceMock.Object, _authenticationServiceMock.Object, contextAccessorMock.Object, + _translationServiceMock.Object, contextAccessorMock.Object, "Store.Address.SelectState", "StoreLogin", "Store"); WireControllerInfrastructure(controller); var urlHelperMock = new Mock(); @@ -207,7 +217,7 @@ public async Task SetLanguage_NonLocalReturnUrl_RedirectsToSubclassAreaIndexInst languageServiceMock.Setup(l => l.GetLanguageById(It.IsAny())).ReturnsAsync((Language)null!); var controller = new TestHomeControllerWithSetLanguage(_countryServiceMock.Object, - _translationServiceMock.Object, _authenticationServiceMock.Object, contextAccessorMock.Object, + _translationServiceMock.Object, contextAccessorMock.Object, "Store.Address.SelectState", "StoreLogin", "Store"); WireControllerInfrastructure(controller); var urlHelperMock = new Mock(); diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/LoginControllerExternalSignInTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/LoginControllerExternalSignInTests.cs index c95be90e4..596c46c5c 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/LoginControllerExternalSignInTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/LoginControllerExternalSignInTests.cs @@ -1,10 +1,12 @@ using Grand.Business.Core.Interfaces.Authentication; +using Grand.Business.Core.Interfaces.Common.Directory; using Grand.Business.Core.Interfaces.Common.Localization; using Grand.Business.Core.Interfaces.Customers; using Grand.Business.Core.Interfaces.Messages; using Grand.Domain.Common; using Grand.Domain.Customers; using Grand.Infrastructure; +using Grand.Infrastructure.Configuration; using Grand.Mediator; using Grand.Web.Admin.Controllers; using Grand.Web.AdminShared.Models.Common; @@ -21,12 +23,14 @@ namespace Grand.Web.Admin.Tests.Controllers; public class LoginControllerExternalSignInTests { private Mock _externalAuthenticationService = null!; + private Mock _groupService = null!; private LoginController _controller = null!; [TestInitialize] public void Setup() { _externalAuthenticationService = new Mock(); + _groupService = new Mock(); _controller = new LoginController(new CustomerSettings(), new CaptchaSettings(), new Mock().Object, new Mock().Object, new Mock().Object, new Mock().Object, @@ -53,7 +57,7 @@ public async Task Index_ListsPanelProvidersOnly() _externalAuthenticationService.Setup(x => x.LoadActiveAuthenticationProviders(null, null)) .Returns([panel.Object, storefrontOnly.Object]); - var model = (LoginModel)((ViewResult)await _controller.Index(_externalAuthenticationService.Object)).Model!; + var model = (LoginModel)((ViewResult)await _controller.Index(_externalAuthenticationService.Object, new OidcLoginConfig(), _groupService.Object)).Model!; Assert.AreEqual(1, model.ExternalSignIn.Count); Assert.AreEqual("OpenID Connect", model.ExternalSignIn[0].Name); @@ -66,7 +70,7 @@ public async Task Index_ProviderWithoutPath_Hidden() var panel = new Mock(); panel.Setup(x => x.GetSignInPath(It.IsAny())).ReturnsAsync((string)null!); _externalAuthenticationService.Setup(x => x.LoadActiveAuthenticationProviders(null, null)).Returns([panel.Object]); - var model = (LoginModel)((ViewResult)await _controller.Index(_externalAuthenticationService.Object)).Model!; + var model = (LoginModel)((ViewResult)await _controller.Index(_externalAuthenticationService.Object, new OidcLoginConfig(), _groupService.Object)).Model!; Assert.AreEqual(0, model.ExternalSignIn.Count); } } diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/LoginControllerProviderOnlyTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/LoginControllerProviderOnlyTests.cs new file mode 100644 index 000000000..0316220bf --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/LoginControllerProviderOnlyTests.cs @@ -0,0 +1,229 @@ +using Grand.Business.Core.Interfaces.Authentication; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Business.Core.Interfaces.Customers; +using Grand.Business.Core.Interfaces.Messages; +using Grand.Domain.Common; +using Grand.Domain.Customers; +using Grand.Infrastructure; +using Grand.Infrastructure.Configuration; +using Grand.Mediator; +using Grand.Web.Admin.Controllers; +using Grand.Web.AdminShared.Models.Common; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Routing; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Admin.Tests.Controllers; + +[TestClass] +public class LoginControllerProviderOnlyTests +{ + private Mock _externalAuthenticationService = null!; + private Mock _contextAccessor = null!; + private Mock _groupService = null!; + private Mock _customerManagerService = null!; + + [TestInitialize] + public void Setup() + { + _externalAuthenticationService = new Mock(); + _groupService = new Mock(); + var workContext = new Mock(); + workContext.Setup(x => x.CurrentCustomer).Returns(new Customer()); + _contextAccessor = new Mock(); + _contextAccessor.Setup(x => x.WorkContext).Returns(workContext.Object); + _customerManagerService = new Mock(); + } + + private LoginController CreateController(string area) + { + var controller = new LoginController(new CustomerSettings(), new CaptchaSettings(), + new Mock().Object, _customerManagerService.Object, + new Mock().Object, new Mock().Object, + new Mock().Object, _contextAccessor.Object, + new Mock().Object); + var httpContext = new DefaultHttpContext(); + httpContext.Request.PathBase = "/shop"; + controller.ControllerContext = new ControllerContext { + HttpContext = httpContext, + RouteData = new RouteData { Values = { ["area"] = area } } + }; + var url = new Mock(); + url.Setup(x => x.RouteUrl(It.IsAny())).Returns($"/shop/{area.ToLowerInvariant()}"); + controller.Url = url.Object; + return controller; + } + + private void WithSignInPath(string? path) + { + var provider = new Mock(); + provider.Setup(x => x.FriendlyName).Returns("OpenID Connect"); + provider.Setup(x => x.GetSignInPath(It.IsAny())).ReturnsAsync(path); + _externalAuthenticationService.Setup(x => x.LoadActiveAuthenticationProviders(null, null)) + .Returns([provider.Object]); + } + + private static OidcLoginConfig ProviderOnly() => new() { SignInMode = OidcSignInMode.ProviderOnly }; + + [TestMethod] + public async Task Index_ProviderOnly_Store_Redirects() + { + WithSignInPath("/oidc/login?returnUrl=%2Fshop%2Fstore"); + var controller = CreateController("Store"); + + var result = await controller.Index(_externalAuthenticationService.Object, ProviderOnly(), _groupService.Object); + + var redirect = result as RedirectResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("/shop/oidc/login?returnUrl=%2Fshop%2Fstore", redirect.Url); + } + + [TestMethod] + public async Task Index_ProviderOnly_Admin_Redirects() + { + WithSignInPath("/oidc/login?returnUrl=%2Fshop%2Fadmin"); + var controller = CreateController("Admin"); + + var result = await controller.Index(_externalAuthenticationService.Object, ProviderOnly(), _groupService.Object); + + var redirect = result as RedirectResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("/shop/oidc/login?returnUrl=%2Fshop%2Fadmin", redirect.Url); + } + + [TestMethod] + [DataRow("1")] + [DataRow("true")] + [DataRow("True")] + public async Task Index_ProviderOnly_Admin_PasswordParam_ShowsForm(string password) + { + WithSignInPath("/oidc/login?returnUrl=%2Fshop%2Fadmin"); + var controller = CreateController("Admin"); + + var result = await controller.Index(_externalAuthenticationService.Object, ProviderOnly(), _groupService.Object, + password); + + var model = (LoginModel)((ViewResult)result).Model!; + Assert.IsTrue(model.PasswordFormShown); + Assert.IsFalse(model.ExternalSignInUnavailable); + Assert.AreEqual(1, model.ExternalSignIn.Count); + } + + [TestMethod] + [DataRow("0")] + [DataRow("")] + [DataRow(null)] + public async Task Index_ProviderOnly_Admin_PasswordParamNotSet_Redirects(string? password) + { + WithSignInPath("/oidc/login?returnUrl=%2Fshop%2Fadmin"); + var controller = CreateController("Admin"); + + var result = await controller.Index(_externalAuthenticationService.Object, ProviderOnly(), _groupService.Object, + password); + + Assert.IsInstanceOfType(result); + } + + [TestMethod] + public async Task Index_NoSignInPath_Store_NoticeWithoutForm() + { + WithSignInPath(null); + var controller = CreateController("Store"); + + var result = await controller.Index(_externalAuthenticationService.Object, ProviderOnly(), _groupService.Object); + + var model = (LoginModel)((ViewResult)result).Model!; + Assert.IsTrue(model.ExternalSignInUnavailable); + Assert.IsFalse(model.PasswordFormShown); + } + + [TestMethod] + public async Task Index_NoSignInPath_Admin_NoticeWithForm() + { + WithSignInPath(null); + var controller = CreateController("Admin"); + + var result = await controller.Index(_externalAuthenticationService.Object, ProviderOnly(), _groupService.Object); + + var model = (LoginModel)((ViewResult)result).Model!; + Assert.IsTrue(model.ExternalSignInUnavailable); + Assert.IsTrue(model.PasswordFormShown); + } + + [TestMethod] + public async Task Index_Mixed_ShowsFormNoRedirect() + { + WithSignInPath("/oidc/login?returnUrl=%2Fshop%2Fadmin"); + var controller = CreateController("Admin"); + + var result = await controller.Index(_externalAuthenticationService.Object, new OidcLoginConfig(), _groupService.Object); + + var model = (LoginModel)((ViewResult)result).Model!; + Assert.IsTrue(model.PasswordFormShown); + Assert.IsFalse(model.ExternalSignInUnavailable); + } + + [TestMethod] + public async Task IndexPost_ProviderOnly_Admin_FailedAttempt_KeepsForm() + { + WithSignInPath("/oidc/login?returnUrl=%2Fshop%2Fadmin"); + _customerManagerService.Setup(x => x.LoginCustomer(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(CustomerLoginResults.WrongPassword); + var controller = CreateController("Admin"); + + var result = await controller.Index(new LoginModel { Email = "a@b.c", Password = "x" }, + _externalAuthenticationService.Object, ProviderOnly()); + + var model = (LoginModel)((ViewResult)result).Model!; + Assert.IsTrue(model.PasswordFormShown); + } + + [TestMethod] + public async Task IndexPost_ProviderOnly_Vendor_NoPasswordForm() + { + WithSignInPath(null); + var controller = CreateController("Vendor"); + + var result = await controller.Index(new LoginModel { Email = "a@b.c", Password = "x" }, + _externalAuthenticationService.Object, ProviderOnly()); + + var model = (LoginModel)((ViewResult)result).Model!; + Assert.IsFalse(model.PasswordFormShown); + Assert.IsTrue(model.ExternalSignInUnavailable); + } + + [TestMethod] + [DataRow("Store")] + [DataRow("Admin")] + public async Task Index_ProviderOnly_SignedInWithoutAccess_NoRedirect(string area) + { + WithSignInPath("/oidc/login?returnUrl=%2Fshop%2Fpanel"); + _groupService.Setup(x => x.IsRegistered(It.IsAny())).ReturnsAsync(true); + var controller = CreateController(area); + + var result = await controller.Index(_externalAuthenticationService.Object, ProviderOnly(), _groupService.Object); + + var model = (LoginModel)((ViewResult)result).Model!; + Assert.IsTrue(model.SignedInWithoutAccess); + Assert.AreEqual(area == "Admin", model.PasswordFormShown); + } + + [TestMethod] + public async Task Index_Mixed_SignedIn_ShowsForm() + { + WithSignInPath("/oidc/login?returnUrl=%2Fshop%2Fadmin"); + _groupService.Setup(x => x.IsRegistered(It.IsAny())).ReturnsAsync(true); + var controller = CreateController("Admin"); + + var result = await controller.Index(_externalAuthenticationService.Object, new OidcLoginConfig(), _groupService.Object); + + var model = (LoginModel)((ViewResult)result).Model!; + Assert.IsFalse(model.SignedInWithoutAccess); + Assert.IsTrue(model.PasswordFormShown); + } +} diff --git a/src/Tests/Grand.Web.Common.Tests/Security/ExternalRegistration/ProviderSignOutTests.cs b/src/Tests/Grand.Web.Common.Tests/Security/ExternalRegistration/ProviderSignOutTests.cs new file mode 100644 index 000000000..79a1081c2 --- /dev/null +++ b/src/Tests/Grand.Web.Common.Tests/Security/ExternalRegistration/ProviderSignOutTests.cs @@ -0,0 +1,169 @@ +using Grand.Business.Core.Interfaces.Authentication; +using Grand.Domain.Stores; +using Grand.Infrastructure; +using Grand.Infrastructure.Configuration; +using Grand.Web.Common.Security.ExternalRegistration; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Common.Tests.Security.ExternalRegistration; + +[TestClass] +public class ProviderSignOutTests +{ + private const string ProviderUrl = "https://idp.example/logout?x=1"; + + private Mock _auth; + private Mock _provider; + private Mock _contextAccessor; + private OidcLoginConfig _config; + private List _providers; + private DefaultHttpContext _httpContext; + + [TestInitialize] + public void Init() + { + _auth = new Mock(); + _provider = new Mock(); + _provider.Setup(x => x.GetSignOutUrl(It.IsAny())).ReturnsAsync(ProviderUrl); + _providers = [_provider.Object]; + _config = new OidcLoginConfig { SignInMode = OidcSignInMode.ProviderOnly }; + var storeContext = new Mock(); + storeContext.Setup(x => x.CurrentStore).Returns(new Store { Url = "https://shop.example" }); + _contextAccessor = new Mock(); + _contextAccessor.Setup(x => x.StoreContext).Returns(storeContext.Object); + _auth.Setup(x => x.HasOidcSession()).ReturnsAsync(true); + _httpContext = new DefaultHttpContext(); + } + + private ProviderSignOut Service() => + new(_auth.Object, _providers, _config, _contextAccessor.Object, + new HttpContextAccessor { HttpContext = _httpContext }, NullLogger.Instance); + + [TestMethod] + [DataRow("same-origin")] + [DataRow("none")] + public async Task SameOriginOrTypedNavigation_ReturnsProviderUrl(string site) + { + _httpContext.Request.Headers["Sec-Fetch-Site"] = site; + + Assert.AreEqual(ProviderUrl, await Service().SignOut()); + } + + [TestMethod] + [DataRow("cross-site")] + [DataRow("same-site")] + public async Task CrossSiteNavigation_SignsOutLocallyOnly(string site) + { + _httpContext.Request.Headers["Sec-Fetch-Site"] = site; + + Assert.IsNull(await Service().SignOut()); + + _provider.Verify(x => x.GetSignOutUrl(It.IsAny()), Times.Never); + _auth.Verify(x => x.SignOut(), Times.Once); + } + + [TestMethod] + public async Task ProviderOnly_SessionCookie_ReturnsProviderUrl() + { + var url = await Service().SignOut(); + + Assert.AreEqual(ProviderUrl, url); + _provider.Verify(x => x.GetSignOutUrl("https://shop.example/"), Times.Once); + _auth.Verify(x => x.SignOut(), Times.Once); + } + + [TestMethod] + public async Task StoreUrlWithTrailingSlash_TargetEndsWithOneSlash() + { + var storeContext = new Mock(); + storeContext.Setup(x => x.CurrentStore).Returns(new Store { Url = "https://shop.example/" }); + _contextAccessor.Setup(x => x.StoreContext).Returns(storeContext.Object); + + await Service().SignOut(); + + _provider.Verify(x => x.GetSignOutUrl("https://shop.example/"), Times.Once); + } + + [TestMethod] + public async Task UrlIsFetchedBeforeLocalSignOut() + { + var order = new List(); + _auth.Setup(x => x.HasOidcSession()).ReturnsAsync(true).Callback(() => order.Add("check")); + _provider.Setup(x => x.GetSignOutUrl(It.IsAny())).ReturnsAsync(ProviderUrl).Callback(() => order.Add("url")); + _auth.Setup(x => x.SignOut()).Returns(Task.CompletedTask).Callback(() => order.Add("signout")); + + await Service().SignOut(); + + CollectionAssert.AreEqual(new[] { "check", "url", "signout" }, order); + } + + [TestMethod] + public async Task Mixed_ReturnsNull_StillSignsOut() + { + _config.SignInMode = OidcSignInMode.Mixed; + + Assert.IsNull(await Service().SignOut()); + + _provider.Verify(x => x.GetSignOutUrl(It.IsAny()), Times.Never); + _auth.Verify(x => x.SignOut(), Times.Once); + } + + [TestMethod] + public async Task PlainCookie_ReturnsNull() + { + _auth.Setup(x => x.HasOidcSession()).ReturnsAsync(false); + + Assert.IsNull(await Service().SignOut()); + + _provider.Verify(x => x.GetSignOutUrl(It.IsAny()), Times.Never); + _auth.Verify(x => x.SignOut(), Times.Once); + } + + [TestMethod] + public async Task NoProvider_ReturnsNull() + { + _providers.Clear(); + + Assert.IsNull(await Service().SignOut()); + + _auth.Verify(x => x.SignOut(), Times.Once); + } + + [TestMethod] + public async Task ProviderThrows_ReturnsNull_StillSignsOut() + { + _provider.Setup(x => x.GetSignOutUrl(It.IsAny())).ThrowsAsync(new InvalidOperationException("discovery down")); + + Assert.IsNull(await Service().SignOut()); + + _auth.Verify(x => x.SignOut(), Times.Once); + } + + [TestMethod] + [DataRow("javascript:alert(1)")] + [DataRow("/relative/logout")] + [DataRow("ftp://idp.example/logout")] + public async Task UrlNotAbsoluteHttp_ReturnsNull_StillSignsOut(string url) + { + _provider.Setup(x => x.GetSignOutUrl(It.IsAny())).ReturnsAsync(url); + + Assert.IsNull(await Service().SignOut()); + + _auth.Verify(x => x.SignOut(), Times.Once); + } + + [TestMethod] + public async Task FirstProviderWithUrlWins() + { + var empty = new Mock(); + empty.Setup(x => x.GetSignOutUrl(It.IsAny())).ReturnsAsync((string)null); + _providers.Insert(0, empty.Object); + + Assert.AreEqual(ProviderUrl, await Service().SignOut()); + + _auth.Verify(x => x.SignOut(), Times.Once); + } +} diff --git a/src/Tests/Grand.Web.Tests/Commands/Customers/UpdateCustomerInfoCommandHandlerTests.cs b/src/Tests/Grand.Web.Tests/Commands/Customers/UpdateCustomerInfoCommandHandlerTests.cs new file mode 100644 index 000000000..3fa9f7e34 --- /dev/null +++ b/src/Tests/Grand.Web.Tests/Commands/Customers/UpdateCustomerInfoCommandHandlerTests.cs @@ -0,0 +1,50 @@ +using Grand.Business.Core.Interfaces.Authentication; +using Grand.Business.Core.Interfaces.Catalog.Tax; +using Grand.Business.Core.Interfaces.Customers; +using Grand.Business.Core.Interfaces.Marketing.Newsletters; +using Grand.Domain.Customers; +using Grand.Domain.Tax; +using Grand.Infrastructure.Configuration; +using Grand.Web.Commands.Handler.Customers; +using Grand.Web.Commands.Models.Customers; +using Grand.Web.Models.Customer; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Tests.Commands.Customers; + +[TestClass] +public class UpdateCustomerInfoCommandHandlerTests +{ + private Mock _customerService; + + private async Task Update(OidcLoginConfig config) + { + _customerService = new Mock(); + var handler = new UpdateCustomerInfoCommandHandler(new Mock().Object, + new Mock().Object, new Mock().Object, + _customerService.Object, new CustomerSettings { AllowUsersToChangeEmail = true }, new TaxSettings(), config); + var customer = new Customer { Id = "c1", Email = "old@x.com" }; + await handler.Handle(new UpdateCustomerInfoCommand { + Customer = customer, + Store = new Domain.Stores.Store { Id = "s1" }, + Model = new CustomerInfoModel { Email = "new@x.com", FirstName = "A", LastName = "B" }, + CustomerAttributes = [] + }, CancellationToken.None); + return customer; + } + + [TestMethod] + public async Task UpdateInfo_ProviderOnly_EmailNotChanged() + { + var customer = await Update(new OidcLoginConfig { SignInMode = OidcSignInMode.ProviderOnly }); + Assert.AreEqual("old@x.com", customer.Email); + } + + [TestMethod] + public async Task UpdateInfo_Mixed_EmailChanged() + { + var customer = await Update(new OidcLoginConfig()); + Assert.AreEqual("new@x.com", customer.Email); + } +} diff --git a/src/Tests/Grand.Web.Tests/Controllers/AccountControllerExternalRegisterTests.cs b/src/Tests/Grand.Web.Tests/Controllers/AccountControllerExternalRegisterTests.cs index 41afa9a64..84cd7f97d 100644 --- a/src/Tests/Grand.Web.Tests/Controllers/AccountControllerExternalRegisterTests.cs +++ b/src/Tests/Grand.Web.Tests/Controllers/AccountControllerExternalRegisterTests.cs @@ -79,7 +79,7 @@ public void Init() public async Task Get_Pending_PrefillsAndMarksExternal() { _external.Setup(x => x.GetPending()).ReturnsAsync(Pending); - var view = (ViewResult)(await _controller.Register(_external.Object, _externalAuth.Object)).Result; + var view = (ViewResult)(await _controller.Register(_external.Object, _externalAuth.Object, new OidcLoginConfig())).Result; var model = (RegisterModel)view.Model; Assert.AreEqual("john@example.com", model.Email); Assert.AreEqual("John", model.FirstName); @@ -94,7 +94,7 @@ public async Task Post_Pending_UsesPendingEmail_RandomPassword_Links() RegistrationRequest request = null; _manager.Setup(x => x.RegisterCustomer(It.IsAny())).Callback(r => request = r); await _controller.Register(new RegisterModel { Email = "evil@b.c", ExternalRegistration = false }, null, - _external.Object, _externalAuth.Object); + _external.Object, _externalAuth.Object, new OidcLoginConfig()); Assert.AreEqual("john@example.com", request.Email); Assert.IsFalse(string.IsNullOrEmpty(request.Password)); _external.Verify(x => x.Complete(_guest, "s1"), Times.Once); @@ -111,7 +111,7 @@ public async Task Post_Pending_ApprovalFollowsPolicy(UserRegistrationType type, _external.Setup(x => x.GetPending()).ReturnsAsync(Pending with { EmailVerified = verified }); RegistrationRequest request = null; _manager.Setup(x => x.RegisterCustomer(It.IsAny())).Callback(r => request = r); - await _controller.Register(new RegisterModel(), null, _external.Object, _externalAuth.Object); + await _controller.Register(new RegisterModel(), null, _external.Object, _externalAuth.Object, new OidcLoginConfig()); Assert.AreEqual(approved, request.IsApproved); } @@ -120,7 +120,7 @@ public async Task Post_Pending_Unverified_SendsActivation() { _settings.UserRegistrationType = UserRegistrationType.Standard; _external.Setup(x => x.GetPending()).ReturnsAsync(Pending with { EmailVerified = false }); - var result = await _controller.Register(new RegisterModel(), null, _external.Object, _externalAuth.Object); + var result = await _controller.Register(new RegisterModel(), null, _external.Object, _externalAuth.Object, new OidcLoginConfig()); _messages.Verify(x => x.SendCustomerEmailValidationMessage(_guest, It.IsAny(), "l1"), Times.Once); Assert.AreEqual((int)UserRegistrationType.EmailValidation, ((RedirectToRouteResult)result).RouteValues["resultId"]); } @@ -132,7 +132,7 @@ public async Task Post_Pending_Unverified_TypedPassword_NotConnected() _external.Setup(x => x.GetPending()).ReturnsAsync(Pending with { EmailVerified = false }); RegistrationRequest request = null; _manager.Setup(x => x.RegisterCustomer(It.IsAny())).Callback(r => request = r); - await _controller.Register(new RegisterModel { Password = "Secret1!" }, null, _external.Object, _externalAuth.Object); + await _controller.Register(new RegisterModel { Password = "Secret1!" }, null, _external.Object, _externalAuth.Object, new OidcLoginConfig()); Assert.AreEqual("john@example.com", request.Email); Assert.AreEqual("Secret1!", request.Password); _external.Verify(x => x.Complete(It.IsAny(), It.IsAny()), Times.Never); @@ -143,7 +143,7 @@ public async Task Post_Pending_Unverified_TypedPassword_NotConnected() public async Task Get_Pending_Unverified_AsksForPassword() { _external.Setup(x => x.GetPending()).ReturnsAsync(Pending with { EmailVerified = false }); - var view = (ViewResult)(await _controller.Register(_external.Object, _externalAuth.Object)).Result; + var view = (ViewResult)(await _controller.Register(_external.Object, _externalAuth.Object, new OidcLoginConfig())).Result; var model = (RegisterModel)view.Model; Assert.IsTrue(model.ExternalRegistration); Assert.IsFalse(model.ExternalConnects); @@ -154,7 +154,7 @@ public async Task Post_Pending_LinkFails_WarnsCustomer() { _external.Setup(x => x.GetPending()).ReturnsAsync(Pending); _external.Setup(x => x.Complete(_guest, "s1")).ReturnsAsync(OidcLinkResult.SubjectLinkedToOtherCustomer); - await _controller.Register(new RegisterModel(), null, _external.Object, _externalAuth.Object); + await _controller.Register(new RegisterModel(), null, _external.Object, _externalAuth.Object, new OidcLoginConfig()); Assert.IsNotNull(_controller.TempData["grand.notifications.Warning"]); } @@ -164,7 +164,7 @@ public async Task Post_NoPending_RegularPath_NoLink() _external.Setup(x => x.GetPending()).ReturnsAsync((ExternalRegistrationIdentity)null); RegistrationRequest request = null; _manager.Setup(x => x.RegisterCustomer(It.IsAny())).Callback(r => request = r); - await _controller.Register(new RegisterModel { Email = "a@b.c", Password = "Secret1!" }, null, _external.Object, _externalAuth.Object); + await _controller.Register(new RegisterModel { Email = "a@b.c", Password = "Secret1!" }, null, _external.Object, _externalAuth.Object, new OidcLoginConfig()); Assert.AreEqual("Secret1!", request.Password); _external.Verify(x => x.Complete(It.IsAny(), It.IsAny()), Times.Never); } @@ -185,7 +185,7 @@ public async Task Post_Pending_LinkedAndSignedIn_GoesThroughProviderSignIn() _external.Setup(x => x.Complete(_guest, "s1")).ReturnsAsync(OidcLinkResult.Linked); var provider = PathProvider(); - var result = (RedirectResult)await _controller.Register(new RegisterModel(), "/cart", _external.Object, _externalAuth.Object); + var result = (RedirectResult)await _controller.Register(new RegisterModel(), "/cart", _external.Object, _externalAuth.Object, new OidcLoginConfig()); Assert.AreEqual("/oidc/login?returnUrl=%2Fregisterresult%2F1", result.Url); provider.Verify(x => x.GetSignInPath(It.Is(u => @@ -199,7 +199,7 @@ public async Task Post_Pending_NotApproved_NoProviderRedirect() _external.Setup(x => x.GetPending()).ReturnsAsync(Pending); _external.Setup(x => x.Complete(_guest, "s1")).ReturnsAsync(OidcLinkResult.Linked); var provider = PathProvider(); - var result = await _controller.Register(new RegisterModel(), null, _external.Object, _externalAuth.Object); + var result = await _controller.Register(new RegisterModel(), null, _external.Object, _externalAuth.Object, new OidcLoginConfig()); Assert.IsInstanceOfType(result, typeof(RedirectToRouteResult)); provider.Verify(x => x.GetSignInPath(It.IsAny()), Times.Never); } @@ -211,7 +211,7 @@ public async Task Post_Pending_NotLinked_NoProviderRedirect() _external.Setup(x => x.GetPending()).ReturnsAsync(Pending); _external.Setup(x => x.Complete(_guest, "s1")).ReturnsAsync(OidcLinkResult.SubjectLinkedToOtherCustomer); var provider = PathProvider(); - var result = (RedirectResult)await _controller.Register(new RegisterModel(), null, _external.Object, _externalAuth.Object); + var result = (RedirectResult)await _controller.Register(new RegisterModel(), null, _external.Object, _externalAuth.Object, new OidcLoginConfig()); Assert.IsFalse(result.Url.Contains("/oidc/login")); provider.Verify(x => x.GetSignInPath(It.IsAny()), Times.Never); } @@ -224,7 +224,7 @@ public async Task Post_Pending_ProviderWithoutSignInPath_NoProviderRedirect() _external.Setup(x => x.Complete(_guest, "s1")).ReturnsAsync(OidcLinkResult.Linked); _externalAuth.Setup(x => x.LoadAuthenticationProviderBySystemName("ExternalAuth.OpenIdConnect")) .Returns(new Mock().Object); - var result = (RedirectResult)await _controller.Register(new RegisterModel(), null, _external.Object, _externalAuth.Object); + var result = (RedirectResult)await _controller.Register(new RegisterModel(), null, _external.Object, _externalAuth.Object, new OidcLoginConfig()); Assert.AreEqual("https://localhost/registerresult/1", result.Url); } @@ -235,7 +235,7 @@ public async Task Post_Pending_NullSignInPath_NoProviderRedirect() _external.Setup(x => x.GetPending()).ReturnsAsync(Pending); _external.Setup(x => x.Complete(_guest, "s1")).ReturnsAsync(OidcLinkResult.Linked); PathProvider(null); - var result = (RedirectResult)await _controller.Register(new RegisterModel(), null, _external.Object, _externalAuth.Object); + var result = (RedirectResult)await _controller.Register(new RegisterModel(), null, _external.Object, _externalAuth.Object, new OidcLoginConfig()); Assert.AreEqual("https://localhost/registerresult/1", result.Url); } } diff --git a/src/Tests/Grand.Web.Tests/Controllers/AccountControllerProviderOnlyTests.cs b/src/Tests/Grand.Web.Tests/Controllers/AccountControllerProviderOnlyTests.cs new file mode 100644 index 000000000..70aac4069 --- /dev/null +++ b/src/Tests/Grand.Web.Tests/Controllers/AccountControllerProviderOnlyTests.cs @@ -0,0 +1,410 @@ +using Grand.Business.Core.Interfaces.Authentication; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Business.Core.Interfaces.Customers; +using Grand.Business.Core.Interfaces.Messages; +using Grand.Business.Core.Utilities.Customers; +using Grand.Domain.Common; +using Grand.Domain.Customers; +using Grand.Domain.Localization; +using Grand.Infrastructure; +using Grand.Infrastructure.Configuration; +using Grand.Mediator; +using Grand.Web.Common.Security.ExternalRegistration; +using Grand.Web.Controllers; +using Grand.Web.Features.Models.Customers; +using Grand.Web.Models.Customer; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Tests.Controllers; + +[TestClass] +public class AccountControllerProviderOnlyTests +{ + private static readonly ExternalRegistrationIdentity Pending = + new("ExternalAuth.OpenIdConnect", "sub-1", "john@example.com", true, "John", "Doe"); + + private Mock _provider; + private Mock _externalAuth; + private Mock _external; + private Mock _customerManager; + private Mock _customers; + private OidcLoginConfig _providerOnly; + private Mock _groups; + private Customer _currentCustomer; + private CustomerSettings _customerSettings; + private AccountController _controller; + + [TestInitialize] + public void Init() + { + _provider = new Mock(); + _provider.Setup(p => p.SystemName).Returns("ExternalAuth.OpenIdConnect"); + _externalAuth = new Mock(); + _externalAuth.Setup(x => x.LoadActiveAuthenticationProviders(It.IsAny(), It.IsAny())) + .Returns(new List { _provider.Object }); + _external = new Mock(); + _providerOnly = new OidcLoginConfig { SignInMode = OidcSignInMode.ProviderOnly }; + + var work = new Mock(); + _currentCustomer = new Customer { Id = "g1" }; + work.Setup(w => w.CurrentCustomer).Returns(_currentCustomer); + work.Setup(w => w.WorkingLanguage).Returns(new Language { Id = "l1" }); + var store = new Mock(); + store.Setup(s => s.CurrentStore).Returns(new Grand.Domain.Stores.Store { Id = "s1" }); + var context = new Mock(); + context.Setup(c => c.WorkContext).Returns(work.Object); + context.Setup(c => c.StoreContext).Returns(store.Object); + + var mediator = new Mock(); + mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) + .ReturnsAsync(new RegisterModel()); + _groups = new Mock(); + _groups.Setup(g => g.IsRegistered(It.IsAny())).ReturnsAsync(false); + + _customerManager = new Mock(); + _customers = new Mock(); + _controller = new AccountController(new Mock().Object, + new Mock().Object, context.Object, _customers.Object, + _groups.Object, _customerManager.Object, new Mock().Object, + mediator.Object, new Mock().Object, new CaptchaSettings(), + _customerSettings = new CustomerSettings { + UserRegistrationType = UserRegistrationType.Standard, TwoFactorAuthenticationEnabled = true, + AllowUsersToDeleteAccount = true + }, new CustomerConfig()); + _controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }; + _controller.TempData = new TempDataDictionary(_controller.HttpContext, new Mock().Object); + var url = new Mock(); + url.Setup(x => x.RouteUrl(It.IsAny())).Returns("https://localhost/registerresult/1"); + url.Setup(x => x.IsLocalUrl(It.IsAny())).Returns((string u) => + !string.IsNullOrEmpty(u) && u.StartsWith('/') && !u.StartsWith("//")); + _controller.Url = url.Object; + } + + [TestMethod] + public async Task Login_ProviderOnly_RedirectsToProvider() + { + _provider.Setup(p => p.GetSignInPath("/cart")).ReturnsAsync("/oidc/login?returnUrl=%2Fcart"); + var result = await _controller.Login(null, "/cart", _providerOnly, _externalAuth.Object); + Assert.AreEqual("/oidc/login?returnUrl=%2Fcart", ((RedirectResult)result).Url); + } + + [TestMethod] + public async Task Login_ProviderOnly_PathBaseIsPrepended() + { + _controller.HttpContext.Request.PathBase = "/shop"; + _provider.Setup(p => p.GetSignInPath("/cart")).ReturnsAsync("/oidc/login?returnUrl=%2Fcart"); + var result = await _controller.Login(null, "/cart", _providerOnly, _externalAuth.Object); + Assert.AreEqual("/shop/oidc/login?returnUrl=%2Fcart", ((RedirectResult)result).Url); + } + + [TestMethod] + public async Task Login_ExternalReturnUrl_NotForwarded() + { + _provider.Setup(p => p.GetSignInPath(It.IsAny())).ReturnsAsync("/oidc/login"); + var result = await _controller.Login(null, "https://evil.example", _providerOnly, _externalAuth.Object); + Assert.AreEqual("/oidc/login", ((RedirectResult)result).Url); + _provider.Verify(p => p.GetSignInPath(null), Times.Once); + _provider.Verify(p => p.GetSignInPath("https://evil.example"), Times.Never); + } + + [TestMethod] + public async Task Login_NoSignInPath_ShowsNotice() + { + _provider.Setup(p => p.GetSignInPath(It.IsAny())).ReturnsAsync((string)null); + var result = await _controller.Login(null, "/cart", _providerOnly, _externalAuth.Object); + var model = (LoginModel)((ViewResult)result).Model; + Assert.IsTrue(model.ExternalSignInUnavailable); + } + + [TestMethod] + public async Task Login_ProviderOnly_SignedIn_ShowsSignOutNotice_ProviderNotAsked() + { + _groups.Setup(g => g.IsRegistered(It.IsAny())).ReturnsAsync(true); + _provider.Setup(p => p.GetSignInPath(It.IsAny())).ReturnsAsync("/oidc/login"); + + var result = await _controller.Login(null, "/cart", _providerOnly, _externalAuth.Object); + + var model = (LoginModel)((ViewResult)result).Model; + Assert.IsTrue(model.SignedInWithoutAccess); + _provider.Verify(p => p.GetSignInPath(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task Login_Mixed_SignedIn_ShowsForm() + { + _groups.Setup(g => g.IsRegistered(It.IsAny())).ReturnsAsync(true); + + var result = await _controller.Login(null, "/cart", new OidcLoginConfig(), _externalAuth.Object); + + var model = (LoginModel)((ViewResult)result).Model; + Assert.IsFalse(model.SignedInWithoutAccess); + Assert.IsFalse(model.ExternalSignInUnavailable); + } + + [TestMethod] + public async Task Login_ProviderOnly_FirstProviderWithoutPath_UsesNextProvider() + { + var first = new Mock(); + first.Setup(p => p.GetSignInPath(It.IsAny())).ReturnsAsync((string)null); + _provider.Setup(p => p.GetSignInPath("/cart")).ReturnsAsync("/oidc/login?returnUrl=%2Fcart"); + _externalAuth.Setup(x => x.LoadActiveAuthenticationProviders(It.IsAny(), It.IsAny())) + .Returns(new List { first.Object, _provider.Object }); + + var result = await _controller.Login(null, "/cart", _providerOnly, _externalAuth.Object); + + Assert.AreEqual("/oidc/login?returnUrl=%2Fcart", ((RedirectResult)result).Url); + } + + [TestMethod] + public async Task Login_Mixed_ShowsForm() + { + var result = await _controller.Login(null, "/cart", new OidcLoginConfig(), _externalAuth.Object); + var model = (LoginModel)((ViewResult)result).Model; + Assert.IsFalse(model.ExternalSignInUnavailable); + _provider.Verify(p => p.GetSignInPath(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task Register_ProviderOnly_NoPending_RedirectsToProvider() + { + _external.Setup(x => x.GetPending()).ReturnsAsync((ExternalRegistrationIdentity)null); + _provider.Setup(p => p.GetSignInPath("/cart")).ReturnsAsync("/oidc/login?returnUrl=%2Fcart"); + var result = (await _controller.Register(_external.Object, _externalAuth.Object, _providerOnly, "/cart")).Result; + Assert.AreEqual("/oidc/login?returnUrl=%2Fcart", ((RedirectResult)result).Url); + } + + [TestMethod] + public async Task Register_AutoProvision_RegistrationDisabled_RedirectsToProvider() + { + _customerSettings.UserRegistrationType = UserRegistrationType.Disabled; + _providerOnly.AutoProvision = true; + _external.Setup(x => x.GetPending()).ReturnsAsync((ExternalRegistrationIdentity)null); + _provider.Setup(p => p.GetSignInPath("/cart")).ReturnsAsync("/oidc/login?returnUrl=%2Fcart"); + var result = (await _controller.Register(_external.Object, _externalAuth.Object, _providerOnly, "/cart")).Result; + Assert.AreEqual("/oidc/login?returnUrl=%2Fcart", ((RedirectResult)result).Url); + } + + [TestMethod] + public async Task Register_AutoProvision_Pending_RedirectsToProvider_NoForm() + { + _providerOnly.AutoProvision = true; + _external.Setup(x => x.GetPending()).ReturnsAsync(Pending); + _provider.Setup(p => p.GetSignInPath(It.IsAny())).ReturnsAsync("/oidc/login"); + var result = (await _controller.Register(_external.Object, _externalAuth.Object, _providerOnly)).Result; + Assert.AreEqual("/oidc/login", ((RedirectResult)result).Url); + } + + [TestMethod] + public async Task Register_ProviderOnly_RegistrationDisabled_WithoutAutoProvision_RegisterResult() + { + _customerSettings.UserRegistrationType = UserRegistrationType.Disabled; + var result = (await _controller.Register(_external.Object, _externalAuth.Object, _providerOnly)).Result; + Assert.AreEqual("RegisterResult", ((RedirectToRouteResult)result).RouteName); + } + + [TestMethod] + public async Task Register_ProviderOnly_NoPending_NoSignInPath_ShowsNotice() + { + _external.Setup(x => x.GetPending()).ReturnsAsync((ExternalRegistrationIdentity)null); + _provider.Setup(p => p.GetSignInPath(It.IsAny())).ReturnsAsync((string)null); + var view = (ViewResult)(await _controller.Register(_external.Object, _externalAuth.Object, _providerOnly)).Result; + Assert.AreEqual("Login", view.ViewName); + Assert.IsTrue(((LoginModel)view.Model).ExternalSignInUnavailable); + } + + [TestMethod] + public async Task Register_ProviderOnly_Pending_ShowsForm() + { + _external.Setup(x => x.GetPending()).ReturnsAsync(Pending); + var view = (ViewResult)(await _controller.Register(_external.Object, _externalAuth.Object, _providerOnly)).Result; + var model = (RegisterModel)view.Model; + Assert.IsTrue(model.ExternalRegistration); + Assert.AreEqual("john@example.com", model.Email); + _provider.Verify(p => p.GetSignInPath(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task RegisterPost_ProviderOnly_UnverifiedPending_RedirectsToLogin() + { + _external.Setup(x => x.GetPending()).ReturnsAsync(Pending with { EmailVerified = false }); + var result = await _controller.Register(new RegisterModel(), null, _external.Object, _externalAuth.Object, _providerOnly); + Assert.AreEqual("Login", ((RedirectToRouteResult)result).RouteName); + } + + [TestMethod] + public async Task RegisterPost_ProviderOnly_NoPending_RedirectsToLogin() + { + _external.Setup(x => x.GetPending()).ReturnsAsync((ExternalRegistrationIdentity)null); + var result = await _controller.Register(new RegisterModel(), null, _external.Object, _externalAuth.Object, _providerOnly); + Assert.AreEqual("Login", ((RedirectToRouteResult)result).RouteName); + } + + [TestMethod] + public async Task RegisterExternalCancel_ProviderOnly_ClearsPendingAndGoesHome() + { + var result = await _controller.RegisterExternalCancel(_external.Object, _providerOnly); + + Assert.AreEqual("HomePage", ((RedirectToRouteResult)result).RouteName); + _external.Verify(x => x.Clear(), Times.Once); + } + + [TestMethod] + public async Task RegisterExternalCancel_Mixed_BackToRegister() + { + var result = await _controller.RegisterExternalCancel(_external.Object, new OidcLoginConfig()); + + Assert.AreEqual("Register", ((RedirectToRouteResult)result).RouteName); + _external.Verify(x => x.Clear(), Times.Once); + } + + [TestMethod] + public void PasswordRecovery_ProviderOnly_RedirectsToLogin() + { + var result = _controller.PasswordRecovery(_providerOnly); + Assert.AreEqual("Login", ((RedirectToRouteResult)result).RouteName); + } + + [TestMethod] + public async Task PasswordRecoveryPost_ProviderOnly_RedirectsToLogin() + { + var result = await _controller.PasswordRecovery(new PasswordRecoveryModel { Email = "a@b.c" }, _providerOnly); + Assert.AreEqual("Login", ((RedirectToRouteResult)result.Result).RouteName); + } + + [TestMethod] + public async Task PasswordRecoveryConfirm_ProviderOnly_RedirectsToLogin() + { + var get = await _controller.PasswordRecoveryConfirm("t", "a@b.c", _providerOnly); + Assert.AreEqual("Login", ((RedirectToRouteResult)get.Result).RouteName); + var post = await _controller.PasswordRecoveryConfirm(new PasswordRecoveryConfirmModel(), _providerOnly); + Assert.AreEqual("Login", ((RedirectToRouteResult)post).RouteName); + } + + [TestMethod] + public async Task ChangePassword_ProviderOnly_Redirects() + { + var get = await _controller.ChangePassword(_providerOnly); + Assert.AreEqual("CustomerInfo", ((RedirectToRouteResult)get).RouteName); + var post = await _controller.ChangePassword( + new ChangePasswordModel { OldPassword = "a", NewPassword = "b", ConfirmNewPassword = "b" }, _providerOnly); + Assert.AreEqual("CustomerInfo", ((RedirectToRouteResult)post).RouteName); + _customerManager.Verify(m => m.ChangePassword(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ChangePassword_Mixed_ChangesPassword() + { + _customers.Setup(c => c.GetCustomerById(It.IsAny())).ReturnsAsync(new Customer { Id = "g1" }); + var post = await _controller.ChangePassword( + new ChangePasswordModel { OldPassword = "a", NewPassword = "b", ConfirmNewPassword = "b" }, + new OidcLoginConfig()); + Assert.IsInstanceOfType(post); + _customerManager.Verify(m => m.ChangePassword(It.IsAny()), Times.Once); + } + + [TestMethod] + public async Task EnableTwoFactor_ProviderOnly_Redirects() + { + var model = new CustomerInfoModel.TwoFactorAuthenticationModel { Code = "123456", SecretKey = "k" }; + var get = await _controller.EnableTwoFactorAuthenticator(_providerOnly); + Assert.AreEqual("CustomerInfo", ((RedirectToRouteResult)get).RouteName); + var post = await _controller.EnableTwoFactorAuthenticator(model, + new Mock().Object, _providerOnly); + Assert.AreEqual("CustomerInfo", ((RedirectToRouteResult)post).RouteName); + _customers.Verify(c => c.UpdateUserField(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + _customers.Verify(c => c.UpdateUserField(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task EnableTwoFactor_Mixed_ShowsForm() + { + var get = await _controller.EnableTwoFactorAuthenticator(new OidcLoginConfig()); + Assert.IsInstanceOfType(get); + } + + private void TwoFactorEnabled() => + _currentCustomer.UserFields.Add(new UserField + { Key = SystemCustomerFieldNames.TwoFactorEnabled, Value = "True", StoreId = "" }); + + [TestMethod] + public async Task DisableTwoFactor_ProviderOnly_Redirects() + { + //two-factor is on: only the provider-only guard can send the customer away + TwoFactorEnabled(); + var get = await _controller.DisableTwoFactorAuthenticator(_providerOnly); + Assert.AreEqual("CustomerInfo", ((RedirectToRouteResult)get).RouteName); + var post = await _controller.DisableTwoFactorAuthenticator( + new CustomerInfoModel.TwoFactorAuthorizationModel { Code = "123456" }, + new Mock().Object, _providerOnly); + Assert.AreEqual("CustomerInfo", ((RedirectToRouteResult)post).RouteName); + _customers.Verify(c => c.UpdateUserField(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task DisableTwoFactor_Mixed_ShowsForm() + { + TwoFactorEnabled(); + var get = await _controller.DisableTwoFactorAuthenticator(new OidcLoginConfig()); + Assert.IsInstanceOfType(get); + } + + private void ExistingLinks(params string[] providers) + { + _externalAuth.Setup(x => x.GetExternalIdentifiers(It.IsAny())).ReturnsAsync( + (IList)providers + .Select(p => new ExternalAuthentication { Id = p, ProviderSystemName = p }).ToList()); + } + + [TestMethod] + public async Task RemoveExternalAssociation_ProviderOnly_OidcKept() + { + ExistingLinks("ExternalAuth.OpenIdConnect", "ExternalAuth.Google"); + await _controller.RemoveExternalAssociation("ExternalAuth.OpenIdConnect", _externalAuth.Object, _providerOnly); + _externalAuth.Verify(x => x.DeleteExternalAuthentication(It.IsAny()), Times.Never); + + await _controller.RemoveExternalAssociation("ExternalAuth.Google", _externalAuth.Object, _providerOnly); + _externalAuth.Verify(x => x.DeleteExternalAuthentication( + It.Is(e => e.Id == "ExternalAuth.Google")), Times.Once); + } + + [TestMethod] + public async Task RemoveExternalAssociation_Mixed_OidcRemoved() + { + ExistingLinks("ExternalAuth.OpenIdConnect"); + await _controller.RemoveExternalAssociation("ExternalAuth.OpenIdConnect", _externalAuth.Object, + new OidcLoginConfig()); + _externalAuth.Verify(x => x.DeleteExternalAuthentication( + It.Is(e => e.Id == "ExternalAuth.OpenIdConnect")), Times.Once); + } + + [TestMethod] + public async Task DeleteAccount_ProviderOnly_AsksNoPassword_AlsoOnRedisplay() + { + var get = (DeleteAccountModel)((ViewResult)_controller.DeleteAccount(_providerOnly)).Model; + Assert.IsFalse(get.PasswordRequired); + + _controller.ModelState.AddModelError("", "failed"); + var post = (DeleteAccountModel)((ViewResult)await _controller.DeleteAccount(new DeleteAccountModel(), _providerOnly)).Model; + Assert.IsFalse(post.PasswordRequired); + } + + [TestMethod] + public void DeleteAccount_Mixed_AsksPassword() + { + var get = (DeleteAccountModel)((ViewResult)_controller.DeleteAccount(new OidcLoginConfig())).Model; + Assert.IsTrue(get.PasswordRequired); + } + + [TestMethod] + public void PasswordRecovery_Mixed_ShowsForm() + { + var result = _controller.PasswordRecovery(new OidcLoginConfig()); + Assert.IsInstanceOfType(result); + } +} diff --git a/src/Tests/Grand.Web.Tests/Features/Handlers/Customers/GetInfoHandlerTests.cs b/src/Tests/Grand.Web.Tests/Features/Handlers/Customers/GetInfoHandlerTests.cs new file mode 100644 index 000000000..18910ba29 --- /dev/null +++ b/src/Tests/Grand.Web.Tests/Features/Handlers/Customers/GetInfoHandlerTests.cs @@ -0,0 +1,90 @@ +using Grand.Business.Core.Interfaces.Authentication; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Business.Core.Interfaces.Marketing.Newsletters; +using Grand.Domain.Customers; +using Grand.Domain.Localization; +using Grand.Domain.Messages; +using Grand.Domain.Tax; +using Grand.Infrastructure.Configuration; +using Grand.Mediator; +using Grand.Web.Common.Localization; +using Grand.Web.Features.Handlers.Customers; +using Grand.Web.Features.Models.Customers; +using Grand.Web.Models.Customer; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Tests.Features.Handlers.Customers; + +[TestClass] +public class GetInfoHandlerTests +{ + private const string Oidc = "ExternalAuth.OpenIdConnect"; + private const string Google = "ExternalAuth.Google"; + + private static async Task Handle(OidcLoginConfig config) + { + var external = new Mock(); + external.Setup(x => x.LoadActiveAuthenticationProviders(It.IsAny(), It.IsAny())) + .Returns(new List()); + external.Setup(x => x.GetExternalIdentifiers(It.IsAny())).ReturnsAsync( + (IList)new List { + new() { Id = "1", ProviderSystemName = Oidc }, + new() { Id = "2", ProviderSystemName = Google } + }); + external.Setup(x => x.AuthenticationProviderIsAvailable(It.IsAny())).Returns(true); + var providerMock = new Mock(); + providerMock.Setup(p => p.FriendlyName).Returns("Provider"); + external.Setup(x => x.LoadAuthenticationProviderBySystemName(It.IsAny())).Returns(providerMock.Object); + + var mediator = new Mock(); + mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List()); + var newsletterCategories = new Mock(); + newsletterCategories.Setup(s => s.GetAllNewsletterCategory()).ReturnsAsync(new List()); + + var handler = new GetInfoHandler(new Mock().Object, + newsletterCategories.Object, new Mock().Object, new Mock().Object, + external.Object, mediator.Object, + new CustomerSettings { AllowUsersToChangeEmail = true, TwoFactorAuthenticationEnabled = true }, + new TaxSettings(), new Mock().Object, config); + + return await handler.Handle(new GetInfo { + Customer = new Customer { Id = "c1", Email = "a@b.c" }, + Store = new Domain.Stores.Store { Id = "s1" }, + Language = new Language { Id = "l1" } + }, CancellationToken.None); + } + + [TestMethod] + public async Task Info_ProviderOnly_EmailReadOnlyTwoFactorHidden() + { + var model = await Handle(new OidcLoginConfig { SignInMode = OidcSignInMode.ProviderOnly }); + Assert.IsFalse(model.AllowUsersToChangeEmail); + Assert.IsFalse(model.TwoFactorAvailable); + } + + [TestMethod] + public async Task Info_Mixed_EmailEditableTwoFactorShown() + { + var model = await Handle(new OidcLoginConfig()); + Assert.IsTrue(model.AllowUsersToChangeEmail); + Assert.IsTrue(model.TwoFactorAvailable); + } + + [TestMethod] + public async Task Info_ProviderOnly_OidcRecordNotRemovable_GoogleRemovable() + { + var model = await Handle(new OidcLoginConfig { SignInMode = OidcSignInMode.ProviderOnly }); + Assert.IsFalse(model.AssociatedExternalAuthRecords.Single(x => x.Id == "1").CanRemove); + Assert.IsTrue(model.AssociatedExternalAuthRecords.Single(x => x.Id == "2").CanRemove); + } + + [TestMethod] + public async Task Info_Mixed_AllRecordsRemovable() + { + var model = await Handle(new OidcLoginConfig()); + Assert.IsTrue(model.AssociatedExternalAuthRecords.All(x => x.CanRemove)); + } +} diff --git a/src/Tests/Grand.Web.Tests/Features/Handlers/Customers/GetNavigationHandlerTests.cs b/src/Tests/Grand.Web.Tests/Features/Handlers/Customers/GetNavigationHandlerTests.cs new file mode 100644 index 000000000..564268b95 --- /dev/null +++ b/src/Tests/Grand.Web.Tests/Features/Handlers/Customers/GetNavigationHandlerTests.cs @@ -0,0 +1,33 @@ +using Grand.Domain.Customers; +using Grand.Domain.Orders; +using Grand.Domain.Vendors; +using Grand.Infrastructure.Configuration; +using Grand.Web.Features.Handlers.Customers; +using Grand.Web.Features.Models.Customers; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Grand.Web.Tests.Features.Handlers.Customers; + +[TestClass] +public class GetNavigationHandlerTests +{ + private static async Task HideChangePassword(OidcLoginConfig config) + { + var handler = new GetNavigationHandler(new CustomerSettings(), new LoyaltyPointsSettings(), + new OrderSettings(), new VendorSettings(), config); + var model = await handler.Handle(new GetNavigation { Customer = new Customer() }, CancellationToken.None); + return model.HideChangePassword; + } + + [TestMethod] + public async Task Navigation_ProviderOnly_HidesChangePassword() + { + Assert.IsTrue(await HideChangePassword(new OidcLoginConfig { SignInMode = OidcSignInMode.ProviderOnly })); + } + + [TestMethod] + public async Task Navigation_Mixed_ShowsChangePassword() + { + Assert.IsFalse(await HideChangePassword(new OidcLoginConfig())); + } +} diff --git a/src/Tests/Grand.Web.Tests/Validators/Customer/DeleteAccountValidatorTests.cs b/src/Tests/Grand.Web.Tests/Validators/Customer/DeleteAccountValidatorTests.cs new file mode 100644 index 000000000..2c01e0f24 --- /dev/null +++ b/src/Tests/Grand.Web.Tests/Validators/Customer/DeleteAccountValidatorTests.cs @@ -0,0 +1,55 @@ +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Business.Core.Interfaces.Common.Security; +using Grand.Domain.Customers; +using Grand.Infrastructure; +using Grand.Infrastructure.Configuration; +using Grand.Infrastructure.Validators; +using Grand.Web.Models.Customer; +using Grand.Web.Validators.Customer; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Tests.Validators.Customers; + +[TestClass] +public class DeleteAccountValidatorTests +{ + private static DeleteAccountValidator Validator(OidcLoginConfig config, bool passwordMatches) + { + var encryption = new Mock(); + encryption.Setup(e => e.VerifyPassword(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Returns(passwordMatches); + var work = new Mock(); + work.Setup(w => w.CurrentCustomer).Returns(new Customer { Password = "pw" }); + var context = new Mock(); + context.Setup(c => c.WorkContext).Returns(work.Object); + var translation = new Mock(); + translation.Setup(t => t.GetResource(It.IsAny())).Returns(k => k); + return new DeleteAccountValidator(new List>(), encryption.Object, + new CustomerSettings(), context.Object, translation.Object, config); + } + + [TestMethod] + public async Task DeleteAccountValidator_ProviderOnly_NoPasswordNeeded() + { + var result = await Validator(new OidcLoginConfig { SignInMode = OidcSignInMode.ProviderOnly }, false) + .ValidateAsync(new DeleteAccountModel()); + Assert.IsTrue(result.IsValid); + } + + [TestMethod] + public async Task DeleteAccountValidator_Mixed_PasswordRequired() + { + var result = await Validator(new OidcLoginConfig(), true).ValidateAsync(new DeleteAccountModel()); + Assert.IsFalse(result.IsValid); + } + + [TestMethod] + public async Task DeleteAccountValidator_Mixed_WrongPasswordRejected() + { + var result = await Validator(new OidcLoginConfig(), false) + .ValidateAsync(new DeleteAccountModel { Password = "x" }); + Assert.IsFalse(result.IsValid); + } +} diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Login/Index.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Login/Index.cshtml index 1b8dab7d3..d4b214977 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Login/Index.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Login/Index.cshtml @@ -17,52 +17,66 @@

@Loc["Admin.Login.Title"]

-
- @if (Model.UsernamesEnabled) - { -
- -
- + @if (Model.PasswordFormShown) + { +
+ @if (Model.UsernamesEnabled) + { +
+ +
+ +
+
- -
- } - else - { + } + else + { +
+ +
+ +
+ +
+ }
- +
- +
- +
- } -
- -
- -
- -
-