diff --git a/CHANGELOG-Permissions.md b/CHANGELOG-Permissions.md new file mode 100644 index 0000000000..104313ec1e --- /dev/null +++ b/CHANGELOG-Permissions.md @@ -0,0 +1,10 @@ +# WIP Release notes for Commerce 5.6 + +### Development +- Product permissions have been refined into separate "View", "Create", "Save", and "Delete" permissions. + +### Extensibility +- Added `craft\commerce\services\ProductTypes::getViewableProductTypes()`. +- Added `craft\commerce\services\ProductTypes::getViewableProductTypeIds()`. +- Added `craft\commerce\services\ProductTypes::getCreatableProductTypeIds()`. +- Deprecated `craft\commerce\services\ProductTypes::hasPermission()`. Use `$user->can()` directly instead. \ No newline at end of file diff --git a/src/Plugin.php b/src/Plugin.php index cc41cd36d6..14bde4a01f 100755 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -392,8 +392,8 @@ public function getCpNavItem(): ?array ]; } - $hasEditableProductTypes = Plugin::getInstance()->getProductTypes()->getEditableProductTypeIds(true); - if ($hasEditableProductTypes) { + $hasViewableProductTypes = Plugin::getInstance()->getProductTypes()->getViewableProductTypeIds(true); + if ($hasViewableProductTypes) { $ret['subnav']['products'] = [ 'label' => Craft::t('commerce', 'Products'), 'url' => 'commerce/products', @@ -639,14 +639,21 @@ private function _registerProductTypePermission(): array foreach ($productTypes as $productType) { $suffix = ':' . $productType->uid; - $productTypePermissions['commerce-editProductType' . $suffix] = [ - 'label' => Craft::t('commerce', 'Edit “{type}” products', ['type' => $productType->name]), + $productTypePermissions['commerce-viewProductType' . $suffix] = [ + 'label' => Craft::t('commerce', 'View “{type}” products', ['type' => $productType->name]), + 'info' => Craft::t('commerce', 'Allows viewing existing products and creating drafts for them.'), 'nested' => [ - "commerce-createProducts$suffix" => [ + 'commerce-createProductType' . $suffix => [ 'label' => Craft::t('commerce', 'Create products'), + 'info' => Craft::t('commerce', 'Allows creating drafts of new products.'), ], - "commerce-deleteProducts$suffix" => [ + 'commerce-saveProductType' . $suffix => [ + 'label' => Craft::t('commerce', 'Save products'), + 'info' => Craft::t('commerce', 'Allows fully saving canonical products (directly or by applying drafts).'), + ], + 'commerce-deleteProductType' . $suffix => [ 'label' => Craft::t('commerce', 'Delete products'), + 'info' => Craft::t('commerce', 'Allows deleting products for all sites.'), ], ], ]; diff --git a/src/console/controllers/CreateTestUsersController.php b/src/console/controllers/CreateTestUsersController.php new file mode 100644 index 0000000000..0ca65a7706 --- /dev/null +++ b/src/console/controllers/CreateTestUsersController.php @@ -0,0 +1,114 @@ + + * @since 5.6.0 + */ +class CreateTestUsersController extends Controller +{ + /** + * Creates test users for every combination of commerce product type permissions. + * + * @return int + */ + public function actionIndex(): int + { + $productTypes = Plugin::getInstance()->getProductTypes()->getAllProductTypes(); + + if (empty($productTypes)) { + $this->stderr("No product types found.\n"); + return ExitCode::UNSPECIFIED_ERROR; + } + + $elementsService = Craft::$app->getElements(); + $permissionsService = Craft::$app->getUserPermissions(); + + // Permission keys and their short labels for username generation + $permissionKeys = [ + 'view' => 'commerce-viewProductType', + 'create' => 'commerce-createProductType', + 'save' => 'commerce-saveProductType', + 'delete' => 'commerce-deleteProductType', + ]; + + // Generate all combinations of create/save/delete (view is always required) + $nestedKeys = ['create', 'save', 'delete']; + $combos = [[]]; // start with view-only (no nested) + foreach ($nestedKeys as $key) { + $newCombos = []; + foreach ($combos as $combo) { + $newCombos[] = $combo; + $newCombos[] = array_merge($combo, [$key]); + } + $combos = $newCombos; + } + + $created = 0; + + foreach ($productTypes as $productType) { + $suffix = ':' . $productType->uid; + $typeHandle = $productType->handle; + + foreach ($combos as $combo) { + // Build username from permissions + $parts = ['view']; + $parts = array_merge($parts, $combo); + $username = $typeHandle . '-' . implode('-', $parts); + $email = $username . '@test.com'; + + // Check if user already exists + $existing = User::find()->username($username)->one(); + if ($existing) { + $this->stdout("User '$username' already exists, skipping.\n"); + continue; + } + + // Create the user + $user = new User(); + $user->username = $username; + $user->email = $email; + $user->active = true; + $user->newPassword = 'password'; + + if (!$elementsService->saveElement($user, false)) { + $this->stderr("Failed to create user '$username': " . implode(', ', $user->getFirstErrors()) . "\n"); + continue; + } + + // Build permission list - view is always included + $permissions = [ + 'accesscp', + 'accessplugin-commerce', + strtolower($permissionKeys['view'] . $suffix), + ]; + + foreach ($combo as $key) { + $permissions[] = strtolower($permissionKeys[$key] . $suffix); + } + + $permissionsService->saveUserPermissions($user->id, $permissions); + + $this->stdout("Created user '$username' with permissions: " . implode(', ', $parts) . "\n"); + $created++; + } + } + + $this->stdout("\nDone. Created $created test users.\n"); + return ExitCode::OK; + } +} diff --git a/src/controllers/ProductsController.php b/src/controllers/ProductsController.php index b6510632cc..d682cae598 100644 --- a/src/controllers/ProductsController.php +++ b/src/controllers/ProductsController.php @@ -29,6 +29,19 @@ */ class ProductsController extends BaseCpController { + /** + * @inheritdoc + * @throws ForbiddenHttpException + */ + public function init(): void + { + parent::init(); + + if (empty(Plugin::getInstance()->getProductTypes()->getViewableProductTypeIds(true))) { + throw new ForbiddenHttpException('User is not permitted to view any product types.'); + } + } + /** * @throws InvalidConfigException */ diff --git a/src/controllers/VariantsController.php b/src/controllers/VariantsController.php index 87228b0990..b5945919ff 100755 --- a/src/controllers/VariantsController.php +++ b/src/controllers/VariantsController.php @@ -7,6 +7,8 @@ namespace craft\commerce\controllers; +use craft\commerce\Plugin; +use yii\web\ForbiddenHttpException; use yii\web\Response; /** @@ -17,6 +19,19 @@ */ class VariantsController extends BaseCpController { + /** + * @inheritdoc + * @throws ForbiddenHttpException + */ + public function init(): void + { + parent::init(); + + if (empty(Plugin::getInstance()->getProductTypes()->getViewableProductTypeIds(true))) { + throw new ForbiddenHttpException('User is not permitted to view any product types.'); + } + } + /** * @return Response */ diff --git a/src/elements/Product.php b/src/elements/Product.php index 5699d133e7..df2f68df53 100644 --- a/src/elements/Product.php +++ b/src/elements/Product.php @@ -11,6 +11,7 @@ use craft\base\Element; use craft\base\ElementInterface; use craft\base\Field; +use craft\behaviors\DraftBehavior; use craft\commerce\base\HasStoreInterface; use craft\commerce\base\StoreTrait; use craft\commerce\behaviors\CurrencyAttributeBehavior; @@ -222,7 +223,7 @@ public static function createCondition(): ElementConditionInterface protected static function defineSources(string $context = null): array { if ($context == 'index') { - $productTypes = Plugin::getInstance()->getProductTypes()->getEditableProductTypes(); + $productTypes = Plugin::getInstance()->getProductTypes()->getViewableProductTypes(); $editable = true; } else { $productTypes = Plugin::getInstance()->getProductTypes()->getAllProductTypes(); @@ -253,7 +254,7 @@ protected static function defineSources(string $context = null): array foreach ($productTypes as $productType) { $key = 'productType:' . $productType->uid; - $canEditProducts = $user && $user->can('commerce-editProductType:' . $productType->uid); + $canEditProducts = $user && $user->can('commerce-saveProductType:' . $productType->uid); $sources[$key] = [ 'key' => $key, @@ -353,7 +354,7 @@ protected static function defineActions(string $source = null): array switch ($source) { case '*': { - $productTypes = Plugin::getInstance()->getProductTypes()->getEditableProductTypes(); + $productTypes = Plugin::getInstance()->getProductTypes()->getViewableProductTypes(); break; } default: @@ -395,14 +396,13 @@ protected static function defineActions(string $source = null): array } elseif (!empty($productTypes)) { $userSession = Craft::$app->getUser(); $currentUser = $userSession->getIdentity(); - $productTypeService = Plugin::getInstance()->getProductTypes(); foreach ($productTypes as $productType) { - $canDelete = $productTypeService->hasPermission($currentUser, $productType, 'commerce-deleteProducts'); - $canCreate = $productTypeService->hasPermission($currentUser, $productType, 'commerce-createProducts'); - $canEdit = $productTypeService->hasPermission($currentUser, $productType, 'commerce-editProductType'); + $canDelete = $currentUser->can('commerce-deleteProductType:' . $productType->uid); + $canCreate = $currentUser->can('commerce-createProductType:' . $productType->uid); + $canSave = $currentUser->can('commerce-saveProductType:' . $productType->uid); - if ($canCreate) { + if ($canCreate && $canSave) { // Duplicate $actions[] = [ 'type' => Duplicate::class, @@ -420,7 +420,7 @@ protected static function defineActions(string $source = null): array $actions[] = $deleteAction; } - if ($canEdit) { + if ($canSave) { $actions[] = SetStatus::class; } @@ -949,7 +949,7 @@ public function canView(User $user): bool return false; } - return $user->can('commerce-editProductType:' . $productType->uid); + return $user->can('commerce-viewProductType:' . $productType->uid); } /** @@ -967,7 +967,20 @@ public function canSave(User $user): bool return false; } - return $user->can('commerce-editProductType:' . $productType->uid); + if ($this->getIsDraft()) { + /** + * @var static|DraftBehavior $this + * @phpstan-ignore-next-line + */ + return $this->canCreateDrafts($user); + } + + // New products require create permission + if (!$this->id) { + return $user->can('commerce-createProductType:' . $productType->uid); + } + + return $user->can('commerce-saveProductType:' . $productType->uid); } /** @@ -985,7 +998,8 @@ public function canDuplicate(User $user): bool return false; } - return Plugin::getInstance()->getProductTypes()->hasPermission($user, $productType, 'commerce-createProducts'); + return $user->can('commerce-createProductType:' . $productType->uid) + && $user->can('commerce-saveProductType:' . $productType->uid); } /** @@ -1003,7 +1017,7 @@ public function canDelete(User $user): bool return false; } - return Plugin::getInstance()->getProductTypes()->hasPermission($user, $productType, 'commerce-deleteProducts'); + return $user->can('commerce-deleteProductType:' . $productType->uid); } /** @@ -1029,7 +1043,7 @@ protected function crumbs(): array { $productType = $this->getType(); - $productTypes = Collection::make(Plugin::getInstance()->getProductTypes()->getEditableProductTypes()); + $productTypes = Collection::make(Plugin::getInstance()->getProductTypes()->getViewableProductTypes()); /** @var Collection $productTypeOptions */ $productTypeOptions = $productTypes ->map(fn(ProductType $t) => [ diff --git a/src/elements/db/ProductQuery.php b/src/elements/db/ProductQuery.php index 168c9c7767..b6daf131a8 100644 --- a/src/elements/db/ProductQuery.php +++ b/src/elements/db/ProductQuery.php @@ -848,8 +848,8 @@ protected function beforePrepare(): bool } $this->_applyHasVariantParam(); - $this->_applyEditableParam($this->editable, 'commerce-editProductType'); - $this->_applyEditableParam($this->savable, 'commerce-editProductType'); + $this->_applyEditableParam($this->editable, 'commerce-viewProductType'); + $this->_applyEditableParam($this->savable, 'commerce-saveProductType'); $this->_applyRefParam(); return parent::beforePrepare(); diff --git a/src/migrations/m260226_120000_product_type_permissions.php b/src/migrations/m260226_120000_product_type_permissions.php new file mode 100644 index 0000000000..ac0539b2c8 --- /dev/null +++ b/src/migrations/m260226_120000_product_type_permissions.php @@ -0,0 +1,114 @@ +select(['uid']) + ->from('{{%commerce_producttypes}}') + ->column($this->db); + + // Build the permission mapping + $map = []; // oldPermission => [newPermission, ...] + foreach ($productTypeUids as $uid) { + // commerce-editProductType → commerce-viewProductType + commerce-saveProductType + $map[strtolower("commerce-editProductType:$uid")] = [ + strtolower("commerce-viewProductType:$uid"), + strtolower("commerce-saveProductType:$uid"), + ]; + // commerce-createProducts → commerce-createProductType + $map[strtolower("commerce-createProducts:$uid")] = [ + strtolower("commerce-createProductType:$uid"), + ]; + // commerce-deleteProducts → commerce-deleteProductType + $map[strtolower("commerce-deleteProducts:$uid")] = [ + strtolower("commerce-deleteProductType:$uid"), + ]; + } + + // Migrate user permissions in the database + foreach ($map as $oldPermission => $newPermissions) { + // Find all users with the old permission + $userIds = (new Query()) + ->select(['upu.userId']) + ->from(['upu' => Table::USERPERMISSIONS_USERS]) + ->innerJoin(['up' => Table::USERPERMISSIONS], '[[up.id]] = [[upu.permissionId]]') + ->where(['up.name' => $oldPermission]) + ->column($this->db); + + $userIds = array_unique($userIds); + + if (!empty($userIds)) { + foreach ($newPermissions as $newPermission) { + // Delete the permission if it already exists + $this->delete(Table::USERPERMISSIONS, [ + 'name' => $newPermission, + ]); + + $this->insert(Table::USERPERMISSIONS, [ + 'name' => $newPermission, + ]); + $newPermissionId = $this->db->getLastInsertID(Table::USERPERMISSIONS); + + $insert = []; + foreach ($userIds as $userId) { + $insert[] = [$newPermissionId, $userId]; + } + + $this->batchInsert(Table::USERPERMISSIONS_USERS, ['permissionId', 'userId'], $insert); + } + } + } + + // Migrate project config for user groups + $projectConfig = Craft::$app->getProjectConfig(); + + foreach ($projectConfig->get('users.groups') ?? [] as $uid => $group) { + $groupPermissions = array_flip($group['permissions'] ?? []); + $save = false; + + foreach ($map as $oldPermission => $newPermissions) { + if (isset($groupPermissions[$oldPermission])) { + foreach ($newPermissions as $newPermission) { + $groupPermissions[$newPermission] = true; + } + $save = true; + } + } + + if ($save) { + $projectConfig->set("users.groups.$uid.permissions", array_keys($groupPermissions)); + } + } + + return true; + } + + /** + * @inheritdoc + */ + public function safeDown(): bool + { + return true; + } +} diff --git a/src/services/ProductTypes.php b/src/services/ProductTypes.php index 1b27b0c64d..5acd9868dc 100755 --- a/src/services/ProductTypes.php +++ b/src/services/ProductTypes.php @@ -121,11 +121,11 @@ class ProductTypes extends Component /** - * Returns all editable product types. + * Returns all viewable product types. * - * @return ProductType[] An array of all the editable product types. + * @return ProductType[] An array of all the viewable product types. */ - public function getEditableProductTypes(): array + public function getViewableProductTypes(): array { if (Craft::$app->getRequest()->getIsConsoleRequest()) { return $this->getAllProductTypes(); @@ -137,33 +137,33 @@ public function getEditableProductTypes(): array return []; } - $editableProductTypeIds = $this->getEditableProductTypeIds(); - $editableProductTypes = []; + $viewableProductTypeIds = $this->getViewableProductTypeIds(); + $viewableProductTypes = []; - foreach ($this->getAllProductTypes() as $productTypes) { - if (in_array($productTypes->id, $editableProductTypeIds)) { - $editableProductTypes[] = $productTypes; + foreach ($this->getAllProductTypes() as $productType) { + if (in_array($productType->id, $viewableProductTypeIds)) { + $viewableProductTypes[] = $productType; } } - return $editableProductTypes; + return $viewableProductTypes; } /** - * Returns all product type IDs that are editable by the current user. + * Returns all product type IDs that are viewable by the current user. * - * @return array An array of all the editable product types’ IDs. + * @return array An array of all the viewable product types' IDs. */ - public function getEditableProductTypeIds(bool $anySite = false): array + public function getViewableProductTypeIds(bool $anySite = false): array { - $editableIds = []; + $viewableIds = []; $user = Craft::$app->getUser()->getIdentity(); $allProductTypes = $this->getAllProductTypes(); $cpSite = Cp::requestedSite(); foreach ($allProductTypes as $productType) { - if (!Plugin::getInstance()->getProductTypes()->hasPermission($user, $productType, 'commerce-editProductType')) { + if (!$user->can('commerce-viewProductType:' . $productType->uid)) { continue; } @@ -171,10 +171,10 @@ public function getEditableProductTypeIds(bool $anySite = false): array continue; } - $editableIds[] = $productType->id; + $viewableIds[] = $productType->id; } - return $editableIds; + return $viewableIds; } /** @@ -190,7 +190,7 @@ public function getCreatableProductTypeIds(): array $allProductTypes = $this->getAllProductTypes(); foreach ($allProductTypes as $productType) { - if ($this->hasPermission($user, $productType, 'commerce-createProducts')) { + if ($user->can('commerce-createProductType:' . $productType->uid)) { $creatableIds[] = $productType->id; } } @@ -208,9 +208,9 @@ public function getCreatableProductTypes(): array $creatableProductTypeIds = $this->getCreatableProductTypeIds(); $creatableProductTypes = []; - foreach ($this->getAllProductTypes() as $productTypes) { - if (in_array($productTypes->id, $creatableProductTypeIds)) { - $creatableProductTypes[] = $productTypes; + foreach ($this->getAllProductTypes() as $productType) { + if (in_array($productType->id, $creatableProductTypeIds)) { + $creatableProductTypes[] = $productType; } } @@ -220,7 +220,7 @@ public function getCreatableProductTypes(): array /** * Returns all the product type IDs. * - * @return array An array of all the product types’ IDs. + * @return array An array of all the product types' IDs. */ public function getAllProductTypeIds(): array { @@ -614,7 +614,7 @@ public function handleChangedProductType(ConfigEvent $event): void foreach ($productIds as $productId) { App::maxPowerCaptain(); - // Loop through each of the changed sites and update all of the products’ slugs and + // Loop through each of the changed sites and update all of the products' slugs and // URIs foreach ($sitesWithNewUriFormats as $siteId) { $product = Product::find() @@ -839,7 +839,7 @@ public function getProductTypeByUid(string $uid): ?ProductType } /** - * Returns whether a product type’s products have URLs, and if the template path is valid. + * Returns whether a product type's products have URLs, and if the template path is valid. * * @param ProductType $productType The product for which to validate the template. * @param int $siteId The site for which to valid for @@ -1031,31 +1031,16 @@ private function _getProductTypeRecord(string $uid): ProductTypeRecord * @param ProductType $productType * @param string|null $checkPermissionName detailed product type permission. * @return bool + * @deprecated in 5.6.0. Use `$user->can()` directly instead. */ public function hasPermission(User $user, ProductType $productType, ?string $checkPermissionName = null): bool { - if ($user->admin) { - return true; - } - - $permissions = Craft::$app->getUserPermissions()->getPermissionsByUserId($user->id); - - $suffix = ':' . $productType->uid; + Craft::$app->getDeprecator()->log(__METHOD__, '`ProductTypes::hasPermission()` has been deprecated. Use `$user->can()` directly instead.'); if ($checkPermissionName !== null) { - $checkPermissionName = strtolower($checkPermissionName . $suffix); - if (!in_array(strtolower($checkPermissionName), $permissions)) { - return false; - } + return $user->can($checkPermissionName . ':' . $productType->uid); } - // Required for create and delete permission. - $editProductType = strtolower('commerce-editProductType' . $suffix); - - if (!in_array($editProductType, $permissions)) { - return false; - } - - return true; + return $user->can('commerce-viewProductType:' . $productType->uid); } } diff --git a/src/stats/TopProductTypes.php b/src/stats/TopProductTypes.php index a5048c26f0..ff883d538b 100644 --- a/src/stats/TopProductTypes.php +++ b/src/stats/TopProductTypes.php @@ -58,7 +58,7 @@ public function getData(): array $selectTotalRevenue = new Expression('SUM([[li.total]]) as revenue'); $orderByRevenue = new Expression('SUM([[li.total]]) DESC'); - $editableProductTypeIds = Plugin::getInstance()->getProductTypes()->getEditableProductTypeIds(); + $editableProductTypeIds = Plugin::getInstance()->getProductTypes()->getViewableProductTypeIds(); $results = $this->_createStatQuery() ->select([ diff --git a/src/stats/TopPurchasables.php b/src/stats/TopPurchasables.php index ceb6f402ad..8196851821 100644 --- a/src/stats/TopPurchasables.php +++ b/src/stats/TopPurchasables.php @@ -55,7 +55,7 @@ public function getData(): array $selectTotalRevenue = new Expression('SUM([[li.total]]) as revenue'); $orderByRevenue = new Expression('SUM([[li.total]]) DESC'); - $editableProductTypeIds = Plugin::getInstance()->getProductTypes()->getEditableProductTypeIds(); + $editableProductTypeIds = Plugin::getInstance()->getProductTypes()->getViewableProductTypeIds(); $topPurchasables = $this->_createStatQuery() ->select([ diff --git a/src/translations/en/commerce.php b/src/translations/en/commerce.php index 01b210e147..96e7d1f745 100644 --- a/src/translations/en/commerce.php +++ b/src/translations/en/commerce.php @@ -56,6 +56,10 @@ 'All products' => 'All products', 'All variants must have a SKU.' => 'All variants must have a SKU.', 'All' => 'All', + 'Allows creating drafts of new products.' => 'Allows creating drafts of new products.', + 'Allows deleting products for all sites.' => 'Allows deleting products for all sites.', + 'Allows fully saving canonical products (directly or by applying drafts).' => 'Allows fully saving canonical products (directly or by applying drafts).', + 'Allows viewing existing products and creating drafts for them.' => 'Allows viewing existing products and creating drafts for them.', 'Allow Checkout Without Payment' => 'Allow Checkout Without Payment', 'Allow Empty Cart On Checkout' => 'Allow Empty Cart On Checkout', 'Allow Partial Payment On Checkout' => 'Allow Partial Payment On Checkout', @@ -967,6 +971,7 @@ 'Save and return to all orders' => 'Save and return to all orders', 'Save and set rules' => 'Save and set rules', 'Save as a new rule' => 'Save as a new rule', + 'Save products' => 'Save products', 'Save product to all sites enabled for this product type' => 'Save product to all sites enabled for this product type', 'Save product to other sites in the same site group' => 'Save product to other sites in the same site group', 'Save product to other sites with the same language' => 'Save product to other sites with the same language', @@ -1303,6 +1308,7 @@ 'View customer' => 'View customer', 'View order' => 'View order', 'View product type - {productType}' => 'View product type - {productType}', + 'View “{type}” products' => 'View “{type}” products', 'View user' => 'View user', 'Warning, deleting this currency will stop all payments and refunds in this currency, are you sure you want to delete “{name}”?' => 'Warning, deleting this currency will stop all payments and refunds in this currency, are you sure you want to delete “{name}”?', 'Web' => 'Web', diff --git a/tests/unit/services/ProductPermissionTest.php b/tests/unit/services/ProductPermissionTest.php index bef346f4ba..995919428b 100644 --- a/tests/unit/services/ProductPermissionTest.php +++ b/tests/unit/services/ProductPermissionTest.php @@ -11,13 +11,11 @@ use Craft; use craft\commerce\elements\Product; use craft\commerce\models\ProductType; -use craft\commerce\Plugin; -use craft\commerce\services\ProductTypes; use craft\elements\User; use UnitTester; /** - * SalesTest + * ProductPermissionTest * * @author Pixel & Tonic, Inc. * @since 3.1.4 @@ -29,76 +27,207 @@ class ProductPermissionTest extends Unit */ protected $tester; - /** - * @var ProductTypes - */ - protected ProductTypes $productTypes; + public function testCanViewWithNoPermissions() + { + [$user, $product] = $this->_existingProduct(); + $this->mockPermissions([]); + $this->assertFalse($product->canView($user)); + } - public function testCanAUserCreateOrDeleteAProduct() + public function testCanViewWithViewPermission() { - $user = new User(); - $user->id = 1; - $user->admin = false; + [$user, $product] = $this->_existingProduct(); + + $this->mockPermissions(['commerce-viewproducttype:randomuid']); + $this->assertTrue($product->canView($user)); + } + + public function testCanViewWithWrongProductType() + { + [$user, $product] = $this->_existingProduct(); + + $this->mockPermissions(['commerce-viewproducttype:anotherrandomuid']); + $this->assertFalse($product->canView($user)); + } + + public function testCanViewWithOnlySavePermission() + { + [$user, $product] = $this->_existingProduct(); + + // Save without view should not grant view + $this->mockPermissions(['commerce-saveproducttype:randomuid']); + $this->assertFalse($product->canView($user)); + } + + public function testCanSaveExistingProductWithSavePermission() + { + [$user, $product] = $this->_existingProduct(); + + $this->mockPermissions(['commerce-viewproducttype:randomuid', 'commerce-saveproducttype:randomuid']); + $this->assertTrue($product->canSave($user)); + } + + public function testCannotSaveExistingProductWithViewOnly() + { + [$user, $product] = $this->_existingProduct(); + + $this->mockPermissions(['commerce-viewproducttype:randomuid']); + $this->assertFalse($product->canSave($user)); + } + + public function testCannotSaveExistingProductWithCreatePermission() + { + [$user, $product] = $this->_existingProduct(); + + // Create permission does not grant save on existing products + $this->mockPermissions(['commerce-viewproducttype:randomuid', 'commerce-createproducttype:randomuid']); + $this->assertFalse($product->canSave($user)); + } + + public function testCanSaveNewProductWithCreatePermission() + { + [$user, $product] = $this->_newProduct(); + + $this->mockPermissions(['commerce-viewproducttype:randomuid', 'commerce-createproducttype:randomuid']); + $this->assertTrue($product->canSave($user)); + } + + public function testCannotSaveNewProductWithViewOnly() + { + [$user, $product] = $this->_newProduct(); + + $this->mockPermissions(['commerce-viewproducttype:randomuid']); + $this->assertFalse($product->canSave($user)); + } + + public function testCannotSaveNewProductWithSavePermission() + { + [$user, $product] = $this->_newProduct(); - $product = $this->make(Product::class, ['getType' => $this->make(ProductType::class, ['id' => 1, 'uid' => 'randomuid']) ]); + // Save permission does not grant create on new products + $this->mockPermissions(['commerce-viewproducttype:randomuid', 'commerce-saveproducttype:randomuid']); + $this->assertFalse($product->canSave($user)); + } + + public function testCanDeleteWithDeletePermission() + { + [$user, $product] = $this->_existingProduct(); + + $this->mockPermissions(['commerce-viewproducttype:randomuid', 'commerce-deleteproducttype:randomuid']); + $this->assertTrue($product->canDelete($user)); + } + + public function testCannotDeleteWithViewOnly() + { + [$user, $product] = $this->_existingProduct(); + + $this->mockPermissions(['commerce-viewproducttype:randomuid']); + $this->assertFalse($product->canDelete($user)); + } + + public function testCannotDeleteWithSavePermission() + { + [$user, $product] = $this->_existingProduct(); - // User has no create product permission on a specific product type. - $this->mockPermissions(['commerce-editproducttype:randomuid']); + // Save permission does not grant delete + $this->mockPermissions(['commerce-viewproducttype:randomuid', 'commerce-saveproducttype:randomuid']); + $this->assertFalse($product->canDelete($user)); + } - $this->assertFalse($this->productTypes->hasPermission($user, $product->getType(), 'commerce-createProducts')); + public function testCanDuplicateWithCreateAndSave() + { + [$user, $product] = $this->_existingProduct(); + + $this->mockPermissions([ + 'commerce-viewproducttype:randomuid', + 'commerce-createproducttype:randomuid', + 'commerce-saveproducttype:randomuid', + ]); + $this->assertTrue($product->canDuplicate($user)); + } - // User has create product permission on a specific product type. - $this->mockPermissions(['commerce-editproducttype:randomuid', 'commerce-createproducts:randomuid']); + public function testCannotDuplicateWithCreateOnly() + { + [$user, $product] = $this->_existingProduct(); - $this->assertTrue($this->productTypes->hasPermission($user, $product->getType(), 'commerce-createProducts')); + $this->mockPermissions(['commerce-viewproducttype:randomuid', 'commerce-createproducttype:randomuid']); + $this->assertFalse($product->canDuplicate($user)); + } - // User has no delete product permission on a specific product type. - $this->mockPermissions(['commerce-editproducttype:randomuid']); + public function testCannotDuplicateWithSaveOnly() + { + [$user, $product] = $this->_existingProduct(); - $this->assertFalse($this->productTypes->hasPermission($user, $product->getType(), 'commerce-deleteProducts:randomuid')); + $this->mockPermissions(['commerce-viewproducttype:randomuid', 'commerce-saveproducttype:randomuid']); + $this->assertFalse($product->canDuplicate($user)); + } - // User has delete product permission on a specific product type. - $this->mockPermissions(['commerce-editproducttype:randomuid', 'commerce-deleteproducts:randomuid']); + public function testCanCreateDraftsAlwaysReturnsTrue() + { + [$user, $product] = $this->_existingProduct(); - $this->assertTrue($this->productTypes->hasPermission($user, $product->getType(), 'commerce-deleteProducts')); + $this->mockPermissions([]); + $this->assertTrue($product->canCreateDrafts($user)); } - public function testCanAUserEditThisProduct() + public function testAdminBypassesAllPermissions() { $user = new User(); $user->id = 1; - $user->admin = false; + $user->admin = true; - $product = $this->make(Product::class, ['getType' => $this->make(ProductType::class, ['id' => 1, 'uid' => 'randomuid'])]); + $product = $this->make(Product::class, [ + 'id' => 100, + 'getType' => $this->_makeProductType(), + ]); $this->mockPermissions([]); + $this->assertTrue($product->canView($user)); + $this->assertTrue($product->canSave($user)); + $this->assertTrue($product->canDelete($user)); + $this->assertTrue($product->canDuplicate($user)); + } - $this->assertFalse($this->productTypes->hasPermission($user, $product->getType())); + /** + * @return array{User, Product} + */ + private function _existingProduct(): array + { + $user = new User(); + $user->id = 1; + $user->admin = false; - $this->mockPermissions(['commerce-editproducttype:randomuid']); - $this->assertTrue($this->productTypes->hasPermission($user, $product->getType(), 'commerce-editproducttype')); + $product = $this->make(Product::class, [ + 'id' => 100, + 'getType' => $this->_makeProductType(), + ]); - // if user has access to another product type - $this->mockPermissions(['commerce-editProductType:anotherrandomuid']); - $this->assertFalse($this->productTypes->hasPermission($user, $product->getType(), 'commerce-editproducttype')); + return [$user, $product]; } - public function testCanAdminUserAbleToEditProduct() + /** + * @return array{User, Product} + */ + private function _newProduct(): array { $user = new User(); $user->id = 1; - $user->admin = true; + $user->admin = false; - $product = $this->make(Product::class, ['getType' => $this->make(ProductType::class, ['id' => 1, 'uid' => 'randomuid'])]); + $product = $this->make(Product::class, [ + 'getType' => $this->_makeProductType(), + ]); - $this->assertTrue($this->productTypes->hasPermission($user, $product->getType(), 'commerce-createProducts')); + return [$user, $product]; + } - $user->admin = false; - $this->assertFalse($this->productTypes->hasPermission($user, $product->getType(), 'commerce-createProducts')); + private function _makeProductType(): ProductType + { + return $this->make(ProductType::class, ['id' => 1, 'uid' => 'randomuid']); } - private function mockPermissions(array $permissions = []) + private function mockPermissions(array $permissions = []): void { $this->tester->mockMethods( Craft::$app, @@ -109,11 +238,4 @@ private function mockPermissions(array $permissions = []) [] ); } - - protected function _before() - { - parent::_before(); - - $this->productTypes = Plugin::getInstance()->getProductTypes(); - } }