Skip to content
Merged
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
11 changes: 11 additions & 0 deletions core/components/minishop3/config/routes/manager.php
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,17 @@
CategoryProductActionPermissions::mutationPermissions()
)
]);
// Category-scoped inline-edit product data (#455)
$router->put('/{id}/products/{productId}/data', function($params) use ($modx) {
$input = file_get_contents('php://input');
$data = json_decode($input, true) ?: [];
$allParams = array_merge($data, $_GET, $params);

$controller = new \MiniShop3\Controllers\Api\Manager\CategoryProductsController($modx);
return $controller->updateProductData($allParams);
}, [
new PermissionMiddleware($modx, 'msproduct_save')
]);
// Toggle product publish status
$router->post('/{id}/products/{productId}/publish', function ($params) use ($modx) {
$input = file_get_contents('php://input');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,76 @@ public function bulkDelete(array $params = []): array
return $this->multiple($params);
}

/**
* Update product data from category grid inline-edit
* PUT /api/mgr/categories/{id}/products/{productId}/data
*
* @param array $params
* @return array Response
*/
public function updateProductData(array $params = []): array
{
$categoryId = (int) ($params['id'] ?? 0);
$productId = (int) ($params['productId'] ?? 0);
$nested = filter_var($params['nested'] ?? false, FILTER_VALIDATE_BOOLEAN);

if (!$categoryId) {
return Response::error('Category ID is required', HttpStatus::BAD_REQUEST)->getData();
}

if (!$productId) {
return Response::error('Product ID is required', HttpStatus::BAD_REQUEST)->getData();
}

$data = $params;
unset($data['id'], $data['productId'], $data['nested']);

if ($data === []) {
return Response::error('Invalid request data', HttpStatus::BAD_REQUEST)->getData();
}

// Scope check + product fetch in one round-trip (CategoryProductScopePolicy).
// Replaces the separate isProductInCategoryScope() bool-only lookup and yields
// the product instance for the document ACL check below (#473 pattern).
$product = $this->scopeService()->findInCategory($categoryId, $productId, $nested);

if (!$product) {
$this->modx->lexicon->load('minishop3:default');

return Response::error(
$this->modx->lexicon('ms3_err_product_not_in_category_scope'),
HttpStatus::FORBIDDEN
)->getData();
}

$savePolicies = [CategoryProductDocumentPolicy::POLICY_SAVE];
if (!CategoryProductDocumentPolicy::isAllowedAll($product, $savePolicies)) {
$this->logDocumentPolicyDenied($product, $savePolicies);

return Response::error(
'Save permission denied for this document',
HttpStatus::FORBIDDEN
)->getData();
}

/** @var \MiniShop3\Services\Product\ProductDataService|null $productDataService */
$productDataService = $this->modx->services->get('ms3_product_data_service');
if (!$productDataService) {
return Response::error('Product data service is not available', HttpStatus::INTERNAL_SERVER_ERROR)->getData();
}

$result = $productDataService->updateProductData($productId, $data);

if (!empty($result['ok']) && !empty($result['data'])) {
return Response::success($result['data'])->getData();
}

$code = $result['code'] ?? HttpStatus::INTERNAL_SERVER_ERROR;
$message = $result['message'] ?? 'Failed to save product data';

return Response::error($message, $code)->getData();
}

/**
* Toggle product publish status
* POST /api/mgr/categories/{id}/products/{productId}/publish
Expand Down Expand Up @@ -611,8 +681,11 @@ private function countVisibleListResults(
return $visible;
}

/** @param list<string> $policies */
private function logDocumentPolicyDenied(msProduct $product, array $policies): void
/**
* @param object $product msProduct or smoke-test stub exposing get('id')
* @param list<string> $policies
*/
private function logDocumentPolicyDenied(object $product, array $policies): void
{
$this->modx->log(
modX::LOG_LEVEL_WARN,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,30 @@ public static function isParentInScope(
return false;
}

return in_array(
$productParentId,
self::allowedParentCategoryIds($categoryId, $nested, $descendantCategoryIds),
true
);
}

/**
* @param list<int> $descendantCategoryIds Child category IDs (recursive, excluding root)
*
* @return list<int>
*/
public static function allowedParentCategoryIds(
int $categoryId,
bool $nested,
array $descendantCategoryIds
): array {
if (!$nested) {
return $productParentId === $categoryId;
return [$categoryId];
}

$allowed = $descendantCategoryIds;
$allowed[] = $categoryId;

return in_array($productParentId, $allowed, true);
return $allowed;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,38 @@ private function quoteOptionKeyForJoinCondition(string $key): string
return str_replace("'", "''", $key);
}

/**
* Parent category IDs allowed for products in category grid scope (matches list filter).
*
* @return list<int>
*/
public function getAllowedProductParentCategoryIds(int $categoryId, bool $nested): array
{
return CategoryProductScopePolicy::allowedParentCategoryIds(
$categoryId,
$nested,
$nested ? $this->treeService()->getDescendantCategoryIds($categoryId) : []
);
}

/**
* Whether a product belongs to the category products grid scope (direct parent or nested tree).
*/
public function isProductInCategoryScope(int $productId, int $categoryId, bool $nested): bool
{
$product = $this->modx->getObject(msProduct::class, $productId);
if (!$product) {
return false;
}

return CategoryProductScopePolicy::isParentInScope(
(int) $product->get('parent'),
$categoryId,
$nested,
$nested ? $this->treeService()->getDescendantCategoryIds($categoryId) : []
);
}

/**
* @param list<string> $optionFieldNames Allowed option field names (whitelist)
*
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<?php

/**
* Regression tests for category product scope policy (issue #444).
* Regression tests for category product scope policy (#444, #455).
*
* Run: php tests/CategoryProductScopePolicyTest.php
*/
Expand Down Expand Up @@ -43,5 +43,16 @@
$assertSame(false, CategoryProductScopePolicy::isParentInScope(0, 10, false, []), 'invalid product parent');
$assertSame(false, CategoryProductScopePolicy::isParentInScope(10, 0, false, []), 'invalid category id');

$assertSame(
[10],
CategoryProductScopePolicy::allowedParentCategoryIds(10, false, [11, 12]),
'allowedParentCategoryIds direct'
);
$assertSame(
[11, 12, 10],
CategoryProductScopePolicy::allowedParentCategoryIds(10, true, [11, 12]),
'allowedParentCategoryIds nested'
);

fwrite(STDOUT, "OK CategoryProductScopePolicyTest\n");
exit(0);
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
['id' => 999, 'parent' => 2, 'published' => 0],
['id' => 100, 'parent' => 1, 'published' => 0],
['id' => 101, 'parent' => 2, 'published' => 0],
['id' => 200, 'parent' => 1, 'published' => 0, 'policies' => ['save' => false]],
];
$modx->categories = [
['id' => 2, 'parent' => 1],
Expand Down Expand Up @@ -89,4 +90,28 @@
$assertSame(true, $sort['success'] ?? null, 'sort success');
$assertSame(1, $sort['data']['updated'] ?? null, 'sort updated count');

// updateProductData: in-scope product with document save policy denied → 403 (#473 pattern)
$modx->getObjectCalls = [];
$aclDenied = $controller->updateProductData([
'id' => 1,
'productId' => 200,
'pagetitle' => 'updated title',
]);
$assertSame(false, $aclDenied['success'] ?? null, 'updateProductData ACL denied success');
$assertSame(HttpStatus::FORBIDDEN, $aclDenied['code'] ?? null, 'updateProductData ACL denied code');
$assertSame(
'Save permission denied for this document',
$aclDenied['message'] ?? null,
'updateProductData ACL denied message'
);

// updateProductData: out-of-scope product → 403 (scope guard still enforced)
$outOfScope = $controller->updateProductData([
'id' => 1,
'productId' => 999,
'pagetitle' => 'updated title',
]);
$assertSame(false, $outOfScope['success'] ?? null, 'updateProductData out-of-scope success');
$assertSame(HttpStatus::FORBIDDEN, $outOfScope['code'] ?? null, 'updateProductData out-of-scope code');

fwrite(STDOUT, "OK: CategoryProductsControllerScopeTest\n");
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@
*/
class CategoryProductScopeModxStub extends modX
{
/** @var list<array{id: int, parent: int, published?: int, deleted?: int}> */
/** @var object|null */
public $lexicon;

/** @var list<array{id: int, parent: int, published?: int, deleted?: int, policies?: array<string, bool>}> */
public array $products = [];

/** @var list<array{id: int, parent: int}> */
Expand All @@ -25,6 +28,27 @@ class CategoryProductScopeModxStub extends modX
public function __construct()
{
parent::__construct();
$this->user = new class {
public function isAuthenticated(string $context): bool
{
return $context === 'mgr';
}

public function getUserToken(string $contextKey): string
{
return 'test-modauth-token';
}

public function get(string $key): mixed
{
return $key === 'id' ? 0 : null;
}
};
$this->lexicon = new class {
public function load(string ...$topics): void
{
}
};
$this->services = new class {
public function get(string $key): null
{
Expand All @@ -38,6 +62,14 @@ public function has(string $key): bool
};
}

/**
* @param array<string, mixed> $params
*/
public function lexicon(string $key, array $params = [], string $language = ''): string
{
return $key;
}

public function getObject($className = '', $criteria = null, $cacheFlag = true)
{
$this->getObjectCalls[] = ['class' => $className, 'criteria' => $criteria];
Expand Down Expand Up @@ -77,7 +109,7 @@ public function getObject($className = '', $criteria = null, $cacheFlag = true)

foreach ($this->products as $row) {
if ((int) $row['id'] === $productId && (int) $row['parent'] === $parentId) {
return new StubMsProduct($row);
return new StubMsProduct($row, $row['policies'] ?? null);
}
}

Expand All @@ -87,7 +119,7 @@ public function getObject($className = '', $criteria = null, $cacheFlag = true)
$productId = (int) $criteria;
foreach ($this->products as $row) {
if ((int) $row['id'] === $productId) {
return new StubMsProduct($row);
return new StubMsProduct($row, $row['policies'] ?? null);
}
}

Expand Down
22 changes: 14 additions & 8 deletions vueManager/src/composables/useCategoryProductsInlineEdit.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,11 @@ import {
export function useCategoryProductsInlineEdit(deps) {
const { products, referencePathsByKey, categoryId, nested, request, toast, _ } = deps

const resolveScopeContext = () => ({
category_id: typeof categoryId === 'object' && categoryId !== null ? categoryId.value : categoryId,
nested: Boolean(typeof nested === 'object' && nested !== null ? nested.value : nested),
})
const resolveCategoryId = () =>
typeof categoryId === 'object' && categoryId !== null ? categoryId.value : categoryId

const resolveNested = () =>
Boolean(typeof nested === 'object' && nested !== null ? nested.value : nested)

const editingCell = ref(null)
const inlineEditValue = ref('')
Expand Down Expand Up @@ -211,10 +212,15 @@ export function useCategoryProductsInlineEdit(deps) {
}
inlineEditSaving.value = true
try {
const res = await request.put(`/api/mgr/product-data/${product.id}`, {
[column.name]: value,
...resolveScopeContext(),
})
const scopedCategoryId = resolveCategoryId()
const payload = { [column.name]: value }
if (resolveNested()) {
payload.nested = 1
}
const res = await request.put(
`/api/mgr/categories/${scopedCategoryId}/products/${product.id}/data`,
payload
)
const idx = products.value.findIndex(p => p.id === product.id)
if (idx >= 0) {
if (res && typeof res === 'object') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,10 +122,8 @@ describe('useCategoryProductsInlineEdit', () => {
api.inlineEditValue.value = '25'
await api.saveInlineEdit(product, column)

expect(request.put).toHaveBeenCalledWith('/api/mgr/product-data/11', {
expect(request.put).toHaveBeenCalledWith('/api/mgr/categories/5/products/11/data', {
price: 25,
category_id: 5,
nested: false,
})
expect(products.value[0].price).toBe(25)
expect(toast.add).toHaveBeenCalledWith(
Expand Down