diff --git a/src/app/Console/Command.php b/src/app/Console/Command.php index 738b23e9..088aec66 100644 --- a/src/app/Console/Command.php +++ b/src/app/Console/Command.php @@ -1,260 +1,262 @@ output->createProgressBar($count); $bar->setFormat( '%current:7s%/%max:7s% [%bar%] %percent:3s%% %elapsed:7s%/%estimated:-7s% %message% ' ); if ($message) { $bar->setMessage("{$message}..."); } $bar->start(); return $bar; } /** * Find the domain. * * @param string $domain Domain ID or namespace * @param bool $withDeleted Include deleted * * @return \App\Domain|null */ public function getDomain($domain, $withDeleted = false) { return $this->getObject(\App\Domain::class, $domain, 'namespace', $withDeleted); } /** * Find a group. * * @param string $group Group ID or email * @param bool $withDeleted Include deleted * * @return \App\Group|null */ public function getGroup($group, $withDeleted = false) { return $this->getObject(\App\Group::class, $group, 'email', $withDeleted); } /** * Find an object. * * @param string $objectClass The name of the class * @param string $objectIdOrTitle The name of a database field to match. * @param string|null $objectTitle An additional database field to match. * @param bool $withDeleted Act as if --with-deleted was used * * @return mixed */ public function getObject($objectClass, $objectIdOrTitle, $objectTitle = null, $withDeleted = false) { if (!$withDeleted) { $withDeleted = $this->hasOption('with-deleted') && $this->option('with-deleted'); } $object = $this->getObjectModel($objectClass, $withDeleted)->find($objectIdOrTitle); if (!$object && !empty($objectTitle)) { $object = $this->getObjectModel($objectClass, $withDeleted) ->where($objectTitle, $objectIdOrTitle)->first(); } return $object; } /** * Returns a preconfigured Model object for a specified class. * * @param string $objectClass The name of the class * @param bool $withDeleted Include withTrashed() query * * @return mixed */ protected function getObjectModel($objectClass, $withDeleted = false) { if ($withDeleted) { $model = $objectClass::withTrashed(); } else { $model = new $objectClass(); } if ($this->commandPrefix == 'scalpel') { return $model; } - $modelsWithTenant = [ - \App\Discount::class, - \App\Domain::class, - \App\Group::class, - \App\Package::class, - \App\Plan::class, - \App\Resource::class, - \App\Sku::class, - \App\User::class, - ]; - $modelsWithOwner = [ \App\Wallet::class, ]; $tenantId = \config('app.tenant_id'); // Add tenant filter - if (in_array($objectClass, $modelsWithTenant)) { + if (in_array(\App\Traits\BelongsToTenantTrait::class, class_uses($objectClass))) { $model = $model->withEnvTenantContext(); } elseif (in_array($objectClass, $modelsWithOwner)) { $model = $model->whereExists(function ($query) use ($tenantId) { $query->select(DB::raw(1)) ->from('users') ->whereRaw('wallets.user_id = users.id') ->whereRaw('users.tenant_id ' . ($tenantId ? "= $tenantId" : 'is null')); }); } return $model; } /** * Find a resource. * * @param string $resource Resource ID or email * @param bool $withDeleted Include deleted * * @return \App\Resource|null */ public function getResource($resource, $withDeleted = false) { return $this->getObject(\App\Resource::class, $resource, 'email', $withDeleted); } + /** + * Find a shared folder. + * + * @param string $folder Folder ID or email + * @param bool $withDeleted Include deleted + * + * @return \App\SharedFolder|null + */ + public function getSharedFolder($folder, $withDeleted = false) + { + return $this->getObject(\App\SharedFolder::class, $folder, 'email', $withDeleted); + } + /** * Find the user. * * @param string $user User ID or email * @param bool $withDeleted Include deleted * * @return \App\User|null */ public function getUser($user, $withDeleted = false) { return $this->getObject(\App\User::class, $user, 'email', $withDeleted); } /** * Find the wallet. * * @param string $wallet Wallet ID * * @return \App\Wallet|null */ public function getWallet($wallet) { return $this->getObject(\App\Wallet::class, $wallet, null); } public function handle() { if ($this->dangerous) { $this->warn( "This command is a dangerous scalpel command with potentially significant unintended consequences" ); $confirmation = $this->confirm("Are you sure you understand what's about to happen?"); if (!$confirmation) { $this->info("Better safe than sorry."); return false; } $this->info("VĂ¡monos!"); } return true; } /** * Return a string for output, with any additional attributes specified as well. * * @param mixed $entry An object * * @return string */ protected function toString($entry) { /** * Haven't figured out yet, how to test if this command implements an option for additional * attributes. if (!in_array('attr', $this->options())) { return $entry->{$entry->getKeyName()}; } */ $str = [ $entry->{$entry->getKeyName()} ]; foreach ($this->option('attr') as $attr) { if ($attr == $entry->getKeyName()) { $this->warn("Specifying {$attr} is not useful."); continue; } if (!array_key_exists($attr, $entry->toArray())) { $this->error("Attribute {$attr} isn't available"); continue; } if (is_numeric($entry->{$attr})) { $str[] = $entry->{$attr}; } else { $str[] = !empty($entry->{$attr}) ? $entry->{$attr} : "null"; } } return implode(" ", $str); } } diff --git a/src/app/Console/Commands/Scalpel/SharedFolder/CreateCommand.php b/src/app/Console/Commands/Scalpel/SharedFolder/CreateCommand.php new file mode 100644 index 00000000..da364f9a --- /dev/null +++ b/src/app/Console/Commands/Scalpel/SharedFolder/CreateCommand.php @@ -0,0 +1,15 @@ +argument('domain'); + $name = $this->argument('name'); + $type = $this->option('type'); + $acl = $this->option('acl'); + + if (empty($type)) { + $type = 'mail'; + } + + $domain = $this->getDomain($domainName); + + if (!$domain) { + $this->error("No such domain {$domainName}."); + return 1; + } + + if ($domain->isPublic()) { + $this->error("Domain {$domainName} is public."); + return 1; + } + + $owner = $domain->wallet()->owner; + + // Validate folder name and type + $rules = [ + 'name' => ['required', 'string', new SharedFolderName($owner, $domain->namespace)], + 'type' => ['required', 'string', new SharedFolderType()] + ]; + + $v = Validator::make(['name' => $name, 'type' => $type], $rules); + + if ($v->fails()) { + $this->error($v->errors()->all()[0]); + return 1; + } + + DB::beginTransaction(); + + // Create the shared folder + $folder = new SharedFolder(); + $folder->name = $name; + $folder->type = $type; + $folder->domain = $domainName; + $folder->save(); + + $folder->assignToWallet($owner->wallets->first()); + + if (!empty($acl)) { + $errors = $folder->setConfig(['acl' => $acl]); + + if (!empty($errors)) { + $this->error("Invalid --acl entry."); + DB::rollBack(); + return 1; + } + } + + DB::commit(); + + $this->info($folder->id); + } +} diff --git a/src/app/Console/Commands/SharedFolder/DeleteCommand.php b/src/app/Console/Commands/SharedFolder/DeleteCommand.php new file mode 100644 index 00000000..260ea3b5 --- /dev/null +++ b/src/app/Console/Commands/SharedFolder/DeleteCommand.php @@ -0,0 +1,40 @@ +argument('folder'); + $folder = $this->getSharedFolder($input); + + if (empty($folder)) { + $this->error("Shared folder {$input} does not exist."); + return 1; + } + + $folder->delete(); + } +} diff --git a/src/app/Console/Commands/SharedFolder/ForceDeleteCommand.php b/src/app/Console/Commands/SharedFolder/ForceDeleteCommand.php new file mode 100644 index 00000000..c075eb96 --- /dev/null +++ b/src/app/Console/Commands/SharedFolder/ForceDeleteCommand.php @@ -0,0 +1,47 @@ +getSharedFolder($this->argument('folder'), true); + + if (!$folder) { + $this->error("Shared folder not found."); + return 1; + } + + if (!$folder->trashed()) { + $this->error("The shared folder is not yet deleted."); + return 1; + } + + DB::beginTransaction(); + $folder->forceDelete(); + DB::commit(); + } +} diff --git a/src/tests/Feature/Console/SharedFolder/CreateTest.php b/src/tests/Feature/Console/SharedFolder/CreateTest.php new file mode 100644 index 00000000..5ddd2207 --- /dev/null +++ b/src/tests/Feature/Console/SharedFolder/CreateTest.php @@ -0,0 +1,92 @@ +where('name', 'Tasks')->first()) { + $folder->forceDelete(); + } + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + if ($folder = SharedFolder::withTrashed()->where('name', 'Tasks')->first()) { + $folder->forceDelete(); + } + + parent::tearDown(); + } + + /** + * Test command runs + */ + public function testHandle(): void + { + Queue::fake(); + + // Warning: We're not using artisan() here, as this will not + // allow us to test "empty output" cases + + $user = $this->getTestUser('john@kolab.org'); + + // Domain not existing + $code = \Artisan::call("sharedfolder:create unknown.org test"); + $output = trim(\Artisan::output()); + $this->assertSame(1, $code); + $this->assertSame("No such domain unknown.org.", $output); + + // Public domain not allowed + $code = \Artisan::call("sharedfolder:create kolabnow.com Test --type=event"); + $output = trim(\Artisan::output()); + $this->assertSame(1, $code); + $this->assertSame("Domain kolabnow.com is public.", $output); + + // Existing folder + $code = \Artisan::call("sharedfolder:create kolab.org Calendar"); + $output = trim(\Artisan::output()); + $this->assertSame(1, $code); + $this->assertSame("The specified name is not available.", $output); + + // Invalid type + $code = \Artisan::call("sharedfolder:create kolab.org Test --type=unknown"); + $output = trim(\Artisan::output()); + $this->assertSame(1, $code); + $this->assertSame("The specified type is invalid.", $output); + + // Invalid acl + $code = \Artisan::call("sharedfolder:create kolab.org Test --type=task --acl=\"anyone,unknown\""); + $output = trim(\Artisan::output()); + + $this->assertSame(1, $code); + $this->assertSame("Invalid --acl entry.", $output); + $this->assertSame(0, SharedFolder::where('name', 'Test')->count()); + + // Create a folder + $acl = '--acl="anyone, read-only" --acl="jack@kolab.org, full"'; + $code = \Artisan::call("sharedfolder:create kolab.org Tasks --type=task $acl"); + $output = trim(\Artisan::output()); + + $folder = SharedFolder::find($output); + + $this->assertSame(0, $code); + $this->assertSame('Tasks', $folder->name); + $this->assertSame('task', $folder->type); + $this->assertSame($user->wallets->first()->id, $folder->wallet()->id); + $this->assertSame(['anyone, read-only', 'jack@kolab.org, full'], $folder->getConfig()['acl']); + } +} diff --git a/src/tests/Feature/Console/SharedFolder/DeleteTest.php b/src/tests/Feature/Console/SharedFolder/DeleteTest.php new file mode 100644 index 00000000..99cf2c66 --- /dev/null +++ b/src/tests/Feature/Console/SharedFolder/DeleteTest.php @@ -0,0 +1,61 @@ +deleteTestSharedFolder('folder-test@kolabnow.com'); + $this->deleteTestUser('folder-owner@kolabnow.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestSharedFolder('folder-test@kolabnow.com'); + $this->deleteTestUser('folder-owner@kolabnow.com'); + + parent::tearDown(); + } + + /** + * Test command runs + */ + public function testHandle(): void + { + Queue::fake(); + + // Warning: We're not using artisan() here, as this will not + // allow us to test "empty output" cases + + // Non-existing folder + $code = \Artisan::call("sharedfolder:delete test@folder.com"); + $output = trim(\Artisan::output()); + + $this->assertSame(1, $code); + $this->assertSame("Shared folder test@folder.com does not exist.", $output); + + $user = $this->getTestUser('folder-owner@kolabnow.com'); + $folder = $this->getTestSharedFolder('folder-test@kolabnow.com'); + + // Existing folder + $code = \Artisan::call("sharedfolder:delete {$folder->email}"); + $output = trim(\Artisan::output()); + + $this->assertSame(0, $code); + $this->assertSame('', $output); + $this->assertTrue($folder->refresh()->trashed()); + } +} diff --git a/src/tests/Feature/Console/SharedFolder/ForceDeleteTest.php b/src/tests/Feature/Console/SharedFolder/ForceDeleteTest.php new file mode 100644 index 00000000..2d864c04 --- /dev/null +++ b/src/tests/Feature/Console/SharedFolder/ForceDeleteTest.php @@ -0,0 +1,71 @@ +deleteTestSharedFolder('folder-test@kolabnow.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestSharedFolder('folder-test@kolabnow.com'); + + parent::tearDown(); + } + + /** + * Test command runs + */ + public function testHandle(): void + { + Queue::fake(); + + // Warning: We're not using artisan() here, as this will not + // allow us to test "empty output" cases + + $folder = $this->getTestSharedFolder('folder-test@kolabnow.com'); + + // Non-existing folder + $code = \Artisan::call("sharedfolder:force-delete test@folder.com"); + $output = trim(\Artisan::output()); + + $this->assertSame(1, $code); + $this->assertSame("Shared folder not found.", $output); + + // Non-deleted folder + $code = \Artisan::call("sharedfolder:force-delete {$folder->email}"); + $output = trim(\Artisan::output()); + + $this->assertSame(1, $code); + $this->assertSame("The shared folder is not yet deleted.", $output); + + $folder->delete(); + $this->assertTrue($folder->trashed()); + + // Existing and deleted folder + $code = \Artisan::call("sharedfolder:force-delete {$folder->email}"); + $output = trim(\Artisan::output()); + + $this->assertSame(0, $code); + $this->assertSame('', $output); + $this->assertCount( + 0, + SharedFolder::withTrashed()->where('email', $folder->email)->get() + ); + } +}