From 04afc5c7c0369604f4ffac4144ef0a466282aabb Mon Sep 17 00:00:00 2001 From: dante di domenico Date: Wed, 5 Feb 2025 14:13:55 +0100 Subject: [PATCH 01/16] fix: block api proxy rules --- src/Controller/ApiController.php | 50 ++++++++++++++++++- src/Controller/Component/ModulesComponent.php | 21 +++++++- 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/src/Controller/ApiController.php b/src/Controller/ApiController.php index 046203738..a0bfae544 100644 --- a/src/Controller/ApiController.php +++ b/src/Controller/ApiController.php @@ -13,8 +13,11 @@ namespace App\Controller; use BEdita\WebTools\Controller\ApiProxyTrait; +use Cake\Core\Configure; use Cake\Event\EventInterface; +use Cake\Http\Exception\UnauthorizedException; use Cake\Http\Response; +use Cake\Utility\Hash; /** * ApiController class. @@ -35,9 +38,54 @@ class ApiController extends AppController public function beforeFilter(EventInterface $event): ?Response { parent::beforeFilter($event); - + if (!$this->allowed()) { + throw new UnauthorizedException(__('You are not authorized to access this resource')); + } $this->Security->setConfig('unlockedActions', ['post', 'patch', 'delete']); return null; } + + /** + * Check if the request is allowed. + * + * @return bool + */ + protected function allowed(): bool + { + // block requests without referer (i.e. from browser) + $referer = (array)$this->request->getHeader('Referer'); + if (empty($referer)) { + return false; + } + /** @var \Authentication\Identity|null $user */ + $user = $this->Authentication->getIdentity(); + $roles = (array)$user->get('roles'); + if (empty($roles)) { + return false; + } + if (in_array('admin', $roles)) { + return true; + } + $method = $this->request->getMethod(); + $action = $this->request->getParam('pass')[0] ?? null; + $blockedMethods = (array)Configure::read('API.blocked', [ + 'users' => ['GET', 'POST', 'PATCH', 'DELETE'], + ]); + $blocked = in_array($method, $blockedMethods[$action] ?? []); + $modules = $this->viewBuilder()->getVar('modules'); + $modules = array_values($modules); + $modules = (array)Hash::combine($modules, '{n}.name', '{n}.hints.allow'); + $modules = array_merge( + $modules, + [ + 'history' => ['GET'], + 'model' => ['GET'], + ], + ); + $allowedMethods = (array)Hash::get($modules, $action, []); + $allowed = in_array($method, $allowedMethods); + + return $allowed && !$blocked; + } } diff --git a/src/Controller/Component/ModulesComponent.php b/src/Controller/Component/ModulesComponent.php index a69c16d9a..b0716bb65 100644 --- a/src/Controller/Component/ModulesComponent.php +++ b/src/Controller/Component/ModulesComponent.php @@ -20,6 +20,8 @@ use Cake\Controller\Component; use Cake\Core\Configure; use Cake\Event\Event; +use Cake\Event\EventInterface; +use Cake\Http\Client\Response; use Cake\Http\Exception\BadRequestException; use Cake\Http\Exception\InternalErrorException; use Cake\I18n\I18n; @@ -84,6 +86,21 @@ class ModulesComponent extends Component ], ]; + /** + * @inheritDoc + */ + public function beforeFilter(EventInterface $event): ?Response + { + /** @var \Authentication\Identity|null $user */ + $user = $this->Authentication->getIdentity(); + if (empty($user)) { + return null; + } + $this->getController()->set('modules', $this->getModules()); + + return null; + } + /** * Read modules and project info from `/home' endpoint. * @@ -103,12 +120,12 @@ public function startup(): void Cache::delete(sprintf('home_%d', $user->get('id'))); } - $modules = $this->getModules(); $project = $this->getProject(); $uploadable = (array)Hash::get($this->Schema->objectTypesFeatures(), 'uploadable'); - $this->getController()->set(compact('modules', 'project', 'uploadable')); + $this->getController()->set(compact('project', 'uploadable')); $currentModuleName = $this->getConfig('currentModuleName'); + $modules = (array)$this->getController()->viewBuilder()->getVar('modules'); if (!empty($currentModuleName)) { $currentModule = Hash::get($modules, $currentModuleName); } From 03a4793f231ae2b399c27b71694804630ba687d7 Mon Sep 17 00:00:00 2001 From: dante di domenico Date: Wed, 5 Feb 2025 14:24:54 +0100 Subject: [PATCH 02/16] fix: use header Sec-Fetch-Mode --- src/Controller/ApiController.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/Controller/ApiController.php b/src/Controller/ApiController.php index a0bfae544..10cd38278 100644 --- a/src/Controller/ApiController.php +++ b/src/Controller/ApiController.php @@ -53,9 +53,8 @@ public function beforeFilter(EventInterface $event): ?Response */ protected function allowed(): bool { - // block requests without referer (i.e. from browser) - $referer = (array)$this->request->getHeader('Referer'); - if (empty($referer)) { + // block requests from navigate fetch mode, i.e. from browser address bar + if (in_array('navigate', (array)$this->request->getHeader('Sec-Fetch-Mode'))) { return false; } /** @var \Authentication\Identity|null $user */ From 3336ee58211d51bd273e2c03baf720bdca125a5b Mon Sep 17 00:00:00 2001 From: dante di domenico Date: Wed, 5 Feb 2025 15:26:27 +0100 Subject: [PATCH 03/16] fix: check origin, referer, mode --- src/Controller/ApiController.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Controller/ApiController.php b/src/Controller/ApiController.php index 10cd38278..af61b0a82 100644 --- a/src/Controller/ApiController.php +++ b/src/Controller/ApiController.php @@ -53,8 +53,11 @@ public function beforeFilter(EventInterface $event): ?Response */ protected function allowed(): bool { - // block requests from navigate fetch mode, i.e. from browser address bar - if (in_array('navigate', (array)$this->request->getHeader('Sec-Fetch-Mode'))) { + // block requests from browser address bar + $sameOrigin = (string)Hash::get((array)$this->request->getHeader('Sec-Fetch-Site'), 0) === 'same-origin'; + $noReferer = empty((array)$this->request->getHeader('Referer')); + $isNavigate = in_array('navigate', (array)$this->request->getHeader('Sec-Fetch-Mode')); + if (!$sameOrigin || $noReferer || $isNavigate) { return false; } /** @var \Authentication\Identity|null $user */ From 412cad5e0fb001249327827895b40f8aeb5f4766 Mon Sep 17 00:00:00 2001 From: dante di domenico Date: Wed, 5 Feb 2025 16:11:33 +0100 Subject: [PATCH 04/16] feat: do not use api users --- config/routes.php | 6 ++ .../objects-history/objects-history.vue | 2 +- .../recent-activity/recent-activity.vue | 2 +- src/Controller/ApiController.php | 1 + src/Controller/HistoryController.php | 23 ++++++++ src/Utility/ApiTools.php | 56 +++++++++++++++++++ 6 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 src/Utility/ApiTools.php diff --git a/config/routes.php b/config/routes.php index 353679145..41048adff 100644 --- a/config/routes.php +++ b/config/routes.php @@ -423,6 +423,12 @@ ['pass' => ['id', 'relation', 'format', 'query'], '_name' => 'export:related:filtered'] ); + $routes->get( + '/history/objects', + ['controller' => 'History', 'action' => 'objects'], + 'history:objects', + ); + // Download stream $routes->get( '/download/{id}', diff --git a/resources/js/app/components/objects-history/objects-history.vue b/resources/js/app/components/objects-history/objects-history.vue index a9696966a..0576bff2e 100644 --- a/resources/js/app/components/objects-history/objects-history.vue +++ b/resources/js/app/components/objects-history/objects-history.vue @@ -122,7 +122,7 @@ export default { return fetch(`/api/history?${query}`).then((r) => r.json()); }, async getObjects(ids) { - return fetch(`/api/objects?filter[id]=${ids.join(',')}`).then((r) => r.json()); + return fetch(`/history/objects?filter[id]=${ids.join(',')}`).then((r) => r.json()); }, loadHistory(pageSize = 20, page = 1) { this.loading = true; diff --git a/resources/js/app/components/recent-activity/recent-activity.vue b/resources/js/app/components/recent-activity/recent-activity.vue index ffea9851b..ff577b547 100644 --- a/resources/js/app/components/recent-activity/recent-activity.vue +++ b/resources/js/app/components/recent-activity/recent-activity.vue @@ -153,7 +153,7 @@ export default { this.currentPage = json.meta.pagination.page; this.activities = [...(json.data || [])]; const ids = this.activities.filter(item => item.meta.resource_id).map(item => item.meta.resource_id).filter((v, i, a) => a.indexOf(v) === i).map(i=>Number(i)); - const objectResponse = await fetch(`${API_URL}api/objects?filter[id]=${ids.join(',')}&page_size=${pageSize}`, API_OPTIONS); + const objectResponse = await fetch(`${API_URL}history/objects?filter[id]=${ids.join(',')}&page_size=${pageSize}`, API_OPTIONS); const objectJson = await objectResponse.json(); for (const item of this.activities) { const object = objectJson.data.find(obj => obj.id === item.meta.resource_id); diff --git a/src/Controller/ApiController.php b/src/Controller/ApiController.php index af61b0a82..d34b66464 100644 --- a/src/Controller/ApiController.php +++ b/src/Controller/ApiController.php @@ -72,6 +72,7 @@ protected function allowed(): bool $method = $this->request->getMethod(); $action = $this->request->getParam('pass')[0] ?? null; $blockedMethods = (array)Configure::read('API.blocked', [ + 'objects' => ['GET', 'POST', 'PATCH', 'DELETE'], 'users' => ['GET', 'POST', 'PATCH', 'DELETE'], ]); $blocked = in_array($method, $blockedMethods[$action] ?? []); diff --git a/src/Controller/HistoryController.php b/src/Controller/HistoryController.php index 30c587553..c36073f44 100644 --- a/src/Controller/HistoryController.php +++ b/src/Controller/HistoryController.php @@ -1,6 +1,9 @@ loadComponent('Schema'); } + /** + * Get objects list: id, title, uname only / no relationships, no links. + * + * @return void + */ + public function objects(): void + { + $this->viewBuilder()->setClassName('Json'); + $this->getRequest()->allowMethod('get'); + $query = array_merge( + $this->getRequest()->getQueryParams(), + ['fields' => 'id,title,uname'] + ); + $response = ApiTools::cleanResponse((array)$this->apiClient->get('objects', $query)); + $data = $response['data']; + $meta = $response['meta']; + $this->set(compact('data', 'meta')); + $this->setSerialize(['data', 'meta']); + } + /** * Get history data by ID * diff --git a/src/Utility/ApiTools.php b/src/Utility/ApiTools.php new file mode 100644 index 000000000..f62e15bba --- /dev/null +++ b/src/Utility/ApiTools.php @@ -0,0 +1,56 @@ + for more details. + */ + +namespace App\Utility; + +use Cake\Utility\Hash; + +/** + * Api utility methods + */ +class ApiTools +{ + /** + * Clean response. + * + * @param array $response The response. + * @param array $remove The keys to remove. + * @return array + */ + public static function cleanResponse( + array $response, + array $remove = [ + 'fromData' => ['links', 'relationships'], + 'fromMeta' => ['schema'], + ] + ): array { + $data = (array)Hash::get($response, 'data'); + $removeFromData = (array)Hash::get($remove, 'fromData'); + foreach ($data as &$item) { + foreach ($removeFromData as $key) { + unset($item[$key]); + } + } + $meta = (array)Hash::get($response, 'meta'); + $removeFromMeta = (array)Hash::get($remove, 'fromMeta'); + foreach ($meta as $metaKey => &$item) { + if (in_array($metaKey, $removeFromMeta)) { + unset($meta[$metaKey]); + } + } + + return compact('data', 'meta'); + } +} From 2b9781e43d7573c61210fed79ec5539f8fcdd334 Mon Sep 17 00:00:00 2001 From: dante di domenico Date: Wed, 5 Feb 2025 16:30:27 +0100 Subject: [PATCH 05/16] tests: ApiToolsTest --- tests/TestCase/Utility/ApiToolsTest.php | 115 ++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 tests/TestCase/Utility/ApiToolsTest.php diff --git a/tests/TestCase/Utility/ApiToolsTest.php b/tests/TestCase/Utility/ApiToolsTest.php new file mode 100644 index 000000000..ce0a951ca --- /dev/null +++ b/tests/TestCase/Utility/ApiToolsTest.php @@ -0,0 +1,115 @@ + for more details. + */ + +namespace App\Test\TestCase; + +use App\Utility\ApiTools; +use Cake\TestSuite\TestCase; + +/** + * App\Utility\ApiTools Test Case + * + * @coversDefaultClass App\Utility\ApiTools + */ +class ApiToolsTest extends TestCase +{ + /** + * Test `cleanResponse` method. + * + * @return void + * @covers ::cleanResponse() + */ + public function testCleanResponse(): void + { + $response = [ + 'data' => [ + [ + 'id' => 1, + 'attributes' => [ + 'uname' => 'gustavo', + 'title' => 'gustavo supporto', + 'name' => 'gustavo', + 'surname' => 'supporto', + ], + 'links' => [ + 'self' => 'https://api.example.org/users/1', + ], + 'relationships' => [ + 'roles' => [ + 'links' => [ + 'self' => 'https://api.example.org/users/1/relationships/roles', + 'related' => 'https://api.example.org/users/1/roles', + ], + ], + ], + ], + ], + 'meta' => [ + 'pagination' => [ + 'page' => 1, + 'page_count' => 1, + 'page_items' => 1, + 'page_size' => 20, + 'count' => 1, + ], + 'schema' => [ + 'type' => 'object', + 'properties' => [ + 'id' => [ + 'type' => 'integer', + ], + 'type' => [ + 'type' => 'string', + ], + 'attributes' => [ + 'type' => 'object', + ], + 'links' => [ + 'type' => 'object', + ], + 'relationships' => [ + 'type' => 'object', + ], + ], + 'required' => ['id', 'type', 'attributes'], + ], + ], + ]; + $actual = ApiTools::cleanResponse($response); + $expected = [ + 'data' => [ + [ + 'id' => 1, + 'attributes' => [ + 'uname' => 'gustavo', + 'title' => 'gustavo supporto', + 'name' => 'gustavo', + 'surname' => 'supporto', + ], + ], + ], + 'meta' => [ + 'pagination' => [ + 'page' => 1, + 'page_count' => 1, + 'page_items' => 1, + 'page_size' => 20, + 'count' => 1, + ], + ], + ]; + static::assertEquals($expected, $actual); + } +} From 4f6f02a169107591402d23fb70ebba81c475f0ce Mon Sep 17 00:00:00 2001 From: dante di domenico Date: Wed, 5 Feb 2025 17:38:39 +0100 Subject: [PATCH 06/16] tests: ApiControllerTest --- src/Controller/ApiController.php | 8 +- .../TestCase/Controller/ApiControllerTest.php | 215 ++++++++++++++++++ 2 files changed, 219 insertions(+), 4 deletions(-) create mode 100644 tests/TestCase/Controller/ApiControllerTest.php diff --git a/src/Controller/ApiController.php b/src/Controller/ApiController.php index d34b66464..8b022e8c6 100644 --- a/src/Controller/ApiController.php +++ b/src/Controller/ApiController.php @@ -1,4 +1,6 @@ Authentication->getIdentity(); - $roles = (array)$user->get('roles'); + $roles = empty($user) ? [] : (array)$user->get('roles'); if (empty($roles)) { return false; } diff --git a/tests/TestCase/Controller/ApiControllerTest.php b/tests/TestCase/Controller/ApiControllerTest.php new file mode 100644 index 000000000..608bdf929 --- /dev/null +++ b/tests/TestCase/Controller/ApiControllerTest.php @@ -0,0 +1,215 @@ + for more details. + */ + +namespace App\Test\TestCase\Controller; + +use App\Controller\ApiController; +use Authentication\Identity; +use Cake\Http\Exception\UnauthorizedException; +use Cake\Http\ServerRequest; +use Cake\Utility\Hash; + +/** + * {@see \App\Controller\ApiController} Test Case + * + * @coversDefaultClass \App\Controller\ApiController + * @uses \App\Controller\ApiController + */ +class ApiControllerTest extends AppControllerTest +{ + /** + * @inheritDoc + */ + public function setUp(): void + { + parent::setUp(); + $this->loadRoutes(); + } + + /** + * Test subject + * + * @var \App\Controller\ApiController + */ + protected $ApiController; + + /** + * Setup controller to test with request config + * + * @param array $config configuration for controller setup + * @return void + */ + protected function setupController($config = null): void + { + $request = null; + if ($config != null) { + $headers = (array)Hash::get($config, 'headers', []); + unset($config['headers']); + $request = new ServerRequest($config); + foreach ($headers as $name => $value) { + $request = $request->withHeader($name, $value); + } + } + $this->ApiController = new ApiController($request); + } + + /** + * Data provider for `testUnauthorizedException` test case. + * + * @return array + */ + public function unauthorizedExceptionProvider(): array + { + return [ + 'no same origin' => [ + [ + 'headers' => [ + 'Referer' => 'http://example.com', + ], + ], + ], + 'no referer' => [ + [ + 'headers' => [ + 'Sec-Fetch-Site' => 'same-origin', + ], + ], + ], + 'navigate' => [ + [ + 'headers' => [ + 'Sec-Fetch-Site' => 'same-origin', + 'Referer' => 'http://example.com', + 'Sec-Fetch-Mode' => 'navigate', + ], + ], + ], + 'user empty roles' => [ + [ + 'headers' => [ + 'Sec-Fetch-Site' => 'same-origin', + 'Referer' => 'http://example.com', + ], + ], + ], + ]; + } + + /** + * Test for unauthorized exception + * + * @param array $config Request configuration. + * @return void + * @dataProvider unauthorizedExceptionProvider + * @covers ::beforeFilter() + * @covers ::allowed() + */ + public function testUnauthorizedException(array $config): void + { + $expected = new UnauthorizedException(__('You are not authorized to access this resource')); + $this->expectException(get_class($expected)); + $this->expectExceptionCode($expected->getCode()); + $this->expectExceptionMessage($expected->getMessage()); + $this->setupController($config); + $this->ApiController->setRequest($this->ApiController->getRequest()->withAttribute('authentication', $this->getAuthenticationServiceMock())); + $this->ApiController->dispatchEvent('Controller.initialize'); + } + + /** + * Test unauthorized role + * + * @return void + * @covers ::beforeFilter() + * @covers ::allowed() + */ + public function testUnauthorizedRole(): void + { + $expected = new UnauthorizedException(__('You are not authorized to access this resource')); + $this->expectException(get_class($expected)); + $this->expectExceptionCode($expected->getCode()); + $this->expectExceptionMessage($expected->getMessage()); + $this->setupController([ + 'headers' => [ + 'Sec-Fetch-Site' => 'same-origin', + 'Referer' => 'http://example.com', + ], + ]); + $user = new Identity([ + 'id' => 1, + 'username' => 'dummy', + 'roles' => ['readers'], + ]); + $this->ApiController->setRequest($this->ApiController->getRequest()->withAttribute('authentication', $this->getAuthenticationServiceMock($user))); + $this->ApiController->Authentication->setIdentity($user); + $this->ApiController->dispatchEvent('Controller.initialize'); + } + + /** + * Test for authorized admin + * + * @return void + * @covers ::beforeFilter() + * @covers ::allowed() + */ + public function testAuthorizeAdmin(): void + { + $this->setupController([ + 'headers' => [ + 'Sec-Fetch-Site' => 'same-origin', + 'Referer' => 'http://example.com', + ], + ]); + $user = new Identity([ + 'id' => 1, + 'username' => 'admin', + 'roles' => ['admin'], + ]); + $this->ApiController->setRequest($this->ApiController->getRequest()->withAttribute('authentication', $this->getAuthenticationServiceMock($user))); + $this->ApiController->Authentication->setIdentity($user); + $this->ApiController->dispatchEvent('Controller.initialize'); + $actual = $this->ApiController->Security->getConfig('unlockedActions'); + $expected = ['post', 'patch', 'delete']; + static::assertEquals($expected, $actual); + } + + /** + * Test for authorized user + * + * @return void + * @covers ::beforeFilter() + * @covers ::allowed() + */ + public function testUserAllowed(): void + { + $this->setupController([ + 'params' => ['pass' => ['events']], + 'headers' => [ + 'Sec-Fetch-Site' => 'same-origin', + 'Referer' => 'http://example.com', + ], + ]); + $user = new Identity([ + 'id' => 1, + 'username' => 'dummy', + 'roles' => ['manager'], + ]); + $this->ApiController->setRequest($this->ApiController->getRequest()->withAttribute('authentication', $this->getAuthenticationServiceMock($user))); + $this->ApiController->Authentication->setIdentity($user); + $this->ApiController->dispatchEvent('Controller.initialize'); + $actual = $this->ApiController->Security->getConfig('unlockedActions'); + $expected = ['post', 'patch', 'delete']; + static::assertEquals($expected, $actual); + } +} From fa481ec7978b6716ce1a9e84ec1231d94e5226f2 Mon Sep 17 00:00:00 2001 From: dante di domenico Date: Wed, 5 Feb 2025 17:44:44 +0100 Subject: [PATCH 07/16] tests: ApiControllerTest --- .../TestCase/Controller/ApiControllerTest.php | 40 +++++++++++++++++-- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/tests/TestCase/Controller/ApiControllerTest.php b/tests/TestCase/Controller/ApiControllerTest.php index 608bdf929..035d6d49e 100644 --- a/tests/TestCase/Controller/ApiControllerTest.php +++ b/tests/TestCase/Controller/ApiControllerTest.php @@ -16,10 +16,15 @@ namespace App\Test\TestCase\Controller; use App\Controller\ApiController; +use Authentication\AuthenticationServiceInterface; use Authentication\Identity; +use Authentication\IdentityInterface; use Cake\Http\Exception\UnauthorizedException; use Cake\Http\ServerRequest; +use Cake\TestSuite\TestCase; use Cake\Utility\Hash; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; /** * {@see \App\Controller\ApiController} Test Case @@ -27,7 +32,7 @@ * @coversDefaultClass \App\Controller\ApiController * @uses \App\Controller\ApiController */ -class ApiControllerTest extends AppControllerTest +class ApiControllerTest extends TestCase { /** * @inheritDoc @@ -151,7 +156,7 @@ public function testUnauthorizedRole(): void 'username' => 'dummy', 'roles' => ['readers'], ]); - $this->ApiController->setRequest($this->ApiController->getRequest()->withAttribute('authentication', $this->getAuthenticationServiceMock($user))); + $this->ApiController->setRequest($this->ApiController->getRequest()->withAttribute('authentication', $this->getAuthenticationServiceMock())); $this->ApiController->Authentication->setIdentity($user); $this->ApiController->dispatchEvent('Controller.initialize'); } @@ -176,7 +181,7 @@ public function testAuthorizeAdmin(): void 'username' => 'admin', 'roles' => ['admin'], ]); - $this->ApiController->setRequest($this->ApiController->getRequest()->withAttribute('authentication', $this->getAuthenticationServiceMock($user))); + $this->ApiController->setRequest($this->ApiController->getRequest()->withAttribute('authentication', $this->getAuthenticationServiceMock())); $this->ApiController->Authentication->setIdentity($user); $this->ApiController->dispatchEvent('Controller.initialize'); $actual = $this->ApiController->Security->getConfig('unlockedActions'); @@ -205,11 +210,38 @@ public function testUserAllowed(): void 'username' => 'dummy', 'roles' => ['manager'], ]); - $this->ApiController->setRequest($this->ApiController->getRequest()->withAttribute('authentication', $this->getAuthenticationServiceMock($user))); + $this->ApiController->setRequest($this->ApiController->getRequest()->withAttribute('authentication', $this->getAuthenticationServiceMock())); $this->ApiController->Authentication->setIdentity($user); $this->ApiController->dispatchEvent('Controller.initialize'); $actual = $this->ApiController->Security->getConfig('unlockedActions'); $expected = ['post', 'patch', 'delete']; static::assertEquals($expected, $actual); } + + /** + * Get mocked AuthenticationService. + * + * @return AuthenticationServiceInterface + */ + protected function getAuthenticationServiceMock(): AuthenticationServiceInterface + { + $authenticationService = $this->getMockBuilder(AuthenticationServiceInterface::class) + ->getMock(); + $authenticationService->method('clearIdentity') + ->willReturnCallback(function (ServerRequestInterface $request, ResponseInterface $response): array { + return [ + 'request' => $request->withoutAttribute('identity'), + 'response' => $response, + ]; + }); + $authenticationService->method('persistIdentity') + ->willReturnCallback(function (ServerRequestInterface $request, ResponseInterface $response, IdentityInterface $identity): array { + return [ + 'request' => $request->withAttribute('identity', $identity), + 'response' => $response, + ]; + }); + + return $authenticationService; + } } From e1d8c56cc4c8d86cc8695022a641e78c086d3539 Mon Sep 17 00:00:00 2001 From: dante di domenico Date: Wed, 5 Feb 2025 17:55:42 +0100 Subject: [PATCH 08/16] tests: update --- tests/TestCase/Controller/Component/ModulesComponentTest.php | 4 ++++ tests/TestCase/Controller/ModulesControllerTest.php | 2 ++ 2 files changed, 6 insertions(+) diff --git a/tests/TestCase/Controller/Component/ModulesComponentTest.php b/tests/TestCase/Controller/Component/ModulesComponentTest.php index 401ae232f..5ec347b49 100644 --- a/tests/TestCase/Controller/Component/ModulesComponentTest.php +++ b/tests/TestCase/Controller/Component/ModulesComponentTest.php @@ -28,6 +28,7 @@ use Cake\Cache\Cache; use Cake\Controller\Controller; use Cake\Core\Configure; +use Cake\Event\Event; use Cake\Http\Exception\BadRequestException; use Cake\Http\Exception\InternalErrorException; use Cake\TestSuite\TestCase; @@ -276,6 +277,7 @@ public function testIsAbstract($expected, $data): void // Mock Authentication component $controller->setRequest($controller->getRequest()->withAttribute('authentication', $this->getAuthenticationServiceMock())); $this->Modules->Authentication->setIdentity(new Identity(['id' => 1, 'roles' => ['guest']])); + $this->Modules->beforeFilter(new Event('Module.beforeFilter')); $this->Modules->startup(); $actual = $this->Modules->isAbstract($data); @@ -348,6 +350,7 @@ public function testObjectTypes($expected, $data): void $this->Modules->Authentication->setIdentity(new Identity(['id' => 1, 'roles' => ['guest']])); if (!empty($expected)) { + $this->Modules->beforeFilter(new Event('Module.beforeFilter')); $this->Modules->startup(); } $actual = $this->Modules->objectTypes($data); @@ -800,6 +803,7 @@ public function testBeforeRender($userId, $modules, ?string $currentModule, arra $clearHomeCache = true; $this->Modules->setConfig(compact('apiClient', 'currentModuleName', 'clearHomeCache')); + $this->Modules->beforeFilter(new Event('Module.beforeFilter')); $this->Modules->startup(); $viewVars = $controller->viewBuilder()->getVars(); diff --git a/tests/TestCase/Controller/ModulesControllerTest.php b/tests/TestCase/Controller/ModulesControllerTest.php index a7050d7ab..28963e3bf 100644 --- a/tests/TestCase/Controller/ModulesControllerTest.php +++ b/tests/TestCase/Controller/ModulesControllerTest.php @@ -23,6 +23,7 @@ use Authentication\IdentityInterface; use Cake\Cache\Cache; use Cake\Core\Configure; +use Cake\Event\Event; use Cake\Http\ServerRequest; use Cake\Utility\Hash; use Psr\Http\Message\ResponseInterface; @@ -76,6 +77,7 @@ protected function setupController(?array $requestConfig = []): void $this->controller->Authentication->setIdentity(new Identity(['id' => 'dummy'])); // Mock GET /config using cache Cache::write(CacheTools::cacheKey('config.Modules'), []); + $this->controller->Modules->beforeFilter(new Event('Module.beforeFilter')); $this->controller->Modules->startup(); $this->setupApi(); $this->createTestObject(); From 19d04b388c7991d9fb081a7c75b8c4b5dd9446f3 Mon Sep 17 00:00:00 2001 From: dante di domenico Date: Wed, 5 Feb 2025 18:00:13 +0100 Subject: [PATCH 09/16] tests: cover beforeRender --- tests/TestCase/Controller/Component/ModulesComponentTest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/TestCase/Controller/Component/ModulesComponentTest.php b/tests/TestCase/Controller/Component/ModulesComponentTest.php index 5ec347b49..193c8b35e 100644 --- a/tests/TestCase/Controller/Component/ModulesComponentTest.php +++ b/tests/TestCase/Controller/Component/ModulesComponentTest.php @@ -770,6 +770,7 @@ public function startupProvider(): array * @return void * @dataProvider startupProvider() * @covers ::startup() + * @covers ::beforeFilter() */ public function testBeforeRender($userId, $modules, ?string $currentModule, array $project, array $meta, array $config = [], ?string $currentModuleName = null): void { From 1788cc718ddeca1cb2bd850e47cc3c7cf39a12b1 Mon Sep 17 00:00:00 2001 From: dante di domenico Date: Wed, 5 Feb 2025 18:05:01 +0100 Subject: [PATCH 10/16] tests: history objects --- .../Controller/HistoryControllerTest.php | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/TestCase/Controller/HistoryControllerTest.php b/tests/TestCase/Controller/HistoryControllerTest.php index 2ff366937..58b103369 100644 --- a/tests/TestCase/Controller/HistoryControllerTest.php +++ b/tests/TestCase/Controller/HistoryControllerTest.php @@ -213,4 +213,33 @@ public function testSetHistory(array $data, string $expected): void $actual = $this->HistoryController->getRequest()->getSession()->read(sprintf('history.%s.attributes', $id)); static::assertEquals($actual, $expected); } + + /** + * Test `objects` method + * + * @return void + * @covers ::objects() + */ + public function testObjects(): void + { + $this->HistoryController->objects(); + $vars = ['data', 'meta']; + foreach ($vars as $var) { + static::assertNotEmpty($this->HistoryController->viewBuilder()->getVar($var)); + } + $actual = $this->HistoryController->viewBuilder()->getVar('data'); + foreach ($actual as $item) { + static::assertArrayHasKey('id', $item); + static::assertArrayHasKey('type', $item); + static::assertArrayHasKey('attributes', $item); + $attributes = $item['attributes']; + static::assertArrayHasKey('uname', $attributes); + static::assertArrayHasKey('title', $attributes); + static::assertArrayNotHasKey('links', $item); + static::assertArrayNotHasKey('relationships', $item); + } + $actual = $this->HistoryController->viewBuilder()->getVar('meta'); + static::assertArrayHasKey('pagination', $actual); + static::assertArrayNotHasKey('schema', $actual); + } } From b4d0343a110cca107b3d6d2091f1eaeeff829678 Mon Sep 17 00:00:00 2001 From: dante di domenico Date: Wed, 5 Feb 2025 18:09:08 +0100 Subject: [PATCH 11/16] refactor: ModulesComponent::beforeFilter --- src/Controller/Component/ModulesComponent.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/Controller/Component/ModulesComponent.php b/src/Controller/Component/ModulesComponent.php index b0716bb65..d8185983e 100644 --- a/src/Controller/Component/ModulesComponent.php +++ b/src/Controller/Component/ModulesComponent.php @@ -93,10 +93,9 @@ public function beforeFilter(EventInterface $event): ?Response { /** @var \Authentication\Identity|null $user */ $user = $this->Authentication->getIdentity(); - if (empty($user)) { - return null; + if (!empty($user)) { + $this->getController()->set('modules', $this->getModules()); } - $this->getController()->set('modules', $this->getModules()); return null; } From f0ead1d118f98e0b2eff1c5f5ff6a5347720b960 Mon Sep 17 00:00:00 2001 From: dante di domenico Date: Wed, 5 Feb 2025 18:31:47 +0100 Subject: [PATCH 12/16] feat: use ApiProxy configuration --- config/app_local.example.php | 16 ++++++++++++++++ src/Controller/ApiController.php | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/config/app_local.example.php b/config/app_local.example.php index e8708f584..4cd85f4a5 100644 --- a/config/app_local.example.php +++ b/config/app_local.example.php @@ -66,6 +66,22 @@ // //], // ], + /** + * Api Proxy configuration, for ApiController. + * This refers to `/api/{endpoint}` calls. + * Contains an array of setting to use for API proxy configuration. + * + * ## Options + * + * - `blocked` - Array of blocked methods per endpoint. + */ + // 'ApiProxy' => [ + // 'blocked' => [ + // 'objects' => ['GET', 'POST', 'PATCH', 'DELETE'], + // 'users' => ['GET', 'POST', 'PATCH', 'DELETE'], + // ], + // ], + /** * Clone configuration. * This adds custom rules to clone objects. diff --git a/src/Controller/ApiController.php b/src/Controller/ApiController.php index 8b022e8c6..4ff8df887 100644 --- a/src/Controller/ApiController.php +++ b/src/Controller/ApiController.php @@ -71,7 +71,7 @@ protected function allowed(): bool } $method = $this->request->getMethod(); $action = $this->request->getParam('pass')[0] ?? null; - $blockedMethods = (array)Configure::read('API.blocked', [ + $blockedMethods = (array)Configure::read('ApiProxy.blocked', [ 'objects' => ['GET', 'POST', 'PATCH', 'DELETE'], 'users' => ['GET', 'POST', 'PATCH', 'DELETE'], ]); From 32e73ec9877958526b63606f13606133d929c247 Mon Sep 17 00:00:00 2001 From: dante di domenico Date: Thu, 6 Feb 2025 12:29:18 +0100 Subject: [PATCH 13/16] feat: use bedita web-tools 5.3 --- composer.json | 2 +- src/Controller/HistoryController.php | 2 +- src/Utility/ApiTools.php | 56 ------------ tests/TestCase/Utility/ApiToolsTest.php | 115 ------------------------ 4 files changed, 2 insertions(+), 173 deletions(-) delete mode 100644 src/Utility/ApiTools.php delete mode 100644 tests/TestCase/Utility/ApiToolsTest.php diff --git a/composer.json b/composer.json index 939210928..8a4c37398 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,7 @@ "require": { "php": ">=8.3", "bedita/i18n": "^5.1.0", - "bedita/web-tools": "^5.1.0", + "bedita/web-tools": "^5.3", "cakephp/authentication": "^2.9", "cakephp/cakephp": "~4.5.0", "cakephp/plugin-installer": "^1.3", diff --git a/src/Controller/HistoryController.php b/src/Controller/HistoryController.php index c36073f44..97fefa3e1 100644 --- a/src/Controller/HistoryController.php +++ b/src/Controller/HistoryController.php @@ -3,7 +3,7 @@ namespace App\Controller; -use App\Utility\ApiTools; +use BEdita\WebTools\Utility\ApiTools; use Cake\Http\Response; /** diff --git a/src/Utility/ApiTools.php b/src/Utility/ApiTools.php deleted file mode 100644 index f62e15bba..000000000 --- a/src/Utility/ApiTools.php +++ /dev/null @@ -1,56 +0,0 @@ - for more details. - */ - -namespace App\Utility; - -use Cake\Utility\Hash; - -/** - * Api utility methods - */ -class ApiTools -{ - /** - * Clean response. - * - * @param array $response The response. - * @param array $remove The keys to remove. - * @return array - */ - public static function cleanResponse( - array $response, - array $remove = [ - 'fromData' => ['links', 'relationships'], - 'fromMeta' => ['schema'], - ] - ): array { - $data = (array)Hash::get($response, 'data'); - $removeFromData = (array)Hash::get($remove, 'fromData'); - foreach ($data as &$item) { - foreach ($removeFromData as $key) { - unset($item[$key]); - } - } - $meta = (array)Hash::get($response, 'meta'); - $removeFromMeta = (array)Hash::get($remove, 'fromMeta'); - foreach ($meta as $metaKey => &$item) { - if (in_array($metaKey, $removeFromMeta)) { - unset($meta[$metaKey]); - } - } - - return compact('data', 'meta'); - } -} diff --git a/tests/TestCase/Utility/ApiToolsTest.php b/tests/TestCase/Utility/ApiToolsTest.php deleted file mode 100644 index ce0a951ca..000000000 --- a/tests/TestCase/Utility/ApiToolsTest.php +++ /dev/null @@ -1,115 +0,0 @@ - for more details. - */ - -namespace App\Test\TestCase; - -use App\Utility\ApiTools; -use Cake\TestSuite\TestCase; - -/** - * App\Utility\ApiTools Test Case - * - * @coversDefaultClass App\Utility\ApiTools - */ -class ApiToolsTest extends TestCase -{ - /** - * Test `cleanResponse` method. - * - * @return void - * @covers ::cleanResponse() - */ - public function testCleanResponse(): void - { - $response = [ - 'data' => [ - [ - 'id' => 1, - 'attributes' => [ - 'uname' => 'gustavo', - 'title' => 'gustavo supporto', - 'name' => 'gustavo', - 'surname' => 'supporto', - ], - 'links' => [ - 'self' => 'https://api.example.org/users/1', - ], - 'relationships' => [ - 'roles' => [ - 'links' => [ - 'self' => 'https://api.example.org/users/1/relationships/roles', - 'related' => 'https://api.example.org/users/1/roles', - ], - ], - ], - ], - ], - 'meta' => [ - 'pagination' => [ - 'page' => 1, - 'page_count' => 1, - 'page_items' => 1, - 'page_size' => 20, - 'count' => 1, - ], - 'schema' => [ - 'type' => 'object', - 'properties' => [ - 'id' => [ - 'type' => 'integer', - ], - 'type' => [ - 'type' => 'string', - ], - 'attributes' => [ - 'type' => 'object', - ], - 'links' => [ - 'type' => 'object', - ], - 'relationships' => [ - 'type' => 'object', - ], - ], - 'required' => ['id', 'type', 'attributes'], - ], - ], - ]; - $actual = ApiTools::cleanResponse($response); - $expected = [ - 'data' => [ - [ - 'id' => 1, - 'attributes' => [ - 'uname' => 'gustavo', - 'title' => 'gustavo supporto', - 'name' => 'gustavo', - 'surname' => 'supporto', - ], - ], - ], - 'meta' => [ - 'pagination' => [ - 'page' => 1, - 'page_count' => 1, - 'page_items' => 1, - 'page_size' => 20, - 'count' => 1, - ], - ], - ]; - static::assertEquals($expected, $actual); - } -} From 142092b661f798328c0c37a43d638a1100966c46 Mon Sep 17 00:00:00 2001 From: dante di domenico Date: Thu, 6 Feb 2025 14:10:03 +0100 Subject: [PATCH 14/16] fix: AdministrationBaseController loadData max 500 --- .../Admin/AdministrationBaseController.php | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/src/Controller/Admin/AdministrationBaseController.php b/src/Controller/Admin/AdministrationBaseController.php index 25e2739f2..e24ba0483 100644 --- a/src/Controller/Admin/AdministrationBaseController.php +++ b/src/Controller/Admin/AdministrationBaseController.php @@ -14,6 +14,7 @@ use App\Controller\AppController; use BEdita\SDK\BEditaClientException; +use BEdita\WebTools\Utility\ApiTools; use Cake\Event\EventInterface; use Cake\Http\Exception\UnauthorizedException; use Cake\Http\Response; @@ -215,17 +216,22 @@ protected function loadData(): array $query = $this->getRequest()->getQueryParams(); $resourceEndpoint = sprintf('%s/%s', $this->endpoint, $this->resourceType); $endpoint = $this->resourceType === 'roles' ? 'roles' : $resourceEndpoint; - $resultResponse = []; - $pagination = ['page' => 0]; - while (Hash::get($pagination, 'page') === 0 || Hash::get($pagination, 'page', -1) < Hash::get($pagination, 'page_count', -1)) { - $query['page'] = $pagination['page'] + 1; - $response = (array)$this->apiClient->get($endpoint, $query); - $pagination = (array)Hash::get($response, 'meta.pagination'); - foreach ((array)Hash::get($response, 'data') as $data) { - $resultResponse['data'][] = $data; - } + $resultResponse = ['data' => []]; + $pageCount = $page = 1; + $total = 0; + $limit = 500; + while ($limit > $total && $page <= $pageCount) { + $response = (array)$this->apiClient->get($endpoint, compact('page') + ['page_size' => 100]); + $response = ApiTools::cleanResponse($response); + $resultResponse['data'] = array_merge( + $resultResponse['data'], + (array)Hash::get($response, 'data'), + ); $resultResponse['meta'] = Hash::get($response, 'meta'); - $resultResponse['links'] = Hash::get($response, 'links'); + $pageCount = (int)Hash::get($response, 'meta.pagination.page_count'); + $count = (int)Hash::get($response, 'meta.pagination.page_items'); + $total += $count; + $page++; } return $resultResponse; From e200d092ab3704db05e8388123b398f7ce031725 Mon Sep 17 00:00:00 2001 From: dante di domenico Date: Thu, 6 Feb 2025 16:41:44 +0100 Subject: [PATCH 15/16] fix: use internal endpoints with minimal data for objects and users --- config/routes.php | 6 +++ .../placeholder-list/placeholder-list.vue | 2 +- .../components/property-view/property-view.js | 2 +- .../user-accesses/user-accesses.vue | 2 +- .../js/app/plugins/tinymce/placeholders.js | 7 +-- src/Controller/ModulesController.php | 45 +++++++++++++++++ .../Controller/ModulesControllerTest.php | 48 +++++++++++++++++++ 7 files changed, 104 insertions(+), 8 deletions(-) diff --git a/config/routes.php b/config/routes.php index 41048adff..5bd9611bb 100644 --- a/config/routes.php +++ b/config/routes.php @@ -429,6 +429,12 @@ 'history:objects', ); + $routes->get( + '/users/list', + ['controller' => 'Modules', 'action' => 'users'], + 'users:list', + ); + // Download stream $routes->get( '/download/{id}', diff --git a/resources/js/app/components/placeholder-list/placeholder-list.vue b/resources/js/app/components/placeholder-list/placeholder-list.vue index 71a4b49dd..c9a3cbf38 100644 --- a/resources/js/app/components/placeholder-list/placeholder-list.vue +++ b/resources/js/app/components/placeholder-list/placeholder-list.vue @@ -103,7 +103,7 @@ export default { return this.cache[id]; } const baseUrl = new URL(BEDITA.base).pathname; - const response = await fetch(`${baseUrl}api/objects/${id}`, { + const response = await fetch(`${baseUrl}resources/get/${id}`, { credentials: 'same-origin', headers: { accept: 'application/json', diff --git a/resources/js/app/components/property-view/property-view.js b/resources/js/app/components/property-view/property-view.js index af688f937..dfbf115df 100644 --- a/resources/js/app/components/property-view/property-view.js +++ b/resources/js/app/components/property-view/property-view.js @@ -191,7 +191,7 @@ export default { const creatorId = this.object?.meta?.created_by; const modifierId = this.object?.meta?.modified_by; const usersId = [creatorId, modifierId]; - const userRes = await fetch(`${API_URL}api/users?filter[id]=${usersId.join(',')}&fields[users]=name,surname,username`, API_OPTIONS); + const userRes = await fetch(`${API_URL}users/list?filter[id]=${usersId.join(',')}`, API_OPTIONS); const userJson = await userRes.json(); const users = userJson.data; diff --git a/resources/js/app/components/user-accesses/user-accesses.vue b/resources/js/app/components/user-accesses/user-accesses.vue index f2d9ee3b0..0aff8345c 100644 --- a/resources/js/app/components/user-accesses/user-accesses.vue +++ b/resources/js/app/components/user-accesses/user-accesses.vue @@ -76,7 +76,7 @@ export default { async getAccesses(pageSize = 20, page = 1) { const filterDate = this.filterDate ? new Date(this.filterDate).toISOString() : ''; - return fetch(`/api/users?page_size=${pageSize}&page=${page}&filter[last_login][gt]=${filterDate}&sort=-last_login`).then((r) => r.json()); + return fetch(`/users/list?page_size=${pageSize}&page=${page}&filter[last_login][gt]=${filterDate}&sort=-last_login`).then((r) => r.json()); }, loadAccesses(pageSize = 20, page = 1) { this.loading = true; diff --git a/resources/js/app/plugins/tinymce/placeholders.js b/resources/js/app/plugins/tinymce/placeholders.js index b6dabe2d4..3af9defc5 100644 --- a/resources/js/app/plugins/tinymce/placeholders.js +++ b/resources/js/app/plugins/tinymce/placeholders.js @@ -16,11 +16,7 @@ const options = { function fetchData(id) { if (!cache[id]) { - let fetchType = fetch(`${baseUrl}api/objects/${id}`, options) - .then((response) => response.json()) - .then((json) => json.data.type); - cache[id] = fetchType - .then((type) => fetch(`${baseUrl}api/${type}/${id}`, options)) + cache[id] = fetch(`${baseUrl}resources/get/${id}`, options) .then((response) => response.json()); } @@ -40,6 +36,7 @@ function loadPreview(editor, node, id) { if (!data) { return; } + console.log(data); let domElements = editor.getBody().querySelectorAll(`[data-placeholder="${data.id}"]`); [...domElements].forEach((dom) => { diff --git a/src/Controller/ModulesController.php b/src/Controller/ModulesController.php index af074bbb1..58c033a90 100644 --- a/src/Controller/ModulesController.php +++ b/src/Controller/ModulesController.php @@ -16,6 +16,7 @@ use App\Utility\Message; use App\Utility\PermissionsTrait; use BEdita\SDK\BEditaClientException; +use BEdita\WebTools\Utility\ApiTools; use Cake\Core\Configure; use Cake\Event\Event; use Cake\Event\EventInterface; @@ -611,4 +612,48 @@ private function setupViewRelations(array $relations): void $this->set('schemasByType', $schemasByType); $this->set('filtersByType', $this->Properties->filtersByType($rightTypes)); } + + /** + * Get list of users / no email, no relationships, no links, no schema, no included. + * + * @return void + */ + public function users(): void + { + $this->viewBuilder()->setClassName('Json'); + $this->getRequest()->allowMethod('get'); + $query = array_merge( + $this->getRequest()->getQueryParams(), + ['fields' => 'id,title,username,name,surname'] + ); + $response = (array)$this->apiClient->get('users', $query); + $response = ApiTools::cleanResponse($response); + $data = (array)Hash::get($response, 'data'); + $meta = (array)Hash::get($response, 'meta'); + $this->set(compact('data', 'meta')); + $this->setSerialize(['data', 'meta']); + } + + /** + * Get single resource, minimal data / no relationships, no links, no schema, no included. + * + * @param string $id The object ID + * @return void + */ + public function get(string $id): void + { + $this->viewBuilder()->setClassName('Json'); + $this->getRequest()->allowMethod('get'); + $response = (array)$this->apiClient->getObject($id, 'objects'); + $query = array_merge( + $this->getRequest()->getQueryParams(), + ['fields' => 'id,title,description,uname,status,media_url'] + ); + $response = (array)$this->apiClient->getObject($id, $response['data']['type'], $query); + $response = ApiTools::cleanResponse($response); + $data = (array)Hash::get($response, 'data'); + $meta = (array)Hash::get($response, 'meta'); + $this->set(compact('data', 'meta')); + $this->setSerialize(['data', 'meta']); + } } diff --git a/tests/TestCase/Controller/ModulesControllerTest.php b/tests/TestCase/Controller/ModulesControllerTest.php index 28963e3bf..154407a71 100644 --- a/tests/TestCase/Controller/ModulesControllerTest.php +++ b/tests/TestCase/Controller/ModulesControllerTest.php @@ -1070,4 +1070,52 @@ public function testGetSetObjectType(): void $actual = $this->controller->getObjectType(); static::assertSame($expected, $actual); } + + /** + * Test list users + * + * @return void + * @covers ::users() + */ + public function testListUsers(): void + { + $this->setupController(); + // Get list of users / no email, no relationships, no links, no schema, no included. + $this->controller->users(); + $actual = $this->controller->viewBuilder()->getVars(); + // check data has only id,title,username,name,surname + $data = $actual['data']; + foreach ($data as $item) { + $itemKeys = array_keys($item); + sort($itemKeys); + static::assertSame(['attributes', 'id', 'meta', 'type'], $itemKeys); + $keys = array_keys($item['attributes']); + sort($keys); + static::assertSame($keys, ['name', 'surname', 'title', 'username']); + } + } + + /** + * Test get single resource minimal data + * + * @return void + * @covers ::get() + */ + public function testResourceGet(): void + { + $this->setupController(); + // get object ID for test + $id = $this->getTestId(); + // Get single resource, minimal data / no relationships, no links, no schema, no included. + $this->controller->get($id); + $actual = $this->controller->viewBuilder()->getVars(); + // check data has only id,title,description,uname,status + $item = $actual['data']; + $itemKeys = array_keys($item); + sort($itemKeys); + static::assertSame(['attributes', 'id', 'type'], $itemKeys); + $keys = array_keys($item['attributes']); + sort($keys); + static::assertSame($keys, ['description', 'status', 'title', 'uname']); + } } From 9653eb18ea249b5476bc002214d868fbcb52daaf Mon Sep 17 00:00:00 2001 From: dante di domenico Date: Thu, 6 Feb 2025 16:41:58 +0100 Subject: [PATCH 16/16] fix: routes --- config/routes.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/config/routes.php b/config/routes.php index 5bd9611bb..a6b6d0147 100644 --- a/config/routes.php +++ b/config/routes.php @@ -434,6 +434,11 @@ ['controller' => 'Modules', 'action' => 'users'], 'users:list', ); + $routes->connect( + '/resources/get/{id}', + ['controller' => 'Modules', 'action' => 'get'], + ['pass' => ['id'], '_name' => 'resource:get'], + ); // Download stream $routes->get(