diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cca480fd..7a39ead42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file. ### Added - Access Points (Plugins can define additional access points to restrict user access to certain parts of the plugin) - Plugin-System now supports custom components, e.g. attribute types +- Plugin-System now supports PluginScopes - .env variable `ALLOW_FILESYSTEM_MIGRATIONS` to explcitly enable filesystem migrations - API endpoint for getting all entity details data in one request: GET::v1/entity/{id}/entity_detail ### Fixed diff --git a/app/Entity.php b/app/Entity.php index 7b3c87350..728046756 100755 --- a/app/Entity.php +++ b/app/Entity.php @@ -7,6 +7,7 @@ use App\Exceptions\AmbiguousValueException; use App\Import\EntityImporter; use App\Traits\CommentTrait; +use App\Traits\HasPluginScopes; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; @@ -25,6 +26,7 @@ class Entity extends Model implements Searchable { use CommentTrait; use SearchableTrait; use LogsActivity; + use HasPluginScopes; /** * The attributes that are assignable. @@ -98,12 +100,6 @@ public static function getSearchCols(): array { return array_keys(self::searchCols); } - protected static function booted(): void { - $pluginScopes = Plugin::getScopesFor(self::class); - foreach($pluginScopes as $pluginScope) { - static::addGlobalScope(new $pluginScope); - } - } public static function getFromPath($path, $delimiter = "\\\\"): ?int { if(!isset($path)) { diff --git a/app/Plugin.php b/app/Plugin.php index 169f43b35..e2608508e 100644 --- a/app/Plugin.php +++ b/app/Plugin.php @@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Log; @@ -38,19 +39,19 @@ class Plugin extends Model 'licence', 'title', ]; - + private static function pluginDirectory() { $pluginDirectory = config('app.plugin_directory'); return base_path($pluginDirectory); } - + public static function getPluginPath(string $path = ''):string { if($path === ''){ return self::pluginDirectory(); } return self::pluginDirectory() . Str::start($path, '/'); } - + public static function isInstalled($name): bool { return self::whereNotNull('installed_at')->where('name', $name)->exists(); } @@ -62,7 +63,7 @@ public static function getInstalled(): Collection { public function slugName(): string { return Str::slug($this->name); } - + public function getPath(string $path = ''): string { $pluginPath = $this->name; if($path !== ''){ @@ -94,7 +95,7 @@ public static function getPluginInfo($path, $isString = false): mixed { return json_decode(json_encode($xmlObject), true); } - + public function getInfo(){ return self::getPluginInfo($this->getPath()); } @@ -109,7 +110,7 @@ public function getMetadata(): array { $metadata[$field] = []; continue; } - + $authors = $info[$field]['author']; $metadata[$field] = is_array($authors) ? $authors : [$authors]; } else { @@ -139,7 +140,7 @@ public function getChangelog(?string $since = null): string { } public function getAccessPoints(): array { - $info = self::getInfo(base_path("app/Plugins/$this->name")); + $info = self::getInfo(); $accesspoints = []; $addedNames = []; $addedPaths = []; @@ -170,51 +171,57 @@ public function getAccessPoints(): array { return $accesspoints; } - public function getScopes(): array { - $info = self::getInfo(base_path("app/Plugins/$this->name"), false); - $scopes = []; - if($info !== false) { - if(array_key_exists('scopes', $info)) { - foreach($info['scopes'] as $scope) { - $attributes = $scope['@attributes']; - if(!array_key_exists('src', $attributes)) { - Log::error(' attribute \'src\' is required'); - continue; - } - if(!array_key_exists('on', $attributes)) { - Log::error(' attribute \'on\' is required'); - continue; - } - - $src = $attributes['src']; - $on = $attributes['on']; - - $srcDir = base_path("app/Plugins/$this->name/Scopes"); - if(!file_exists($srcDir) || !is_dir($srcDir)) { - Log::error('Missing \'Scopes\' directory'); - continue; - } - $srcPath = "{$srcDir}/{$src}"; - if(!file_exists($srcPath)) { - Log::error("Missing file '$src'"); - continue; - } - if(!class_exists($on)) { - Log::error("Class '{$on}' does not exist!"); - continue; - } - $className = Str::replaceEnd('.php', '', $src); - $namespacedSrc = "App\Plugins\\$this->name\Scopes\\$className"; + private function getScopeCacheKey(): string { + return 'plugin_scopes_' . $this->id; + } - if(!array_key_exists($on, $scopes)) { - $scopes[$on] = []; + public function getScopes(): array { + return Cache::rememberForever($this->getScopeCacheKey(), function() { + $info = self::getInfo(); + $scopes = []; + if($info !== false) { + if(array_key_exists('scopes', $info)) { + foreach($info['scopes'] as $scope) { + $attributes = $scope['@attributes']; + if(!array_key_exists('src', $attributes)) { + Log::error(' attribute \'src\' is required'); + continue; + } + if(!array_key_exists('on', $attributes)) { + Log::error(' attribute \'on\' is required'); + continue; + } + + $src = $attributes['src']; + $on = $attributes['on']; + + $srcDir = $this->getPath("Scopes"); + if(!file_exists($srcDir) || !is_dir($srcDir)) { + Log::error('Missing \'Scopes\' directory'); + continue; + } + $srcPath = $srcDir . DIRECTORY_SEPARATOR . $src; + if(!file_exists($srcPath)) { + Log::error("Missing file '$src'"); + continue; + } + if(!class_exists($on)) { + Log::error("Class '{$on}' does not exist!"); + continue; + } + $className = Str::replaceEnd('.php', '', $src); + $namespacedSrc = "App\\Plugins\\$this->name\\Scopes\\$className"; + + if(!array_key_exists($on, $scopes)) { + $scopes[$on] = []; + } + + $scopes[$on][] = $namespacedSrc; } - - $scopes[$on][] = $namespacedSrc; } } - } - return $scopes; + return $scopes; + }); } /** @@ -344,11 +351,16 @@ public function updateUpdateState($fromInfoVersion): void { } } + public function clearCache(): void { + Cache::forget($this->getScopeCacheKey()); + } + public function handleInstallation(bool $isUpdate = false): void { $this->runMigrations(); $this->publishScript(); $this->addPermissions(); $this->installPresetsFromFile(); + $this->clearCache(); if(!$isUpdate) { $this->installed_at = Carbon::now(); @@ -369,7 +381,7 @@ public function handleUpdate(): string { public function handleUninstall(): void { $this->removeScript(); - + $this->clearCache(); $this->installed_at = null; $this->save(); } diff --git a/app/Traits/HasPluginScopes.php b/app/Traits/HasPluginScopes.php new file mode 100644 index 000000000..2dde1beb1 --- /dev/null +++ b/app/Traits/HasPluginScopes.php @@ -0,0 +1,25 @@ + Carbon::createFromFormat('Y-m-d H:i:s', '2020-04-14 04:40:04', 'UTC'), 'updated_at' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-06-16 06:36:27', 'UTC'), ), + 2 => array( + 'id' => 3, + 'name' => 'ScopePlugin', + 'version' => '3.2.0', + 'uuid' => '123e4567-e89b-12d3-a456-426614174004', + 'update_available' => null, + 'installed_at' => null, + 'created_at' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-08-01 08:00:00', 'UTC'), + 'updated_at' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-08-01 08:00:00', 'UTC'), + ), )); // Reset PostgreSQL sequence to continue from the highest ID diff --git a/tests/Feature/ApiPluginTest.php b/tests/Feature/ApiPluginTest.php index f2ce4147b..ec91abbcc 100644 --- a/tests/Feature/ApiPluginTest.php +++ b/tests/Feature/ApiPluginTest.php @@ -4,15 +4,15 @@ use Tests\TestCase; use App\Plugin; +use App\Entity; use Carbon\Carbon; use Illuminate\Support\Str; use PHPUnit\Framework\Attributes\TestDox; class ApiPluginTest extends TestCase { - - private static function getFooPlugin(): array - { + + private static function getFooPlugin(): array { return [ 'id' => 1, 'name' => 'FooPlugin', @@ -24,9 +24,8 @@ private static function getFooPlugin(): array 'updated_at' => '2020-03-30T03:33:45.000000Z' ]; } - - private static function getBarPlugin(): array - { + + private static function getBarPlugin(): array { return [ 'id' => 2, 'name' => 'BarPlugin', @@ -38,9 +37,21 @@ private static function getBarPlugin(): array 'updated_at' => '2020-06-16T06:36:27.000000Z', ]; } - - private static function getUnregisteredPlugin(): array - { + + private static function getScopePlugin(): array { + return [ + 'id' => 3, + 'name' => 'ScopePlugin', + 'version' => '3.2.0', + 'uuid' => '123e4567-e89b-12d3-a456-426614174004', + 'update_available' => null, + 'installed_at' => null, + 'created_at' => '2020-08-01T08:00:00.000000Z', + 'updated_at' => '2020-08-01T08:00:00.000000Z', + ]; + } + + private static function getUnregisteredPlugin(): array { return [ 'id' => 3, 'name' => 'UnregisteredPlugin', @@ -52,30 +63,29 @@ private static function getUnregisteredPlugin(): array 'updated_at' => '2020-08-01T08:00:00.000000Z', ]; } - - - private function getTestPlugins(): array - { + + + private function getTestPlugins(): array { return [ $this->getFooPlugin(), $this->getBarPlugin() ]; } - - private function mockPluginDirectory($plugin){ + + private function mockPluginDirectory($plugin): void { $required_fields = ['name', 'uuid', 'version']; - + $missing_fields = []; foreach($required_fields as $field) { if(!isset($plugin[$field]) || empty($plugin[$field])) { $missing_fields[] = $field; } } - + if(count($missing_fields) > 0) { throw new \Exception("Plugin is missing required fields: " . implode(", ", $missing_fields)); } - + $structure = [ "package.json" => $this->generatePackageJSON($plugin), "CHANGELOG.md" => "# Changelog", @@ -86,17 +96,17 @@ private function mockPluginDirectory($plugin){ "info.xml" => $this->generateInfoXml($plugin), ] ]; - + // Create plugin directory structure $pluginDir = base_path(config('app.plugin_directory')) . '/' . $plugin['name']; if(!file_exists($pluginDir)) { mkdir($pluginDir, 0755, true); } - + // Process structure array foreach($structure as $name => $content) { $path = $pluginDir . '/' . $name; - + if(is_array($content)) { // Create directory and process contents if(!file_exists($path)) { @@ -111,8 +121,8 @@ private function mockPluginDirectory($plugin){ } } } - - private function generatePackageJSON($plugin){ + + private function generatePackageJSON($plugin): string { return << @@ -145,32 +155,29 @@ private function generateInfoXml($plugin) { {$description} {$plugin['version']} {$licence} - -{$authors} + {$authors} XML; } - + #[TestDox('GET /v1/plugin : Get Plugins')] - public function testGetPlugins() - { + public function testGetPlugins(): void { $response = $this->userRequest() ->get('/api/v1/plugin'); $response->assertStatus(200); - $response->assertJsonCount(2); + $response->assertJsonCount(3); $response->assertJson($this->getTestPlugins()); } #[TestDox('GET /v1/plugin/ : Install Plugin')] - public function testInstallPlugin() - { + public function testInstallPlugin(): void { Carbon::setTestNow('2020-05-15 05:25:06'); $response = $this->userRequest() ->get('/api/v1/plugin/2'); $response->assertStatus(200); - + $installedBarPlugin = $this->getBarPlugin(); $installedBarPlugin['installed_at'] = '2020-05-15T05:25:06.000000Z'; $installedBarPlugin['updated_at'] = '2020-05-15T05:25:06.000000Z'; @@ -181,16 +188,15 @@ public function testInstallPlugin() // Reset time after test Carbon::setTestNow(); } - + #[TestDox('PATCH /v1/plugin/ : Update Plugin')] - public function testUpdatePlugin() - { + public function testUpdatePlugin(): void { Carbon::setTestNow('2020-07-20 10:15:30'); $response = $this->userRequest() ->patch('/api/v1/plugin/1'); - + $response->assertStatus(200); - + $updatedFooPlugin = $this->getFooPlugin(); $updatedFooPlugin['version'] = '2.2.0'; $updatedFooPlugin['update_available'] = null; @@ -202,14 +208,13 @@ public function testUpdatePlugin() } #[TestDox('DELETE /v1/plugin/ : Uninstall Plugin')] - public function testUninstallPlugin() - { + public function testUninstallPlugin(): void { Carbon::setTestNow('2020-07-20 10:15:30'); $response = $this->userRequest() ->delete('/api/v1/plugin/1'); - + $response->assertStatus(200); - + $uninstalledFooPlugin = $this->getFooPlugin(); $uninstalledFooPlugin['installed_at'] = null; $uninstalledFooPlugin['updated_at'] = '2020-07-20T10:15:30.000000Z'; @@ -223,9 +228,8 @@ public function testUninstallPlugin() } #[TestDox('DELETE /v1/plugin/remove/ : Remove Plugin')] - public function testRemovePlugin() - { - + public function testRemovePlugin(): void { + Carbon::setTestNow('2020-07-20 10:15:30'); // Create a plugin entry in the database for the unregistered plugin @@ -235,16 +239,16 @@ public function testRemovePlugin() 'uuid' => '123e4567-e89b-12d3-a456-426614174003', 'installed_at' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-07-20 10:15:30', 'UTC'), ]); - + // We need to create a mock plugin directory for the plugin to be removed $this->mockPluginDirectory($this->getUnregisteredPlugin()); - + $directoryWasCreated = file_exists('tests/assets/Plugins/UnregisteredPlugin'); $this->assertTrue($directoryWasCreated); - + $response = $this->userRequest() ->delete("/api/v1/plugin/remove/{$plugin->id}"); - + $response->assertStatus(200); $response->assertJson([ 'uninstall_location' => 'unregisteredplugin-123e4567-e89b-12d3-a456-426614174003.js', @@ -252,11 +256,11 @@ public function testRemovePlugin() $this->assertDatabaseMissing('plugins', [ 'name' => 'UnregisteredPlugin', ]); - + //File is missing $directoryWasRemoved = file_exists('tests/assets/Plugins/UnregisteredPlugin'); $this->assertFalse($directoryWasRemoved); - + //Other plugins are still there $this->assertDatabaseHas('plugins', [ 'name' => 'FooPlugin', @@ -264,16 +268,61 @@ public function testRemovePlugin() $this->assertDatabaseHas('plugins', [ 'name' => 'BarPlugin', ]); - + $fooPluginDirectoryExists = file_exists('tests/assets/Plugins/FooPlugin'); $this->assertTrue($fooPluginDirectoryExists); - + $barPluginDirectoryExists = file_exists('tests/assets/Plugins/BarPlugin'); $this->assertTrue($barPluginDirectoryExists); - + // Reset time after test Carbon::setTestNow(); } - + // TODO: Add upload plugin test + + public function testScopePlugin(): void { + // Set time for installed_at and updated_at + Carbon::setTestNow('2020-07-20 10:15:30'); + Plugin::where('id', 3)->update([ + 'installed_at' => Carbon::now(), + 'updated_at' => Carbon::now(), + ]); + // We reboot the model to register the plugin scopes + self::rebootModel(Entity::class); + + $response = $this->userRequest() + ->get('/api/v1/search/entity?q='); + + $response->assertStatus(200); + $response->assertJsonCount(2, 'data'); + + $response->assertJsonFragment([ + 'id' => 7, + 'name' => 'Site B', + 'entity_type_id' => 3, + ]); + + $response->assertJsonFragment([ + 'id' => 1, + 'name' => 'Site A', + 'entity_type_id' => 3, + ]); + + // Test if works after uninstalling the plugin + Plugin::where('id', 3)->update([ + 'installed_at' => null, + 'updated_at' => Carbon::now(), + ]); + self::rebootModel(Entity::class); + // Re-run the search query + $response = $this->userRequest() + ->get('/api/v1/search/entity?q='); + + $response->assertStatus(200); + $response->assertJsonCount(8, 'data'); + + // Reset time after test + Carbon::setTestNow(); + } } \ No newline at end of file diff --git a/tests/Feature/ApiSearchTest.php b/tests/Feature/ApiSearchTest.php index 71a522734..e78976eaa 100644 --- a/tests/Feature/ApiSearchTest.php +++ b/tests/Feature/ApiSearchTest.php @@ -197,14 +197,13 @@ public function testEntitySearchEndpoint() $content = json_decode($response->getContent()); $response->assertStatus(200); - $response->assertJsonCount(10); + $response->assertJsonCount(3, 'data'); // response content is Laravel Paginate Array $this->assertObjectHasProperty('data', $content); $this->assertObjectHasProperty('from', $content); $this->assertObjectHasProperty('to', $content); $this->assertObjectHasProperty('per_page', $content); $this->assertObjectHasProperty('current_page', $content); - $this->assertObjectHasProperty('current_page_url', $content); $this->assertObjectHasProperty('first_page_url', $content); $this->assertObjectHasProperty('next_page_url', $content); $this->assertObjectHasProperty('prev_page_url', $content); diff --git a/tests/TestCase.php b/tests/TestCase.php index ab1ffd970..e637ebe68 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -87,4 +87,23 @@ public function userRequest() { 'Accept' => 'application/json' // When not setting this, Laravels validation will return a 302 on failure! ]); } + + /** + * The booted method of laravel models is called before the testCase is run. + * Therefore when you need to modify any model and have those changes being + * available in the booted function of that model. You need to "reboot" the model. + */ + public static function rebootModel($modelClass) { + // Clear global scopes before rebooting + $reflection = new \ReflectionClass($modelClass); + + // Clear global scopes + $scopesProperty = $reflection->getProperty('globalScopes'); + $scopesProperty->setAccessible(true); + $scopesProperty->setValue(null, []); + + // Flush event listeners and reboot + $modelClass::flushEventListeners(); + $modelClass::boot(); + } } \ No newline at end of file diff --git a/tests/assets/Plugins/ScopePlugin/App/info.xml b/tests/assets/Plugins/ScopePlugin/App/info.xml new file mode 100644 index 000000000..d7688c377 --- /dev/null +++ b/tests/assets/Plugins/ScopePlugin/App/info.xml @@ -0,0 +1,14 @@ + + + + ScopePlugin + ScopePlugin Plugin + + 3.2.0 + + + + + + + \ No newline at end of file diff --git a/tests/assets/Plugins/ScopePlugin/CHANGELOG.md b/tests/assets/Plugins/ScopePlugin/CHANGELOG.md new file mode 100644 index 000000000..de7b4fceb --- /dev/null +++ b/tests/assets/Plugins/ScopePlugin/CHANGELOG.md @@ -0,0 +1,3 @@ +# Changelog + +Scope plugin `CHANGELOG.md` \ No newline at end of file diff --git a/tests/assets/Plugins/ScopePlugin/Scopes/EntityScope.php b/tests/assets/Plugins/ScopePlugin/Scopes/EntityScope.php new file mode 100644 index 000000000..86b6538f0 --- /dev/null +++ b/tests/assets/Plugins/ScopePlugin/Scopes/EntityScope.php @@ -0,0 +1,13 @@ +where('entity_type_id', 3); + } +} \ No newline at end of file diff --git a/tests/assets/Plugins/ScopePlugin/js/script.js b/tests/assets/Plugins/ScopePlugin/js/script.js new file mode 100644 index 000000000..cd7f1f2c8 --- /dev/null +++ b/tests/assets/Plugins/ScopePlugin/js/script.js @@ -0,0 +1 @@ +console.log("Hello from ScopePlugin!"); \ No newline at end of file diff --git a/tests/assets/Plugins/ScopePlugin/package.json b/tests/assets/Plugins/ScopePlugin/package.json new file mode 100644 index 000000000..b2eee6bf7 --- /dev/null +++ b/tests/assets/Plugins/ScopePlugin/package.json @@ -0,0 +1,7 @@ +{ + "name": "package_ScopePlugin", + "pluginName": "ScopePlugin", + "version": "3.2.0", + "private": true, + "type": "module" +} \ No newline at end of file diff --git a/tests/assets/Plugins/ScopePlugin/routes/api.php b/tests/assets/Plugins/ScopePlugin/routes/api.php new file mode 100644 index 000000000..bb98e5730 --- /dev/null +++ b/tests/assets/Plugins/ScopePlugin/routes/api.php @@ -0,0 +1 @@ +