From 56c1fbdc917256749ef0779af642d3ec6c8c93ee Mon Sep 17 00:00:00 2001 From: Ivan Bochkarev Date: Thu, 16 Jul 2026 10:04:25 +0600 Subject: [PATCH 1/4] fix(api): stop loading storefront routes via manager connector Index and Router now share manager-only route loading so /api/v1 is served only through api.php, not connector.php with TokenMiddleware. --- .../manager/example-addon.php.dist | 7 +- .../ms3.routes.d/web/example-addon.php.dist | 9 +- .../minishop3/config/routes/manager.php | 4 +- .../minishop3/config/routes/web.php | 2 +- .../minishop3/src/Processors/Api/Index.php | 94 ++------------- .../Api/ManagerConnectorRouteLoader.php | 64 ++++++++++ .../ProcessesManagerConnectorRouteTrait.php | 79 +++++++++++++ .../minishop3/src/Processors/Api/Router.php | 80 +------------ .../tests/ManagerConnectorRouteLoaderTest.php | 109 ++++++++++++++++++ 9 files changed, 274 insertions(+), 174 deletions(-) create mode 100644 core/components/minishop3/src/Processors/Api/ManagerConnectorRouteLoader.php create mode 100644 core/components/minishop3/src/Processors/Api/ProcessesManagerConnectorRouteTrait.php create mode 100644 core/components/minishop3/tests/ManagerConnectorRouteLoaderTest.php diff --git a/core/components/minishop3/config/ms3.routes.d/manager/example-addon.php.dist b/core/components/minishop3/config/ms3.routes.d/manager/example-addon.php.dist index 46711eb5..3b288794 100644 --- a/core/components/minishop3/config/ms3.routes.d/manager/example-addon.php.dist +++ b/core/components/minishop3/config/ms3.routes.d/manager/example-addon.php.dist @@ -4,10 +4,9 @@ * * Скопируйте в: core/config/ms3.routes.d/manager/50-myaddon.php * - * Порядок (Processors\Api\Router — встроенная админка MODX): системные manager.php → - * ms3_routes_manager.custom.php → ms3.routes.d/manager/*.php (web.php и ms3.routes.d/web/ не грузятся). - * - * Processors\Api\Index (кастомные фронты / Vue-менеджер): дополнительно web.php и ms3.routes.d/web/*.php. + * Порядок (Index и Router на connector): системные manager.php → + * ms3_routes_manager.custom.php → ms3.routes.d/manager/*.php. + * Storefront (web.php, ms3.routes.d/web/) — только через api.php (#384). * * @var \MiniShop3\Router\Router $router * @var \MODX\Revolution\modX $modx diff --git a/core/components/minishop3/config/ms3.routes.d/web/example-addon.php.dist b/core/components/minishop3/config/ms3.routes.d/web/example-addon.php.dist index 64b7ef3b..3c5ad967 100644 --- a/core/components/minishop3/config/ms3.routes.d/web/example-addon.php.dist +++ b/core/components/minishop3/config/ms3.routes.d/web/example-addon.php.dist @@ -8,11 +8,10 @@ * core/config/ms3.routes.d/web/50-myaddon.php * (имя с префиксом для порядка загрузки; файлы сортируются по алфавиту.) * - * ВАЖНО — ДВЕ ТОЧКИ ВХОДА: - * ======================== - * Файлы из ms3.routes.d/web/ подключаются и в публичном Web API (api.php), и в connector через - * Processors\Api\Index (кастомный фронт / Vue-менеджер). Они НЕ подключаются в Processors\Api\Router - * (встроенная админка MODX). Учитывайте это при проектировании путей и прав доступа. + * ВАЖНО — ТОЧКА ВХОДА: + * ==================== + * Файлы из ms3.routes.d/web/ подключаются только в публичном Web API (api.php). + * Connector Index/Router manager-роуты не грузят (#384). * * ПОРЯДОК ЗАГРУЗКИ (Web API, api.php): * ==================================== diff --git a/core/components/minishop3/config/routes/manager.php b/core/components/minishop3/config/routes/manager.php index 71aa8978..012b269c 100644 --- a/core/components/minishop3/config/routes/manager.php +++ b/core/components/minishop3/config/routes/manager.php @@ -9,8 +9,8 @@ * * DO NOT edit this file directly! * For custom routes use: core/config/ms3_routes_manager.custom.php - * For addon fragments: core/config/ms3.routes.d/manager/*.php (connector). Web fragments (ms3.routes.d/web/) - * load via api.php and Processors\Api\Index only, not via Processors\Api\Router (built-in manager). + * For addon fragments: core/config/ms3.routes.d/manager/*.php (connector Index/Router). + * Web fragments (ms3.routes.d/web/) load via api.php only — not via connector processors (#384). * * Custom and addon routes are loaded AFTER system routes and can override them. * diff --git a/core/components/minishop3/config/routes/web.php b/core/components/minishop3/config/routes/web.php index a3381528..cf2584a1 100644 --- a/core/components/minishop3/config/routes/web.php +++ b/core/components/minishop3/config/routes/web.php @@ -16,7 +16,7 @@ * DO NOT edit this file directly! * For custom routes use: core/config/ms3_routes_web.custom.php * For addon fragments (one file per addon): core/config/ms3.routes.d/web/*.php - * (same fragments load in api.php and in Processors\Api\Index on connector, not in Processors\Api\Router) + * Loaded only via api.php — not via connector Index/Router (#384). * * Custom and addon routes are loaded AFTER system routes and can override them. * diff --git a/core/components/minishop3/src/Processors/Api/Index.php b/core/components/minishop3/src/Processors/Api/Index.php index 799b8c1e..2afa96fa 100644 --- a/core/components/minishop3/src/Processors/Api/Index.php +++ b/core/components/minishop3/src/Processors/Api/Index.php @@ -2,109 +2,29 @@ namespace MiniShop3\Processors\Api; -use MiniShop3\Router\Router as ApiRouter; use MODX\Revolution\Processors\Processor; /** - * Processor for handling API requests through connector.php (кастомные фронты / Vue-менеджер). + * Processor for handling API requests through connector.php (Vue Manager / custom mgr fronts). * - * Полная цепочка: manager.php, web.php, custom-файлы, ms3.routes.d/manager и ms3.routes.d/web. - * Встроенная админка MODX использует Processors\Api\Router — без web-роутов и без web-фрагментов. + * Manager routes only: manager.php, ms3_routes_manager.custom.php, ms3.routes.d/manager/. + * Storefront /api/v1 lives on api.php — not loaded here (#384). * * Usage: - * connector.php?action=api&route=/api/mgr/test/success + * connector.php?action=MiniShop3\Processors\Api\Index&route=/api/mgr/... */ class Index extends Processor { + use ProcessesManagerConnectorRouteTrait; + /** @var string $permission Empty string = public access without permission check */ public $permission = ''; /** - * Check access permissions - * Router will check permissions via middleware, so allow everything here - * - * @return bool + * Router middleware enforces mgr Auth + permissions per route. */ public function checkPermissions() { return true; } - - /** - * {@inheritdoc} - */ - public function process() - { - try { - $route = $this->getProperty('route', ''); - - if (empty($route)) { - return $this->failure('Route parameter is required', ['code' => 400]); - } - - $componentPath = MODX_CORE_PATH . 'components/minishop3/'; - $autoloader = $componentPath . 'vendor/autoload.php'; - - if (!file_exists($autoloader)) { - return $this->failure('Component not properly installed. Run: composer install', ['code' => 500]); - } - - if (!class_exists('FastRoute\\simpleDispatcher')) { - require_once $autoloader; - } - - $router = new ApiRouter($this->modx); - - $systemRoutesFile = $componentPath . 'config/routes/manager.php'; - - if (!file_exists($systemRoutesFile)) { - return $this->failure('System routes not found: ' . $systemRoutesFile, ['code' => 500]); - } - - $router->loadRoutes($systemRoutesFile); - - $webRoutesFile = $componentPath . 'config/routes/web.php'; - - if (file_exists($webRoutesFile)) { - $router->loadRoutes($webRoutesFile); - } - - $customRoutesFile = MODX_CORE_PATH . 'config/ms3_routes_manager.custom.php'; - - if (file_exists($customRoutesFile)) { - $router->loadRoutes($customRoutesFile); - } - - $router->loadRoutesFromDirectory(ApiRouter::coreAddonRoutesDirectory('manager')); - $router->loadRoutesFromDirectory(ApiRouter::coreAddonRoutesDirectory('web')); - - $router->build(); - - $response = $router->dispatch($route, $_SERVER['REQUEST_METHOD']); - - $responseData = $response->getData(); - $statusCode = $response->getStatusCode(); - - http_response_code($statusCode); - - if ($statusCode >= 200 && $statusCode < 300) { - return $this->success('', $responseData['data'] ?? $responseData); - } - - return $this->failure( - $responseData['message'] ?? 'API request failed', - $responseData - ); - - } catch (\Exception $e) { - $this->modx->log(\MODX\Revolution\modX::LOG_LEVEL_ERROR, '[MiniShop3 API] ' . $e->getMessage()); - - return $this->failure( - $this->modx->getOption('ms3_api_debug', null, false) - ? $e->getMessage() - : 'Internal server error', - ['code' => 500] - ); - } - } } diff --git a/core/components/minishop3/src/Processors/Api/ManagerConnectorRouteLoader.php b/core/components/minishop3/src/Processors/Api/ManagerConnectorRouteLoader.php new file mode 100644 index 00000000..086ce991 --- /dev/null +++ b/core/components/minishop3/src/Processors/Api/ManagerConnectorRouteLoader.php @@ -0,0 +1,64 @@ + suffixes relative to core/ or component root + */ + public static function forbiddenPathSuffixes(): array + { + return [ + 'config/routes/web.php', + 'config/ms3_routes_web.custom.php', + 'config/ms3.routes.d/web', + ]; + } + + /** + * @throws \RuntimeException when required manager.php is missing + */ + public static function load(ApiRouter $router, string $componentPath, string $corePath): void + { + $componentPath = rtrim($componentPath, '/\\') . DIRECTORY_SEPARATOR; + $corePath = rtrim($corePath, '/\\') . DIRECTORY_SEPARATOR; + + $managerRoutes = $componentPath . 'config' . DIRECTORY_SEPARATOR . 'routes' . DIRECTORY_SEPARATOR . 'manager.php'; + // Caller should pre-check; keep throw as safety net for direct use. + if (!file_exists($managerRoutes)) { + throw new \RuntimeException('System routes not found: ' . $managerRoutes); + } + + $router->loadRoutes($managerRoutes); + + $customRoutes = $corePath . 'config' . DIRECTORY_SEPARATOR . 'ms3_routes_manager.custom.php'; + if (file_exists($customRoutes)) { + $router->loadRoutes($customRoutes); + } + + $addonDir = (defined('MODX_CORE_PATH') + && rtrim((string)MODX_CORE_PATH, '/\\') === rtrim($corePath, '/\\')) + ? ApiRouter::coreAddonRoutesDirectory('manager') + : $corePath . 'config' . DIRECTORY_SEPARATOR . 'ms3.routes.d' . DIRECTORY_SEPARATOR . 'manager'; + + $router->loadRoutesFromDirectory($addonDir); + } + + /** + * Storefront routes must not be served through connector Index/Router. + */ + public static function isStorefrontRoute(string $route): bool + { + return str_starts_with($route, '/api/v1'); + } +} diff --git a/core/components/minishop3/src/Processors/Api/ProcessesManagerConnectorRouteTrait.php b/core/components/minishop3/src/Processors/Api/ProcessesManagerConnectorRouteTrait.php new file mode 100644 index 00000000..db270e2c --- /dev/null +++ b/core/components/minishop3/src/Processors/Api/ProcessesManagerConnectorRouteTrait.php @@ -0,0 +1,79 @@ +getProperty('route', '')); + + if ($route === '') { + return $this->failure('Route parameter is required', ['code' => 400]); + } + + if (ManagerConnectorRouteLoader::isStorefrontRoute($route)) { + return $this->failure( + 'Storefront API is not available via manager connector. Use api.php.', + ['code' => 404] + ); + } + + $componentPath = MODX_CORE_PATH . 'components/minishop3/'; + $autoloader = $componentPath . 'vendor/autoload.php'; + + if (!file_exists($autoloader)) { + return $this->failure('Component not properly installed. Run: composer install', ['code' => 500]); + } + + $managerRoutes = $componentPath . 'config/routes/manager.php'; + if (!file_exists($managerRoutes)) { + return $this->failure('System routes not found: ' . $managerRoutes, ['code' => 500]); + } + + if (!class_exists('FastRoute\\simpleDispatcher')) { + require_once $autoloader; + } + + $router = new ApiRouter($this->modx); + ManagerConnectorRouteLoader::load($router, $componentPath, MODX_CORE_PATH); + $router->build(); + + $response = $router->dispatch($route, $_SERVER['REQUEST_METHOD']); + + $responseData = $response->getData(); + $statusCode = $response->getStatusCode(); + + http_response_code($statusCode); + + if ($statusCode >= 200 && $statusCode < 300) { + return $this->success('', $responseData['data'] ?? $responseData); + } + + return $this->failure( + $responseData['message'] ?? 'API request failed', + $responseData + ); + } catch (\Exception $e) { + $this->modx->log(\MODX\Revolution\modX::LOG_LEVEL_ERROR, '[MiniShop3 API] ' . $e->getMessage()); + + return $this->failure( + $this->modx->getOption('ms3_api_debug', null, false) + ? $e->getMessage() + : 'Internal server error', + ['code' => 500] + ); + } + } +} diff --git a/core/components/minishop3/src/Processors/Api/Router.php b/core/components/minishop3/src/Processors/Api/Router.php index 4f7b6b36..8d441059 100644 --- a/core/components/minishop3/src/Processors/Api/Router.php +++ b/core/components/minishop3/src/Processors/Api/Router.php @@ -2,88 +2,18 @@ namespace MiniShop3\Processors\Api; -use MiniShop3\Router\Router as ApiRouter; use MODX\Revolution\Processors\Processor; /** - * Processor for handling API requests through connector.php (встроенная админка MODX, сессия менеджера). + * Processor for handling API requests through connector.php (built-in MODX manager UI). * - * Загружаются только manager-роуты: системный manager.php, ms3_routes_manager.custom.php, ms3.routes.d/manager/. - * Web-роуты (web.php, ms3.routes.d/web/) сюда не подключаются — для них отдельные точки входа: api.php и Processors\Api\Index. + * Manager routes only: manager.php, ms3_routes_manager.custom.php, ms3.routes.d/manager/. + * Storefront /api/v1 lives on api.php (#384). * * Usage: - * connector.php?action=api&route=/api/mgr/test/success + * connector.php?action=MiniShop3\Processors\Api\Router&route=/api/mgr/... */ class Router extends Processor { - /** - * {@inheritdoc} - */ - public function process() - { - try { - $route = $this->getProperty('route', ''); - - if (empty($route)) { - return $this->failure('Route parameter is required', ['code' => 400]); - } - - $componentPath = MODX_CORE_PATH . 'components/minishop3/'; - $autoloader = $componentPath . 'vendor/autoload.php'; - - if (!file_exists($autoloader)) { - return $this->failure('Component not properly installed. Run: composer install', ['code' => 500]); - } - - if (!class_exists('FastRoute\\simpleDispatcher')) { - require_once $autoloader; - } - - $router = new ApiRouter($this->modx); - - $systemRoutesFile = $componentPath . 'config/routes/manager.php'; - - if (!file_exists($systemRoutesFile)) { - return $this->failure('System routes not found: ' . $systemRoutesFile, ['code' => 500]); - } - - $router->loadRoutes($systemRoutesFile); - - $customRoutesFile = MODX_CORE_PATH . 'config/ms3_routes_manager.custom.php'; - - if (file_exists($customRoutesFile)) { - $router->loadRoutes($customRoutesFile); - } - - $router->loadRoutesFromDirectory(ApiRouter::coreAddonRoutesDirectory('manager')); - - $router->build(); - - $response = $router->dispatch($route, $_SERVER['REQUEST_METHOD']); - - $responseData = $response->getData(); - $statusCode = $response->getStatusCode(); - - http_response_code($statusCode); - - if ($statusCode >= 200 && $statusCode < 300) { - return $this->success('', $responseData['data'] ?? $responseData); - } - - return $this->failure( - $responseData['message'] ?? 'API request failed', - $responseData - ); - - } catch (\Exception $e) { - $this->modx->log(\MODX\Revolution\modX::LOG_LEVEL_ERROR, '[MiniShop3 API] ' . $e->getMessage()); - - return $this->failure( - $this->modx->getOption('ms3_api_debug', null, false) - ? $e->getMessage() - : 'Internal server error', - ['code' => 500] - ); - } - } + use ProcessesManagerConnectorRouteTrait; } diff --git a/core/components/minishop3/tests/ManagerConnectorRouteLoaderTest.php b/core/components/minishop3/tests/ManagerConnectorRouteLoaderTest.php new file mode 100644 index 00000000..c8cc375a --- /dev/null +++ b/core/components/minishop3/tests/ManagerConnectorRouteLoaderTest.php @@ -0,0 +1,109 @@ + Date: Thu, 16 Jul 2026 10:13:53 +0600 Subject: [PATCH 2/4] refactor(api): move manager/web route loading onto Router Collapse ManagerConnectorRouteLoader into Router::loadManagerRoutes() and loadWebRoutes() with testable route plans, removing path-policy drift. --- assets/components/minishop3/api.php | 22 +--- .../Api/ManagerConnectorRouteLoader.php | 64 ---------- .../ProcessesManagerConnectorRouteTrait.php | 6 +- .../minishop3/src/Router/Router.php | 120 ++++++++++++++++++ .../tests/ManagerConnectorRouteLoaderTest.php | 109 ---------------- .../minishop3/tests/RouterRoutePlansTest.php | 119 +++++++++++++++++ 6 files changed, 244 insertions(+), 196 deletions(-) delete mode 100644 core/components/minishop3/src/Processors/Api/ManagerConnectorRouteLoader.php delete mode 100644 core/components/minishop3/tests/ManagerConnectorRouteLoaderTest.php create mode 100644 core/components/minishop3/tests/RouterRoutePlansTest.php diff --git a/assets/components/minishop3/api.php b/assets/components/minishop3/api.php index 3ce8f978..567d531f 100644 --- a/assets/components/minishop3/api.php +++ b/assets/components/minishop3/api.php @@ -65,27 +65,9 @@ $ms3 = $modx->services->get('ms3'); $ms3->initialize('web'); - // Создаём роутер + // Создаём роутер — только Web API (фронтенд); manager connector не грузит эти пути (#384) $router = new \MiniShop3\Router\Router($modx); - - // Загружаем ТОЛЬКО Web API роуты (фронтенд, публичные) - $webRoutesFile = $componentPath . 'config/routes/web.php'; - - if (!file_exists($webRoutesFile)) { - throw new \Exception('Web routes not found: ' . $webRoutesFile); - } - - $router->loadRoutes($webRoutesFile); - - // Загружаем пользовательские роуты (опционально) - $customRoutesFile = MODX_CORE_PATH . 'config/ms3_routes_web.custom.php'; - if (file_exists($customRoutesFile)) { - $router->loadRoutes($customRoutesFile); - } - - $router->loadRoutesFromDirectory(\MiniShop3\Router\Router::coreAddonRoutesDirectory('web')); - - // Строим dispatcher + $router->loadWebRoutes($componentPath, MODX_CORE_PATH); $router->build(); // Обрабатываем запрос diff --git a/core/components/minishop3/src/Processors/Api/ManagerConnectorRouteLoader.php b/core/components/minishop3/src/Processors/Api/ManagerConnectorRouteLoader.php deleted file mode 100644 index 086ce991..00000000 --- a/core/components/minishop3/src/Processors/Api/ManagerConnectorRouteLoader.php +++ /dev/null @@ -1,64 +0,0 @@ - suffixes relative to core/ or component root - */ - public static function forbiddenPathSuffixes(): array - { - return [ - 'config/routes/web.php', - 'config/ms3_routes_web.custom.php', - 'config/ms3.routes.d/web', - ]; - } - - /** - * @throws \RuntimeException when required manager.php is missing - */ - public static function load(ApiRouter $router, string $componentPath, string $corePath): void - { - $componentPath = rtrim($componentPath, '/\\') . DIRECTORY_SEPARATOR; - $corePath = rtrim($corePath, '/\\') . DIRECTORY_SEPARATOR; - - $managerRoutes = $componentPath . 'config' . DIRECTORY_SEPARATOR . 'routes' . DIRECTORY_SEPARATOR . 'manager.php'; - // Caller should pre-check; keep throw as safety net for direct use. - if (!file_exists($managerRoutes)) { - throw new \RuntimeException('System routes not found: ' . $managerRoutes); - } - - $router->loadRoutes($managerRoutes); - - $customRoutes = $corePath . 'config' . DIRECTORY_SEPARATOR . 'ms3_routes_manager.custom.php'; - if (file_exists($customRoutes)) { - $router->loadRoutes($customRoutes); - } - - $addonDir = (defined('MODX_CORE_PATH') - && rtrim((string)MODX_CORE_PATH, '/\\') === rtrim($corePath, '/\\')) - ? ApiRouter::coreAddonRoutesDirectory('manager') - : $corePath . 'config' . DIRECTORY_SEPARATOR . 'ms3.routes.d' . DIRECTORY_SEPARATOR . 'manager'; - - $router->loadRoutesFromDirectory($addonDir); - } - - /** - * Storefront routes must not be served through connector Index/Router. - */ - public static function isStorefrontRoute(string $route): bool - { - return str_starts_with($route, '/api/v1'); - } -} diff --git a/core/components/minishop3/src/Processors/Api/ProcessesManagerConnectorRouteTrait.php b/core/components/minishop3/src/Processors/Api/ProcessesManagerConnectorRouteTrait.php index db270e2c..120caae7 100644 --- a/core/components/minishop3/src/Processors/Api/ProcessesManagerConnectorRouteTrait.php +++ b/core/components/minishop3/src/Processors/Api/ProcessesManagerConnectorRouteTrait.php @@ -7,7 +7,7 @@ /** * Shared connector.php dispatch for manager API routes (Index / Router). * - * Manager routes only via ManagerConnectorRouteLoader; storefront /api/v1 rejected (#384). + * Manager routes only via Router::loadManagerRoutes(); storefront /api/v1 rejected (#384). */ trait ProcessesManagerConnectorRouteTrait { @@ -23,7 +23,7 @@ public function process() return $this->failure('Route parameter is required', ['code' => 400]); } - if (ManagerConnectorRouteLoader::isStorefrontRoute($route)) { + if (ApiRouter::isStorefrontRoute($route)) { return $this->failure( 'Storefront API is not available via manager connector. Use api.php.', ['code' => 404] @@ -47,7 +47,7 @@ public function process() } $router = new ApiRouter($this->modx); - ManagerConnectorRouteLoader::load($router, $componentPath, MODX_CORE_PATH); + $router->loadManagerRoutes($componentPath, MODX_CORE_PATH); $router->build(); $response = $router->dispatch($route, $_SERVER['REQUEST_METHOD']); diff --git a/core/components/minishop3/src/Router/Router.php b/core/components/minishop3/src/Router/Router.php index 03812542..34cfcc1e 100644 --- a/core/components/minishop3/src/Router/Router.php +++ b/core/components/minishop3/src/Router/Router.php @@ -50,6 +50,126 @@ public static function coreAddonRoutesDirectory(string $segment): string return MODX_CORE_PATH . 'config/ms3.routes.d/' . $segment; } + /** + * Storefront API routes served by api.php — not via manager connector (#384). + */ + public static function isStorefrontRoute(string $route): bool + { + return str_starts_with($route, '/api/v1'); + } + + /** + * Planned manager-route sources (connector Index/Router). Never includes web (#384). + * + * @return list + */ + public static function managerRoutePlan(string $componentPath, string $corePath): array + { + $componentPath = rtrim($componentPath, '/\\') . DIRECTORY_SEPARATOR; + $corePath = rtrim($corePath, '/\\') . DIRECTORY_SEPARATOR; + + return [ + [ + 'kind' => 'file', + 'path' => $componentPath . 'config' . DIRECTORY_SEPARATOR . 'routes' . DIRECTORY_SEPARATOR . 'manager.php', + 'required' => true, + ], + [ + 'kind' => 'file', + 'path' => $corePath . 'config' . DIRECTORY_SEPARATOR . 'ms3_routes_manager.custom.php', + 'required' => false, + ], + [ + 'kind' => 'dir', + 'path' => $corePath . 'config' . DIRECTORY_SEPARATOR . 'ms3.routes.d' . DIRECTORY_SEPARATOR . 'manager', + 'required' => false, + ], + ]; + } + + /** + * Planned web-route sources (api.php). Never includes manager (#384). + * + * @return list + */ + public static function webRoutePlan(string $componentPath, string $corePath): array + { + $componentPath = rtrim($componentPath, '/\\') . DIRECTORY_SEPARATOR; + $corePath = rtrim($corePath, '/\\') . DIRECTORY_SEPARATOR; + + return [ + [ + 'kind' => 'file', + 'path' => $componentPath . 'config' . DIRECTORY_SEPARATOR . 'routes' . DIRECTORY_SEPARATOR . 'web.php', + 'required' => true, + ], + [ + 'kind' => 'file', + 'path' => $corePath . 'config' . DIRECTORY_SEPARATOR . 'ms3_routes_web.custom.php', + 'required' => false, + ], + [ + 'kind' => 'dir', + 'path' => $corePath . 'config' . DIRECTORY_SEPARATOR . 'ms3.routes.d' . DIRECTORY_SEPARATOR . 'web', + 'required' => false, + ], + ]; + } + + /** + * Load manager-only routes for connector.php processors. + * + * @throws \RuntimeException when required manager.php is missing + */ + public function loadManagerRoutes( + ?string $componentPath = null, + ?string $corePath = null + ): self { + $componentPath ??= MODX_CORE_PATH . 'components/minishop3'; + $corePath ??= MODX_CORE_PATH; + + return $this->loadRoutePlan(self::managerRoutePlan($componentPath, $corePath)); + } + + /** + * Load storefront routes for api.php. + * + * @throws \RuntimeException when required web.php is missing + */ + public function loadWebRoutes( + ?string $componentPath = null, + ?string $corePath = null + ): self { + $componentPath ??= MODX_CORE_PATH . 'components/minishop3'; + $corePath ??= MODX_CORE_PATH; + + return $this->loadRoutePlan(self::webRoutePlan($componentPath, $corePath)); + } + + /** + * @param list $plan + */ + protected function loadRoutePlan(array $plan): self + { + foreach ($plan as $item) { + if ($item['kind'] === 'dir') { + $this->loadRoutesFromDirectory($item['path']); + continue; + } + + if (!file_exists($item['path'])) { + if (!empty($item['required'])) { + throw new \RuntimeException('System routes not found: ' . $item['path']); + } + continue; + } + + $this->loadRoutes($item['path']); + } + + return $this; + } + /** * Load routes from configuration file * diff --git a/core/components/minishop3/tests/ManagerConnectorRouteLoaderTest.php b/core/components/minishop3/tests/ManagerConnectorRouteLoaderTest.php deleted file mode 100644 index c8cc375a..00000000 --- a/core/components/minishop3/tests/ManagerConnectorRouteLoaderTest.php +++ /dev/null @@ -1,109 +0,0 @@ - $item) { + $path = $item['path']; + foreach (['web.php', 'ms3_routes_web', 'ms3.routes.d' . DIRECTORY_SEPARATOR . 'web'] as $forbidden) { + if (str_contains($path, $forbidden)) { + $fail("manager plan[{$i}] contains forbidden fragment {$forbidden}: {$path}"); + } + } +} + +$webPlan = Router::webRoutePlan($component, $core); +$assertSame(3, count($webPlan), 'web plan size'); +$assertSame( + $component . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'routes' . DIRECTORY_SEPARATOR . 'web.php', + $webPlan[0]['path'], + 'web.php path' +); +$assertSame( + $core . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'ms3.routes.d' . DIRECTORY_SEPARATOR . 'web', + $webPlan[2]['path'], + 'web addon dir' +); + +foreach ($webPlan as $i => $item) { + $path = $item['path']; + foreach (['manager.php', 'ms3_routes_manager', 'ms3.routes.d' . DIRECTORY_SEPARATOR . 'manager'] as $forbidden) { + if (str_contains($path, $forbidden)) { + $fail("web plan[{$i}] contains forbidden fragment {$forbidden}: {$path}"); + } + } +} + +// Plans must be disjoint (no shared path) +$managerPaths = array_column($managerPlan, 'path'); +$webPaths = array_column($webPlan, 'path'); +$overlap = array_intersect($managerPaths, $webPaths); +if ($overlap !== []) { + $fail('manager and web plans overlap: ' . implode(', ', $overlap)); +} + +// Connector trait must use loadManagerRoutes + isStorefrontRoute (wiring check) +$traitSrc = file_get_contents(__DIR__ . '/../src/Processors/Api/ProcessesManagerConnectorRouteTrait.php'); +if ($traitSrc === false) { + $fail('cannot read ProcessesManagerConnectorRouteTrait.php'); +} +if (!str_contains($traitSrc, 'loadManagerRoutes')) { + $fail('trait must call loadManagerRoutes()'); +} +if (!str_contains($traitSrc, 'isStorefrontRoute')) { + $fail('trait must reject storefront via isStorefrontRoute()'); +} +if (str_contains($traitSrc, 'loadWebRoutes') || str_contains($traitSrc, 'ManagerConnectorRouteLoader')) { + $fail('trait must not load web routes or use deleted ManagerConnectorRouteLoader'); +} + +if (file_exists(__DIR__ . '/../src/Processors/Api/ManagerConnectorRouteLoader.php')) { + $fail('ManagerConnectorRouteLoader.php should be deleted'); +} + +$apiPhpPath = dirname(__DIR__, 4) . '/assets/components/minishop3/api.php'; +$apiPhp = file_get_contents($apiPhpPath); +if ($apiPhp === false || !str_contains($apiPhp, 'loadWebRoutes')) { + $fail('api.php must use loadWebRoutes() (' . $apiPhpPath . ')'); +} + +fwrite(STDOUT, "OK: RouterRoutePlansTest\n"); From 95d562c1a00bc6943257cedc2a8b940e2af8407d Mon Sep 17 00:00:00 2001 From: Ivan Bochkarev Date: Thu, 16 Jul 2026 10:14:19 +0600 Subject: [PATCH 3/4] test(api): assert user-visible error when manager.php is missing --- core/components/minishop3/tests/RouterRoutePlansTest.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/core/components/minishop3/tests/RouterRoutePlansTest.php b/core/components/minishop3/tests/RouterRoutePlansTest.php index 5396e084..b8968940 100644 --- a/core/components/minishop3/tests/RouterRoutePlansTest.php +++ b/core/components/minishop3/tests/RouterRoutePlansTest.php @@ -102,6 +102,9 @@ if (!str_contains($traitSrc, 'isStorefrontRoute')) { $fail('trait must reject storefront via isStorefrontRoute()'); } +if (!str_contains($traitSrc, 'System routes not found:')) { + $fail('trait must return user-visible message when manager.php is missing'); +} if (str_contains($traitSrc, 'loadWebRoutes') || str_contains($traitSrc, 'ManagerConnectorRouteLoader')) { $fail('trait must not load web routes or use deleted ManagerConnectorRouteLoader'); } From 80a55e0a2d87d358ace0825a22336f411e78f70b Mon Sep 17 00:00:00 2001 From: Ivan Bochkarev Date: Thu, 16 Jul 2026 10:14:51 +0600 Subject: [PATCH 4/4] fix(api): set HTTP status on connector early rejects Align storefront 404 and other early failures with http_response_code, not only the JSON code field. --- .../Api/ProcessesManagerConnectorRouteTrait.php | 9 +++++++++ core/components/minishop3/tests/RouterRoutePlansTest.php | 4 ++++ 2 files changed, 13 insertions(+) diff --git a/core/components/minishop3/src/Processors/Api/ProcessesManagerConnectorRouteTrait.php b/core/components/minishop3/src/Processors/Api/ProcessesManagerConnectorRouteTrait.php index 120caae7..8b464da6 100644 --- a/core/components/minishop3/src/Processors/Api/ProcessesManagerConnectorRouteTrait.php +++ b/core/components/minishop3/src/Processors/Api/ProcessesManagerConnectorRouteTrait.php @@ -20,10 +20,14 @@ public function process() $route = trim((string)$this->getProperty('route', '')); if ($route === '') { + http_response_code(400); + return $this->failure('Route parameter is required', ['code' => 400]); } if (ApiRouter::isStorefrontRoute($route)) { + http_response_code(404); + return $this->failure( 'Storefront API is not available via manager connector. Use api.php.', ['code' => 404] @@ -34,11 +38,15 @@ public function process() $autoloader = $componentPath . 'vendor/autoload.php'; if (!file_exists($autoloader)) { + http_response_code(500); + return $this->failure('Component not properly installed. Run: composer install', ['code' => 500]); } $managerRoutes = $componentPath . 'config/routes/manager.php'; if (!file_exists($managerRoutes)) { + http_response_code(500); + return $this->failure('System routes not found: ' . $managerRoutes, ['code' => 500]); } @@ -67,6 +75,7 @@ public function process() ); } catch (\Exception $e) { $this->modx->log(\MODX\Revolution\modX::LOG_LEVEL_ERROR, '[MiniShop3 API] ' . $e->getMessage()); + http_response_code(500); return $this->failure( $this->modx->getOption('ms3_api_debug', null, false) diff --git a/core/components/minishop3/tests/RouterRoutePlansTest.php b/core/components/minishop3/tests/RouterRoutePlansTest.php index b8968940..58bafbee 100644 --- a/core/components/minishop3/tests/RouterRoutePlansTest.php +++ b/core/components/minishop3/tests/RouterRoutePlansTest.php @@ -105,6 +105,10 @@ if (!str_contains($traitSrc, 'System routes not found:')) { $fail('trait must return user-visible message when manager.php is missing'); } +// Early rejects set HTTP status (storefront 404), not only JSON code +if (!str_contains($traitSrc, 'http_response_code(404)')) { + $fail('trait must call http_response_code(404) when rejecting storefront routes'); +} if (str_contains($traitSrc, 'loadWebRoutes') || str_contains($traitSrc, 'ManagerConnectorRouteLoader')) { $fail('trait must not load web routes or use deleted ManagerConnectorRouteLoader'); }