Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 52 additions & 62 deletions src/Tests/Grand.Web.Store.Tests/Controllers/CustomerControllerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,12 @@
using Grand.Infrastructure.Configuration;
using Grand.Web.AdminShared.Interfaces;
using Grand.Web.AdminShared.Models.Customers;
using Grand.Web.Common.DataSource;
using Grand.Web.Common.Models;
using Grand.Web.Store.Controllers;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Routing;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;

Expand Down Expand Up @@ -81,67 +78,22 @@ private CustomerController BuildController(bool perStoreEnabled)
return controller;
}

private static (ActionExecutingContext context, Func<bool> wasNextCalled) BuildGate(CustomerController controller,
string actionName)
{
var actionContext = new ActionContext(controller.ControllerContext.HttpContext, new RouteData(),
new ControllerActionDescriptor { ActionName = actionName });
var context = new ActionExecutingContext(actionContext, new List<IFilterMetadata>(),
new Dictionary<string, object>(), controller);
return (context, () => false);
}

[TestMethod]
public async Task Gate_PerStoreDisabled_RedirectsToPerStoreDisabled()
{
var controller = BuildController(perStoreEnabled: false);
var (context, _) = BuildGate(controller, nameof(CustomerController.List));
var nextCalled = false;

await controller.OnActionExecutionAsync(context, () =>
{
nextCalled = true;
return Task.FromResult(new ActionExecutedContext(context, new List<IFilterMetadata>(), controller));
});

Assert.IsFalse(nextCalled);
var redirect = context.Result as RedirectToActionResult;
Assert.IsNotNull(redirect);
Assert.AreEqual(nameof(CustomerController.PerStoreDisabled), redirect.ActionName);
}

[TestMethod]
public async Task Gate_PerStoreDisabled_AllowsPerStoreDisabledAction()
public async Task CustomerList_PerStoreDisabled_DoesNotFilterByCurrentStore()
{
var controller = BuildController(perStoreEnabled: false);
var (context, _) = BuildGate(controller, nameof(CustomerController.PerStoreDisabled));
var nextCalled = false;

await controller.OnActionExecutionAsync(context, () =>
{
nextCalled = true;
return Task.FromResult(new ActionExecutedContext(context, new List<IFilterMetadata>(), controller));
});

Assert.IsTrue(nextCalled);
Assert.IsNull(context.Result);
}

[TestMethod]
public async Task Gate_PerStoreEnabled_CallsNext()
{
var controller = BuildController(perStoreEnabled: true);
var (context, _) = BuildGate(controller, nameof(CustomerController.List));
var nextCalled = false;

await controller.OnActionExecutionAsync(context, () =>
{
nextCalled = true;
return Task.FromResult(new ActionExecutedContext(context, new List<IFilterMetadata>(), controller));
});

Assert.IsTrue(nextCalled);
Assert.IsNull(context.Result);
_groupServiceMock.Setup(g => g.GetCustomerGroupBySystemName(SystemCustomerGroupNames.Registered))
.ReturnsAsync(new CustomerGroup { Id = "registered-group" });
var capturedStoreId = "";
_customerViewModelServiceMock.Setup(s => s.PrepareCustomerList(It.IsAny<CustomerListModel>(),
It.IsAny<string[]>(), It.IsAny<string[]>(), It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string>()))
.Callback<CustomerListModel, string[], string[], int, int, string>((_, _, _, _, _, storeId) =>
capturedStoreId = storeId)
.ReturnsAsync((new List<CustomerModel>(), 0));

var result = await controller.CustomerList(new DataSourceRequest { Page = 1, PageSize = 20 }, new CustomerListModel());
Assert.IsInstanceOfType(result, typeof(JsonResult));
Assert.AreEqual("", capturedStoreId);
}

[TestMethod]
Expand Down Expand Up @@ -190,4 +142,42 @@ public async Task Create_ForcesStoreScopedRegisteredOnlyConstraints()
Assert.AreEqual("", captured.SeId);
CollectionAssert.AreEqual(new[] { "registered-group" }, captured.CustomerGroups);
}

[TestMethod]
public async Task Create_PerStoreDisabled_DoesNotOverrideStoreId()
{
var controller = BuildController(perStoreEnabled: false);

_groupServiceMock.Setup(g => g.GetCustomerGroupBySystemName(SystemCustomerGroupNames.Registered))
.ReturnsAsync(new CustomerGroup { Id = "registered-group" });
_customerAttributeServiceMock.Setup(a => a.GetAllCustomerAttributes())
.ReturnsAsync(new List<CustomerAttribute>());

CustomerModel captured = null;
_customerViewModelServiceMock.Setup(s => s.InsertCustomerModel(It.IsAny<CustomerModel>()))
.Callback<CustomerModel>(m => captured = m)
.ReturnsAsync(new Customer { Id = "c1", StoreId = "foreign-store" });

var model = new CustomerModel {
Email = "new@customer.com",
StoreId = "foreign-store",
Owner = "owner@x.com",
VendorId = "vendor-1",
StaffStoreId = "staff-store",
SeId = "sales-1",
CustomerGroups = ["administrators-group"],
SelectedAttributes = new List<CustomAttributeModel>()
};

var result = await controller.Create(model, false);

Assert.IsInstanceOfType(result, typeof(RedirectToActionResult));
Assert.IsNotNull(captured);
Assert.AreEqual("foreign-store", captured.StoreId);
Assert.AreEqual("", captured.Owner);
Assert.AreEqual("", captured.VendorId);
Assert.AreEqual("", captured.StaffStoreId);
Assert.AreEqual("", captured.SeId);
CollectionAssert.AreEqual(new[] { "registered-group" }, captured.CustomerGroups);
}
}
9 changes: 8 additions & 1 deletion src/Web/Grand.Web.Admin/Controllers/DownloadController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,14 @@ public async Task<IActionResult> DownloadFile(Guid downloadGuid)
return Content("No download record found with the specified id");

if (download.UseDownloadUrl)
return new RedirectResult(download.DownloadUrl);
{
if (!string.IsNullOrEmpty(download.DownloadUrl) &&
Uri.TryCreate(download.DownloadUrl, UriKind.Absolute, out var uri) &&
(uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
return new RedirectResult(download.DownloadUrl);

return Content("Invalid download URL");
}

//use stored data
if (download.DownloadBinary == null)
Expand Down
48 changes: 16 additions & 32 deletions src/Web/Grand.Web.Store/Controllers/CustomerController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,6 @@
using Grand.Web.Common.Models;
using Grand.Web.Common.Security.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.Mvc.Filters;

namespace Grand.Web.Store.Controllers;

Expand Down Expand Up @@ -104,39 +102,23 @@ public CustomerController(

#endregion

#region Per-store gate

//managing customers from the store panel only makes sense when customer identity is scoped per store
//(Customer:RegisterCustomersPerStore). When it's off the whole controller is disabled and every action
//is routed to the PerStoreDisabled page that explains how to enable the setting.
public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
if (!_customerConfig.RegisterCustomersPerStore &&
context.ActionDescriptor is ControllerActionDescriptor { ActionName: not nameof(PerStoreDisabled) })
{
context.Result = RedirectToAction(nameof(PerStoreDisabled));
return;
}

await next();
}

#endregion

#region Utilities

private string CurrentStoreId => _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId;
private bool IsPerStoreMode => _customerConfig.RegisterCustomersPerStore;
private string CustomerStoreFilter => IsPerStoreMode ? CurrentStoreId : "";
private bool IsCurrentStore(string storeId) => !IsPerStoreMode || storeId == CurrentStoreId;

/// <summary>
/// Loads a customer only when it is a non-deleted, registered customer of the current store.
/// Loads a non-deleted customer and, in per-store mode, additionally enforces store/registered access.
/// Returns null otherwise so callers can deny access.
/// </summary>
private async Task<Customer> GetStoreCustomer(string id)
{
var customer = await _customerService.GetCustomerById(id);
if (customer == null || customer.Deleted || customer.StoreId != CurrentStoreId)
if (customer == null || customer.Deleted)
return null;
if (!await _groupService.IsRegistered(customer))
if (IsPerStoreMode && (customer.StoreId != CurrentStoreId || !await _groupService.IsRegistered(customer)))
return null;
return customer;
}
Expand All @@ -147,7 +129,8 @@ private async Task<Customer> GetStoreCustomer(string id)
/// </summary>
private async Task ApplyStoreConstraints(CustomerModel model)
{
model.StoreId = CurrentStoreId;
if (IsPerStoreMode)
model.StoreId = CurrentStoreId;
model.Owner = "";
model.VendorId = "";
model.StaffStoreId = "";
Expand Down Expand Up @@ -226,7 +209,7 @@ public async Task<IActionResult> List()
}

/// <summary>
/// Shown instead of the panel when per-store customer identity is disabled (see the gate below).
/// Kept for backward URL compatibility.
/// </summary>
public IActionResult PerStoreDisabled()
{
Expand All @@ -240,7 +223,7 @@ public async Task<IActionResult> CustomerList(DataSourceRequest command, Custome
//store managers only ever see the registered customers of their own store
var registered = await _groupService.GetCustomerGroupBySystemName(SystemCustomerGroupNames.Registered);
var (customerModelList, totalCount) = await _customerViewModelService.PrepareCustomerList(model,
registered != null ? [registered.Id] : [], null, command.Page, command.PageSize, CurrentStoreId);
registered != null ? [registered.Id] : [], null, command.Page, command.PageSize, CustomerStoreFilter);
var gridModel = new DataSourceResult {
Data = customerModelList.ToList(),
Total = totalCount
Expand Down Expand Up @@ -496,7 +479,8 @@ public async Task<IActionResult> LoyaltyPointsHistoryAdd(string customerId, stri
if (customer == null)
return Json(new { Result = false });

await _customerViewModelService.InsertLoyaltyPointsHistory(customer, CurrentStoreId, addLoyaltyPointsValue,
var loyaltyStoreId = IsPerStoreMode ? CurrentStoreId : storeId;
await _customerViewModelService.InsertLoyaltyPointsHistory(customer, loyaltyStoreId, addLoyaltyPointsValue,
addLoyaltyPointsMessage);

return Json(new { Result = true });
Expand Down Expand Up @@ -633,7 +617,7 @@ public async Task<IActionResult> OrderList(string customerId, DataSourceRequest

var model = new OrderListModel {
CustomerId = customerId,
StoreId = CurrentStoreId
StoreId = CustomerStoreFilter
};

var (orderModels, totalCount) =
Expand All @@ -651,7 +635,7 @@ public async Task<IActionResult> OrderDetails(string orderId,
[FromServices] IOrderService orderService, [FromServices] IOrderViewModelService orderViewModelService)
{
var order = await orderService.GetOrderById(orderId);
if (order == null || order.StoreId != CurrentStoreId)
if (order == null || !IsCurrentStore(order.StoreId))
return Json(new DataSourceResult { Data = null, Total = 0 });

var ordermodel = new OrderModel();
Expand All @@ -677,7 +661,7 @@ public async Task<IActionResult> ReviewList(string customerId, DataSourceRequest
return Json(new DataSourceResult { Data = null, Total = 0 });

var productReviews = await _productReviewService.GetAllProductReviews(customerId, null,
null, null, "", CurrentStoreId, "", command.Page - 1, command.PageSize);
null, null, "", CustomerStoreFilter, "", command.Page - 1, command.PageSize);
var items = new List<ProductReviewModel>();
foreach (var x in productReviews)
{
Expand All @@ -698,7 +682,7 @@ public async Task<IActionResult> ReviewList(string customerId, DataSourceRequest
public async Task<IActionResult> ReviewDelete(string id)
{
var productReview = await _productReviewService.GetProductReviewById(id);
if (productReview == null || productReview.StoreId != CurrentStoreId)
if (productReview == null || !IsCurrentStore(productReview.StoreId))
throw new ArgumentException("No review found with the specified id", nameof(id));

await _productReviewViewModelService.DeleteProductReview(productReview);
Expand Down
4 changes: 3 additions & 1 deletion src/Web/Grand.Web/Controllers/CheckoutController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -784,7 +784,9 @@ public virtual async Task<IActionResult> CompleteRedirectionPayment(string payme

// Get the redirect URL from PostRedirectPayment
var redirectUrl = await _paymentService.PostRedirectPayment(paymentTransaction);
if (!string.IsNullOrEmpty(redirectUrl))
if (!string.IsNullOrEmpty(redirectUrl) &&
Uri.TryCreate(redirectUrl, UriKind.Absolute, out var uri) &&
(uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
return Redirect(redirectUrl);

return RedirectToRoute("CheckoutCompleted", new { orderId = order.Id });
Expand Down
4 changes: 3 additions & 1 deletion src/Web/Grand.Web/Controllers/OrderController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,9 @@ public virtual async Task<IActionResult> RePostPayment(string orderId)
return RedirectToRoute("OrderDetails", new { orderId });

var redirectUrl = await _paymentService.PostRedirectPayment(paymentTransaction);
if (!string.IsNullOrEmpty(redirectUrl))
if (!string.IsNullOrEmpty(redirectUrl) &&
Uri.TryCreate(redirectUrl, UriKind.Absolute, out var uri) &&
(uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
return Redirect(redirectUrl);

return RedirectToRoute("OrderDetails", new { orderId });
Expand Down
22 changes: 11 additions & 11 deletions src/Web/Grand.Web/Views/Account/Info.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -597,19 +597,19 @@
data: () => ({
@{
<text>
FirstName: '@Html.Raw(Model.FirstName)',
LastName: '@Html.Raw(Model.LastName)',
Company: '@Html.Raw(Model.Company)',
StreetAddress: '@Html.Raw(Model.StreetAddress)',
StreetAddress2: '@Html.Raw(Model.StreetAddress2)',
ZipPostalCode: '@Html.Raw(Model.ZipPostalCode)',
City: '@Html.Raw(Model.City)',
FirstName: @Json.Serialize(Model.FirstName),
LastName: @Json.Serialize(Model.LastName),
Company: @Json.Serialize(Model.Company),
StreetAddress: @Json.Serialize(Model.StreetAddress),
StreetAddress2: @Json.Serialize(Model.StreetAddress2),
ZipPostalCode: @Json.Serialize(Model.ZipPostalCode),
City: @Json.Serialize(Model.City),
CountryId: '@Model.CountryId',
StateProvinceId: '@Model.StateProvinceId',
Phone: '@Html.Raw(Model.Phone)',
Fax: '@Html.Raw(Model.Fax)',
Username: '@Html.Raw(Model.Username)',
Email: '@Html.Raw(Model.Email)',
Phone: @Json.Serialize(Model.Phone),
Fax: @Json.Serialize(Model.Fax),
Username: @Json.Serialize(Model.Username),
Email: @Json.Serialize(Model.Email),
@foreach (var item in Model.CustomerAttributes)
{
@switch (item.AttributeControlType)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,9 @@
<script asp-location="Footer" asp-order="300">
var createorupdatesubaccount = new Vue({
data: () => ({
FirstName: '@Html.Raw(Model.FirstName)',
LastName: '@Html.Raw(Model.LastName)',
Email: '@Html.Raw(Model.Email)',
FirstName: @Json.Serialize(Model.FirstName),
LastName: @Json.Serialize(Model.LastName),
Email: @Json.Serialize(Model.Email),
Password: '',
})
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,9 @@
<script asp-location="Footer" asp-order="300">
var createorupdatesubaccount = new Vue({
data: () => ({
FirstName: '@Html.Raw(Model.FirstName)',
LastName: '@Html.Raw(Model.LastName)',
Email: '@Html.Raw(Model.Email)',
FirstName: @Json.Serialize(Model.FirstName),
LastName: @Json.Serialize(Model.LastName),
Email: @Json.Serialize(Model.Email),
Password: '',
})
});
Expand Down
22 changes: 11 additions & 11 deletions src/Web/Grand.Web/Views/Account/Register.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -519,19 +519,19 @@
data: () => ({
@{
<text>
FirstName: '@Html.Raw(Model.FirstName)',
LastName: '@Html.Raw(Model.LastName)',
Company: '@Html.Raw(Model.Company)',
StreetAddress: '@Html.Raw(Model.StreetAddress)',
StreetAddress2: '@Html.Raw(Model.StreetAddress2)',
ZipPostalCode: '@Html.Raw(Model.ZipPostalCode)',
City: '@Html.Raw(Model.City)',
FirstName: @Json.Serialize(Model.FirstName),
LastName: @Json.Serialize(Model.LastName),
Company: @Json.Serialize(Model.Company),
StreetAddress: @Json.Serialize(Model.StreetAddress),
StreetAddress2: @Json.Serialize(Model.StreetAddress2),
ZipPostalCode: @Json.Serialize(Model.ZipPostalCode),
City: @Json.Serialize(Model.City),
CountryId: '@Model.CountryId',
StateProvinceId: '@Model.StateProvinceId',
Phone: '@Html.Raw(Model.Phone)',
Fax: '@Html.Raw(Model.Fax)',
Username: '@Html.Raw(Model.Username)',
Email: '@Html.Raw(Model.Email)',
Phone: @Json.Serialize(Model.Phone),
Fax: @Json.Serialize(Model.Fax),
Username: @Json.Serialize(Model.Username),
Email: @Json.Serialize(Model.Email),
Password: '',
ConfirmPassword: '',
AcceptPrivacyPolicy: false,
Expand Down
Loading
Loading