diff --git a/src/app/Backends/IMAP.php b/src/app/Backends/IMAP.php --- a/src/app/Backends/IMAP.php +++ b/src/app/Backends/IMAP.php @@ -33,7 +33,7 @@ /** * Check if a shared folder is set up. * - * @param string $folder Folder name, eg. shared/Resources/Name@domain.tld + * @param string $folder Folder name, e.g. shared/Resources/Name@domain.tld * * @return bool True if a folder exists and is set up, False otherwise */ @@ -43,7 +43,7 @@ $imap = self::initIMAP($config); // Convert the folder from UTF8 to UTF7-IMAP - if (\preg_match('|^(shared/Resources/)(.*)(@[^@]+)$|', $folder, $matches)) { + if (\preg_match('#^(shared/|shared/Resources/)(.+)(@[^@]+)$#', $folder, $matches)) { $folderName = \mb_convert_encoding($matches[2], 'UTF7-IMAP', 'UTF8'); $folder = $matches[1] . $folderName . $matches[3]; } diff --git a/src/app/Backends/LDAP.php b/src/app/Backends/LDAP.php --- a/src/app/Backends/LDAP.php +++ b/src/app/Backends/LDAP.php @@ -5,6 +5,7 @@ use App\Domain; use App\Group; use App\Resource; +use App\SharedFolder; use App\User; class LDAP @@ -20,6 +21,12 @@ 'invitation_policy', ]; + /** @const array Shared folder settings used by the backend */ + public const SHARED_FOLDER_SETTINGS = [ + 'folder', + 'acl', + ]; + /** @const array User settings used by the backend */ public const USER_SETTINGS = [ 'first_name', @@ -296,6 +303,45 @@ } } + /** + * Create a shared folder in LDAP. + * + * @param \App\SharedFolder $folder The shared folder to create. + * + * @throws \Exception + */ + public static function createSharedFolder(SharedFolder $folder): void + { + $config = self::getConfig('admin'); + $ldap = self::initLDAP($config); + + $domainName = explode('@', $folder->email, 2)[1]; + $cn = $ldap->quote_string($folder->name); + $dn = "cn={$cn}," . self::baseDN($domainName, 'Shared Folders'); + + $entry = [ + 'mail' => $folder->email, + 'objectclass' => [ + 'top', + 'kolabsharedfolder', + 'mailrecipient', + ], + ]; + + self::setSharedFolderAttributes($ldap, $folder, $entry); + + self::addEntry( + $ldap, + $dn, + $entry, + "Failed to create shared folder {$folder->id} in LDAP (" . __LINE__ . ")" + ); + + if (empty(self::$ldap)) { + $ldap->close(); + } + } + /** * Create a user in LDAP. * @@ -456,6 +502,34 @@ } } + /** + * Delete a shared folder from LDAP. + * + * @param \App\SharedFolder $folder The shared folder to delete. + * + * @throws \Exception + */ + public static function deleteSharedFolder(SharedFolder $folder): void + { + $config = self::getConfig('admin'); + $ldap = self::initLDAP($config); + + if (self::getSharedFolderEntry($ldap, $folder->email, $dn)) { + $result = $ldap->delete_entry($dn); + + if (!$result) { + self::throwException( + $ldap, + "Failed to delete shared folder {$folder->id} from LDAP (" . __LINE__ . ")" + ); + } + } + + if (empty(self::$ldap)) { + $ldap->close(); + } + } + /** * Delete a user from LDAP. * @@ -554,6 +628,28 @@ return $resource; } + /** + * Get a shared folder data from LDAP. + * + * @param string $email The resource email. + * + * @return array|false|null + * @throws \Exception + */ + public static function getSharedFolder(string $email) + { + $config = self::getConfig('admin'); + $ldap = self::initLDAP($config); + + $folder = self::getSharedFolderEntry($ldap, $email, $dn); + + if (empty(self::$ldap)) { + $ldap->close(); + } + + return $folder; + } + /** * Get a user data from LDAP. * @@ -694,6 +790,43 @@ } } + /** + * Update a shared folder in LDAP. + * + * @param \App\SharedFolder $folder The shared folder to update + * + * @throws \Exception + */ + public static function updateSharedFolder(SharedFolder $folder): void + { + $config = self::getConfig('admin'); + $ldap = self::initLDAP($config); + + $newEntry = $oldEntry = self::getSharedFolderEntry($ldap, $folder->email, $dn); + + if (empty($oldEntry)) { + self::throwException( + $ldap, + "Failed to update shared folder {$folder->id} in LDAP (folder not found)" + ); + } + + self::setSharedFolderAttributes($ldap, $folder, $newEntry); + + $result = $ldap->modify_entry($dn, $oldEntry, $newEntry); + + if (!is_array($result)) { + self::throwException( + $ldap, + "Failed to update shared folder {$folder->id} in LDAP (" . __LINE__ . ")" + ); + } + + if (empty(self::$ldap)) { + $ldap->close(); + } + } + /** * Update a user in LDAP. * @@ -886,6 +1019,19 @@ } } + /** + * Set common shared folder attributes + */ + private static function setSharedFolderAttributes($ldap, SharedFolder $folder, &$entry) + { + $settings = $folder->getSettings(['acl', 'folder']); + + $entry['cn'] = $folder->name; + $entry['kolabfoldertype'] = $folder->type; + $entry['kolabtargetfolder'] = $settings['folder'] ?? ''; + $entry['acl'] = !empty($settings['acl']) ? json_decode($settings['acl'], true) : ''; + } + /** * Set common user attributes */ @@ -1027,6 +1173,28 @@ return self::searchEntry($ldap, $base_dn, "(mail=$email)", $attrs, $dn); } + /** + * Get a shared folder entry from LDAP. + * + * @param \Net_LDAP3 $ldap Ldap connection + * @param string $email Resource email (mail) + * @param string $dn Reference to the shared folder DN + * + * @return null|array Shared folder entry, NULL if not found + */ + private static function getSharedFolderEntry($ldap, $email, &$dn = null) + { + $domainName = explode('@', $email, 2)[1]; + $base_dn = self::baseDN($domainName, 'Shared Folders'); + + $attrs = ['dn', 'cn', 'mail', 'objectclass', 'kolabtargetfolder', 'kolabfoldertype', 'acl']; + + // For shared folders we're using search() instead of get_entry() because + // a folder name is not constant, so e.g. on update we might have + // the new name, but not the old one. Email address is constant. + return self::searchEntry($ldap, $base_dn, "(mail=$email)", $attrs, $dn); + } + /** * Get user entry from LDAP. * diff --git a/src/app/Console/Commands/SharedFoldersCommand.php b/src/app/Console/Commands/SharedFoldersCommand.php new file mode 100644 --- /dev/null +++ b/src/app/Console/Commands/SharedFoldersCommand.php @@ -0,0 +1,12 @@ +exists() || \App\Group::whereRaw('substr(email, ?) = ?', [-$suffixLen, $suffix])->exists() || \App\Resource::whereRaw('substr(email, ?) = ?', [-$suffixLen, $suffix])->exists() + || \App\SharedFolder::whereRaw('substr(email, ?) = ?', [-$suffixLen, $suffix])->exists() ); } diff --git a/src/app/Handlers/Beta/SharedFolders.php b/src/app/Handlers/Beta/SharedFolders.php new file mode 100644 --- /dev/null +++ b/src/app/Handlers/Beta/SharedFolders.php @@ -0,0 +1,49 @@ +wallet()->entitlements() + ->where('entitleable_type', \App\Domain::class)->count() > 0; + } + + return false; + } + + /** + * The priority that specifies the order of SKUs in UI. + * Higher number means higher on the list. + * + * @return int + */ + public static function priority(): int + { + return 10; + } +} diff --git a/src/app/Handlers/SharedFolder.php b/src/app/Handlers/SharedFolder.php --- a/src/app/Handlers/SharedFolder.php +++ b/src/app/Handlers/SharedFolder.php @@ -11,7 +11,6 @@ */ public static function entitleableClass(): string { - // TODO - return ''; + return \App\SharedFolder::class; } } diff --git a/src/app/Http/Controllers/API/V4/Admin/SharedFoldersController.php b/src/app/Http/Controllers/API/V4/Admin/SharedFoldersController.php new file mode 100644 --- /dev/null +++ b/src/app/Http/Controllers/API/V4/Admin/SharedFoldersController.php @@ -0,0 +1,59 @@ +input('search')); + $owner = trim(request()->input('owner')); + $result = collect([]); + + if ($owner) { + if ($owner = User::find($owner)) { + $result = $owner->sharedFolders(false)->orderBy('name')->get(); + } + } elseif (!empty($search)) { + if ($folder = SharedFolder::where('email', $search)->first()) { + $result->push($folder); + } + } + + // Process the result + $result = $result->map( + function ($folder) { + return $this->objectToClient($folder); + } + ); + + $result = [ + 'list' => $result, + 'count' => count($result), + 'message' => \trans('app.search-foundxsharedfolders', ['x' => count($result)]), + ]; + + return response()->json($result); + } + + /** + * Create a new shared folder. + * + * @param \Illuminate\Http\Request $request The API request. + * + * @return \Illuminate\Http\JsonResponse The response + */ + public function store(Request $request) + { + return $this->errorResponse(404); + } +} diff --git a/src/app/Http/Controllers/API/V4/Admin/UsersController.php b/src/app/Http/Controllers/API/V4/Admin/UsersController.php --- a/src/app/Http/Controllers/API/V4/Admin/UsersController.php +++ b/src/app/Http/Controllers/API/V4/Admin/UsersController.php @@ -63,6 +63,8 @@ $user_ids = $user_ids->merge([$group->wallet()->user_id])->unique(); } elseif ($resource = \App\Resource::withTrashed()->where('email', $search)->first()) { $user_ids = $user_ids->merge([$resource->wallet()->user_id])->unique(); + } elseif ($folder = \App\SharedFolder::withTrashed()->where('email', $search)->first()) { + $user_ids = $user_ids->merge([$folder->wallet()->user_id])->unique(); } if (!$user_ids->isEmpty()) { diff --git a/src/app/Http/Controllers/API/V4/PaymentsController.php b/src/app/Http/Controllers/API/V4/PaymentsController.php --- a/src/app/Http/Controllers/API/V4/PaymentsController.php +++ b/src/app/Http/Controllers/API/V4/PaymentsController.php @@ -167,7 +167,7 @@ // It has to be at least minimum payment amount and must cover current debt if ( $wallet->balance < 0 - && $wallet->balance * -1 > PaymentProvider::MIN_AMOUNT + && $wallet->balance <= PaymentProvider::MIN_AMOUNT * -1 && $wallet->balance + $amount < 0 ) { return ['amount' => \trans('validation.minamountdebt')]; diff --git a/src/app/Http/Controllers/API/V4/Reseller/SharedFoldersController.php b/src/app/Http/Controllers/API/V4/Reseller/SharedFoldersController.php new file mode 100644 --- /dev/null +++ b/src/app/Http/Controllers/API/V4/Reseller/SharedFoldersController.php @@ -0,0 +1,46 @@ +input('search')); + $owner = trim(request()->input('owner')); + $result = collect([]); + + if ($owner) { + if ($owner = User::withSubjectTenantContext()->find($owner)) { + $result = $owner->sharedFolders(false)->orderBy('name')->get(); + } + } elseif (!empty($search)) { + if ($folder = SharedFolder::withSubjectTenantContext()->where('email', $search)->first()) { + $result->push($folder); + } + } + + // Process the result + $result = $result->map( + function ($folder) { + return $this->objectToClient($folder); + } + ); + + $result = [ + 'list' => $result, + 'count' => count($result), + 'message' => \trans('app.search-foundxsharedfolders', ['x' => count($result)]), + ]; + + return response()->json($result); + } +} diff --git a/src/app/Http/Controllers/API/V4/Reseller/UsersController.php b/src/app/Http/Controllers/API/V4/Reseller/UsersController.php --- a/src/app/Http/Controllers/API/V4/Reseller/UsersController.php +++ b/src/app/Http/Controllers/API/V4/Reseller/UsersController.php @@ -53,6 +53,10 @@ // Search by a distribution list email if ($group = Group::withTrashed()->where('email', $search)->first()) { $user_ids = $user_ids->merge([$group->wallet()->user_id])->unique(); + } elseif ($resource = \App\Resource::withTrashed()->where('email', $search)->first()) { + $user_ids = $user_ids->merge([$resource->wallet()->user_id])->unique(); + } elseif ($folder = \App\SharedFolder::withTrashed()->where('email', $search)->first()) { + $user_ids = $user_ids->merge([$folder->wallet()->user_id])->unique(); } if (!$user_ids->isEmpty()) { diff --git a/src/app/Http/Controllers/API/V4/ResourcesController.php b/src/app/Http/Controllers/API/V4/ResourcesController.php --- a/src/app/Http/Controllers/API/V4/ResourcesController.php +++ b/src/app/Http/Controllers/API/V4/ResourcesController.php @@ -209,7 +209,6 @@ $v = Validator::make($request->all(), $rules); if ($v->fails()) { - $errors = $v->errors()->toArray(); return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } diff --git a/src/app/Http/Controllers/API/V4/SharedFoldersController.php b/src/app/Http/Controllers/API/V4/SharedFoldersController.php new file mode 100644 --- /dev/null +++ b/src/app/Http/Controllers/API/V4/SharedFoldersController.php @@ -0,0 +1,357 @@ +errorResponse(404); + } + + /** + * Delete a shared folder. + * + * @param int $id Shared folder identifier + * + * @return \Illuminate\Http\JsonResponse The response + */ + public function destroy($id) + { + $folder = SharedFolder::find($id); + + if (!$this->checkTenant($folder)) { + return $this->errorResponse(404); + } + + if (!$this->guard()->user()->canDelete($folder)) { + return $this->errorResponse(403); + } + + $folder->delete(); + + return response()->json([ + 'status' => 'success', + 'message' => \trans('app.shared-folder-delete-success'), + ]); + } + + /** + * Show the form for editing the specified shared folder. + * + * @param int $id Shared folder identifier + * + * @return \Illuminate\Http\JsonResponse + */ + public function edit($id) + { + return $this->errorResponse(404); + } + + /** + * Listing of a shared folders belonging to the authenticated user. + * + * The shared-folder entitlements billed to the current user wallet(s) + * + * @return \Illuminate\Http\JsonResponse + */ + public function index() + { + $user = $this->guard()->user(); + + $result = $user->sharedFolders()->orderBy('name')->get() + ->map(function (SharedFolder $folder) { + return $this->objectToClient($folder); + }); + + return response()->json($result); + } + + /** + * Set the shared folder configuration. + * + * @param int $id Shared folder identifier + * + * @return \Illuminate\Http\JsonResponse|void + */ + public function setConfig($id) + { + $folder = SharedFolder::find($id); + + if (!$this->checkTenant($folder)) { + return $this->errorResponse(404); + } + + if (!$this->guard()->user()->canUpdate($folder)) { + return $this->errorResponse(403); + } + + $errors = $folder->setConfig(request()->input()); + + if (!empty($errors)) { + return response()->json(['status' => 'error', 'errors' => $errors], 422); + } + + return response()->json([ + 'status' => 'success', + 'message' => \trans('app.shared-folder-setconfig-success'), + ]); + } + + /** + * Display information of a shared folder specified by $id. + * + * @param int $id Shared folder identifier + * + * @return \Illuminate\Http\JsonResponse + */ + public function show($id) + { + $folder = SharedFolder::find($id); + + if (!$this->checkTenant($folder)) { + return $this->errorResponse(404); + } + + if (!$this->guard()->user()->canRead($folder)) { + return $this->errorResponse(403); + } + + $response = $this->objectToClient($folder, true); + + $response['statusInfo'] = self::statusInfo($folder); + + // Shared folder configuration, e.g. acl + $response['config'] = $folder->getConfig(); + + return response()->json($response); + } + + /** + * Fetch a shared folder status (and reload setup process) + * + * @param int $id Shared folder identifier + * + * @return \Illuminate\Http\JsonResponse + */ + public function status($id) + { + $folder = SharedFolder::find($id); + + if (!$this->checkTenant($folder)) { + return $this->errorResponse(404); + } + + if (!$this->guard()->user()->canRead($folder)) { + return $this->errorResponse(403); + } + + $response = $this->processStateUpdate($folder); + $response = array_merge($response, self::objectState($folder)); + + return response()->json($response); + } + + /** + * SharedFolder status (extended) information + * + * @param \App\SharedFolder $folder SharedFolder object + * + * @return array Status information + */ + public static function statusInfo(SharedFolder $folder): array + { + return self::processStateInfo( + $folder, + [ + 'shared-folder-new' => true, + 'shared-folder-ldap-ready' => $folder->isLdapReady(), + 'shared-folder-imap-ready' => $folder->isImapReady(), + ] + ); + } + + /** + * Create a new shared folder record. + * + * @param \Illuminate\Http\Request $request The API request. + * + * @return \Illuminate\Http\JsonResponse The response + */ + public function store(Request $request) + { + $current_user = $this->guard()->user(); + $owner = $current_user->wallet()->owner; + + if ($owner->id != $current_user->id) { + return $this->errorResponse(403); + } + + $domain = request()->input('domain'); + + $rules = [ + 'name' => ['required', 'string', new SharedFolderName($owner, $domain)], + 'type' => ['required', 'string', new SharedFolderType()] + ]; + + $v = Validator::make($request->all(), $rules); + + if ($v->fails()) { + return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); + } + + DB::beginTransaction(); + + // Create the shared folder + $folder = new SharedFolder(); + $folder->name = request()->input('name'); + $folder->type = request()->input('type'); + $folder->domain = $domain; + $folder->save(); + + $folder->assignToWallet($owner->wallets->first()); + + DB::commit(); + + return response()->json([ + 'status' => 'success', + 'message' => \trans('app.shared-folder-create-success'), + ]); + } + + /** + * Update a shared folder. + * + * @param \Illuminate\Http\Request $request The API request. + * @param string $id Shared folder identifier + * + * @return \Illuminate\Http\JsonResponse The response + */ + public function update(Request $request, $id) + { + $folder = SharedFolder::find($id); + + if (!$this->checkTenant($folder)) { + return $this->errorResponse(404); + } + + $current_user = $this->guard()->user(); + + if (!$current_user->canUpdate($folder)) { + return $this->errorResponse(403); + } + + $owner = $folder->wallet()->owner; + + $name = $request->input('name'); + $errors = []; + + // Validate the folder name + if ($name !== null && $name != $folder->name) { + $domainName = explode('@', $folder->email, 2)[1]; + $rules = ['name' => ['required', 'string', new SharedFolderName($owner, $domainName)]]; + + $v = Validator::make($request->all(), $rules); + + if ($v->fails()) { + $errors = $v->errors()->toArray(); + } else { + $folder->name = $name; + } + } + + if (!empty($errors)) { + return response()->json(['status' => 'error', 'errors' => $errors], 422); + } + + $folder->save(); + + return response()->json([ + 'status' => 'success', + 'message' => \trans('app.shared-folder-update-success'), + ]); + } + + /** + * Execute (synchronously) specified step in a shared folder setup process. + * + * @param \App\SharedFolder $folder Shared folder object + * @param string $step Step identifier (as in self::statusInfo()) + * + * @return bool|null True if the execution succeeded, False if not, Null when + * the job has been sent to the worker (result unknown) + */ + public static function execProcessStep(SharedFolder $folder, string $step): ?bool + { + try { + if (strpos($step, 'domain-') === 0) { + return DomainsController::execProcessStep($folder->domain(), $step); + } + + switch ($step) { + case 'shared-folder-ldap-ready': + // Shared folder not in LDAP, create it + $job = new \App\Jobs\SharedFolder\CreateJob($folder->id); + $job->handle(); + + $folder->refresh(); + + return $folder->isLdapReady(); + + case 'shared-folder-imap-ready': + // Shared folder not in IMAP? Verify again + // Do it synchronously if the imap admin credentials are available + // otherwise let the worker do the job + if (!\config('imap.admin_password')) { + \App\Jobs\SharedFolder\VerifyJob::dispatch($folder->id); + + return null; + } + + $job = new \App\Jobs\SharedFolder\VerifyJob($folder->id); + $job->handle(); + + $folder->refresh(); + + return $folder->isImapReady(); + } + } catch (\Exception $e) { + \Log::error($e); + } + + return false; + } + + /** + * Prepare shared folder statuses for the UI + * + * @param \App\SharedFolder $folder Shared folder object + * + * @return array Statuses array + */ + protected static function objectState(SharedFolder $folder): array + { + return [ + 'isLdapReady' => $folder->isLdapReady(), + 'isImapReady' => $folder->isImapReady(), + 'isActive' => $folder->isActive(), + 'isDeleted' => $folder->isDeleted() || $folder->trashed(), + ]; + } +} diff --git a/src/app/Http/Controllers/API/V4/UsersController.php b/src/app/Http/Controllers/API/V4/UsersController.php --- a/src/app/Http/Controllers/API/V4/UsersController.php +++ b/src/app/Http/Controllers/API/V4/UsersController.php @@ -252,6 +252,8 @@ 'enableDomains' => $isController && $hasCustomDomain, // TODO: Make 'enableDistlists' working for wallet controllers that aren't account owners 'enableDistlists' => $isController && $hasCustomDomain && in_array('distlist', $skus), + // TODO: Make 'enableFolders' working for wallet controllers that aren't account owners + 'enableFolders' => $isController && $hasCustomDomain && in_array('beta-shared-folders', $skus), // TODO: Make 'enableResources' working for wallet controllers that aren't account owners 'enableResources' => $isController && $hasCustomDomain && in_array('beta-resources', $skus), 'enableUsers' => $isController, diff --git a/src/app/Jobs/CommonJob.php b/src/app/Jobs/CommonJob.php --- a/src/app/Jobs/CommonJob.php +++ b/src/app/Jobs/CommonJob.php @@ -29,6 +29,13 @@ */ public $failureMessage; + /** + * The job deleted state. + * + * @var bool + */ + protected $isDeleted = false; + /** * The job released state. * @@ -50,6 +57,22 @@ */ abstract public function handle(); + /** + * Delete the job from the queue. + * + * @return void + */ + public function delete() + { + // We need this for testing purposes + $this->isDeleted = true; + + // @phpstan-ignore-next-line + if ($this->job) { + $this->job->delete(); + } + } + /** * Delete the job, call the "failed" method, and raise the failed job event. * @@ -95,6 +118,16 @@ } } + /** + * Determine if the job has been deleted. + * + * @return bool + */ + public function isDeleted(): bool + { + return $this->isDeleted; + } + /** * Check if the job was released * diff --git a/src/app/Jobs/Resource/VerifyJob.php b/src/app/Jobs/Resource/VerifyJob.php --- a/src/app/Jobs/Resource/VerifyJob.php +++ b/src/app/Jobs/Resource/VerifyJob.php @@ -19,7 +19,7 @@ return; } - // the user has a mailbox (or is marked as such) + // the resource was already verified if ($resource->isImapReady()) { $this->fail(new \Exception("Resource {$this->resourceId} is already verified.")); return; diff --git a/src/app/Jobs/SharedFolder/CreateJob.php b/src/app/Jobs/SharedFolder/CreateJob.php new file mode 100644 --- /dev/null +++ b/src/app/Jobs/SharedFolder/CreateJob.php @@ -0,0 +1,61 @@ +getSharedFolder(); + + if (!$folder) { + return; + } + + // sanity checks + if ($folder->isDeleted()) { + $this->fail(new \Exception("Shared folder {$this->folderId} is marked as deleted.")); + return; + } + + if ($folder->trashed()) { + $this->fail(new \Exception("Shared folder {$this->folderId} is actually deleted.")); + return; + } + + if ($folder->isLdapReady()) { + $this->fail(new \Exception("Shared folder {$this->folderId} is already marked as ldap-ready.")); + return; + } + + // see if the domain is ready + $domain = $folder->domain(); + + if (!$domain) { + $this->fail(new \Exception("The domain for shared folder {$this->folderId} does not exist.")); + return; + } + + if ($domain->isDeleted()) { + $this->fail(new \Exception("The domain for shared folder {$this->folderId} is marked as deleted.")); + return; + } + + if (!$domain->isLdapReady()) { + $this->release(60); + return; + } + + \App\Backends\LDAP::createSharedFolder($folder); + + $folder->status |= \App\SharedFolder::STATUS_LDAP_READY; + $folder->save(); + } +} diff --git a/src/app/Jobs/SharedFolder/DeleteJob.php b/src/app/Jobs/SharedFolder/DeleteJob.php new file mode 100644 --- /dev/null +++ b/src/app/Jobs/SharedFolder/DeleteJob.php @@ -0,0 +1,42 @@ +getSharedFolder(); + + if (!$folder) { + return; + } + + // sanity checks + if ($folder->isDeleted()) { + $this->fail(new \Exception("Shared folder {$this->folderId} is already marked as deleted.")); + return; + } + + \App\Backends\LDAP::deleteSharedFolder($folder); + + $folder->status |= \App\SharedFolder::STATUS_DELETED; + + if ($folder->isLdapReady()) { + $folder->status ^= \App\SharedFolder::STATUS_LDAP_READY; + } + + if ($folder->isImapReady()) { + $folder->status ^= \App\SharedFolder::STATUS_IMAP_READY; + } + + $folder->save(); + } +} diff --git a/src/app/Jobs/SharedFolder/UpdateJob.php b/src/app/Jobs/SharedFolder/UpdateJob.php new file mode 100644 --- /dev/null +++ b/src/app/Jobs/SharedFolder/UpdateJob.php @@ -0,0 +1,30 @@ +getSharedFolder(); + + if (!$folder) { + return; + } + + // Cancel the update if the folder is deleted or not yet in LDAP + if (!$folder->isLdapReady() || $folder->isDeleted()) { + $this->delete(); + return; + } + + \App\Backends\LDAP::updateSharedFolder($folder); + } +} diff --git a/src/app/Jobs/SharedFolder/VerifyJob.php b/src/app/Jobs/SharedFolder/VerifyJob.php new file mode 100644 --- /dev/null +++ b/src/app/Jobs/SharedFolder/VerifyJob.php @@ -0,0 +1,36 @@ +getSharedFolder(); + + if (!$folder) { + return; + } + + // the user has a mailbox (or is marked as such) + if ($folder->isImapReady()) { + $this->fail(new \Exception("Shared folder {$this->folderId} is already verified.")); + return; + } + + $folderName = $folder->getSetting('folder'); + + if (\App\Backends\IMAP::verifySharedFolder($folderName)) { + $folder->status |= \App\SharedFolder::STATUS_IMAP_READY; + $folder->status |= \App\SharedFolder::STATUS_ACTIVE; + $folder->save(); + } + } +} diff --git a/src/app/Jobs/SharedFolderJob.php b/src/app/Jobs/SharedFolderJob.php new file mode 100644 --- /dev/null +++ b/src/app/Jobs/SharedFolderJob.php @@ -0,0 +1,72 @@ +handle(); + * ``` + */ +abstract class SharedFolderJob extends CommonJob +{ + /** + * The ID for the \App\SharedFolder. This is the shortest globally unique identifier and saves Redis space + * compared to a serialized version of the complete \App\SharedFolder object. + * + * @var int + */ + protected $folderId; + /** + * The \App\SharedFolder email property, for legibility in the queue management. + * + * @var string + */ + protected $folderEmail; + + /** + * Create a new job instance. + * + * @param int $folderId The ID for the shared folder to process. + * + * @return void + */ + public function __construct(int $folderId) + { + $this->folderId = $folderId; + + $folder = $this->getSharedFolder(); + + if ($folder) { + $this->folderEmail = $folder->email; + } + } + + /** + * Get the \App\SharedFolder entry associated with this job. + * + * @return \App\SharedFolder|null + * + * @throws \Exception + */ + protected function getSharedFolder() + { + $folder = \App\SharedFolder::withTrashed()->find($this->folderId); + + if (!$folder) { + // The record might not exist yet in case of a db replication environment + // This will release the job and delay another attempt for 5 seconds + if ($this instanceof SharedFolder\CreateJob) { + $this->release(5); + return null; + } + + $this->fail(new \Exception("Shared folder {$this->folderId} could not be found in the database.")); + } + + return $folder; + } +} diff --git a/src/app/Observers/SharedFolderObserver.php b/src/app/Observers/SharedFolderObserver.php new file mode 100644 --- /dev/null +++ b/src/app/Observers/SharedFolderObserver.php @@ -0,0 +1,140 @@ +type)) { + $folder->type = 'mail'; + } + + if (empty($folder->email)) { + if (!isset($folder->name)) { + throw new \Exception("Missing 'domain' property for a new shared folder"); + } + + $domainName = \strtolower($folder->domain); + + $folder->email = "{$folder->type}-{$folder->id}@{$domainName}"; + } else { + $folder->email = \strtolower($folder->email); + } + + $folder->status |= SharedFolder::STATUS_NEW | SharedFolder::STATUS_ACTIVE; + } + + /** + * Handle the shared folder "created" event. + * + * @param \App\SharedFolder $folder The folder + * + * @return void + */ + public function created(SharedFolder $folder) + { + $domainName = explode('@', $folder->email, 2)[1]; + + $settings = [ + 'folder' => "shared/{$folder->name}@{$domainName}", + ]; + + foreach ($settings as $key => $value) { + $settings[$key] = [ + 'key' => $key, + 'value' => $value, + 'shared_folder_id' => $folder->id, + ]; + } + + // Note: Don't use setSettings() here to bypass SharedFolderSetting observers + // Note: This is a single multi-insert query + $folder->settings()->insert(array_values($settings)); + + // Create folder record in LDAP, then check if it is created in IMAP + $chain = [ + new \App\Jobs\SharedFolder\VerifyJob($folder->id), + ]; + + \App\Jobs\SharedFolder\CreateJob::withChain($chain)->dispatch($folder->id); + } + + /** + * Handle the shared folder "deleting" event. + * + * @param \App\SharedFolder $folder The folder + * + * @return void + */ + public function deleting(SharedFolder $folder) + { + // Entitlements do not have referential integrity on the entitled object, so this is our + // way of doing an onDelete('cascade') without the foreign key. + \App\Entitlement::where('entitleable_id', $folder->id) + ->where('entitleable_type', SharedFolder::class) + ->delete(); + } + + /** + * Handle the shared folder "deleted" event. + * + * @param \App\SharedFolder $folder The folder + * + * @return void + */ + public function deleted(SharedFolder $folder) + { + if ($folder->isForceDeleting()) { + return; + } + + \App\Jobs\SharedFolder\DeleteJob::dispatch($folder->id); + } + + /** + * Handle the shared folder "updated" event. + * + * @param \App\SharedFolder $folder The folder + * + * @return void + */ + public function updated(SharedFolder $folder) + { + \App\Jobs\SharedFolder\UpdateJob::dispatch($folder->id); + + // Update the folder property if name changed + if ($folder->name != $folder->getOriginal('name')) { + $domainName = explode('@', $folder->email, 2)[1]; + $folderName = "shared/{$folder->name}@{$domainName}"; + + // Note: This does not invoke SharedFolderSetting observer events, good. + $folder->settings()->where('key', 'folder')->update(['value' => $folderName]); + } + } + + /** + * Handle the shared folder "force deleted" event. + * + * @param \App\SharedFolder $folder The folder + * + * @return void + */ + public function forceDeleted(SharedFolder $folder) + { + // A folder can be force-deleted separately from the owner + // we have to force-delete entitlements + \App\Entitlement::where('entitleable_id', $folder->id) + ->where('entitleable_type', SharedFolder::class) + ->forceDelete(); + } +} diff --git a/src/app/Observers/SharedFolderSettingObserver.php b/src/app/Observers/SharedFolderSettingObserver.php new file mode 100644 --- /dev/null +++ b/src/app/Observers/SharedFolderSettingObserver.php @@ -0,0 +1,51 @@ +key, LDAP::SHARED_FOLDER_SETTINGS)) { + \App\Jobs\SharedFolder\UpdateJob::dispatch($folderSetting->shared_folder_id); + } + } + + /** + * Handle the shared folder setting "updated" event. + * + * @param \App\SharedFolderSetting $folderSetting Settings object + * + * @return void + */ + public function updated(SharedFolderSetting $folderSetting) + { + if (in_array($folderSetting->key, LDAP::SHARED_FOLDER_SETTINGS)) { + \App\Jobs\SharedFolder\UpdateJob::dispatch($folderSetting->shared_folder_id); + } + } + + /** + * Handle the shared folder setting "deleted" event. + * + * @param \App\SharedFolderSetting $folderSetting Settings object + * + * @return void + */ + public function deleted(SharedFolderSetting $folderSetting) + { + if (in_array($folderSetting->key, LDAP::SHARED_FOLDER_SETTINGS)) { + \App\Jobs\SharedFolder\UpdateJob::dispatch($folderSetting->shared_folder_id); + } + } +} diff --git a/src/app/Observers/UserObserver.php b/src/app/Observers/UserObserver.php --- a/src/app/Observers/UserObserver.php +++ b/src/app/Observers/UserObserver.php @@ -6,6 +6,7 @@ use App\Domain; use App\Group; use App\Resource; +use App\SharedFolder; use App\Transaction; use App\User; use App\Wallet; @@ -149,6 +150,7 @@ $domains = []; $groups = []; $resources = []; + $folders = []; $entitlements = []; foreach ($assignments as $entitlement) { @@ -160,6 +162,8 @@ $groups[] = $entitlement->entitleable_id; } elseif ($entitlement->entitleable_type == Resource::class) { $resources[] = $entitlement->entitleable_id; + } elseif ($entitlement->entitleable_type == SharedFolder::class) { + $folders[] = $entitlement->entitleable_id; } else { $entitlements[] = $entitlement; } @@ -191,6 +195,12 @@ } } + if (!empty($folders)) { + foreach (SharedFolder::whereIn('id', array_unique($folders))->get() as $_folder) { + $_folder->delete(); + } + } + foreach ($entitlements as $entitlement) { $entitlement->delete(); } @@ -222,6 +232,7 @@ $domains = []; $groups = []; $resources = []; + $folders = []; $users = []; foreach ($assignments as $entitlement) { @@ -238,6 +249,8 @@ $groups[] = $entitlement->entitleable_id; } elseif ($entitlement->entitleable_type == Resource::class) { $resources[] = $entitlement->entitleable_id; + } elseif ($entitlement->entitleable_type == SharedFolder::class) { + $folders[] = $entitlement->entitleable_id; } } @@ -270,6 +283,11 @@ Resource::withTrashed()->whereIn('id', array_unique($resources))->forceDelete(); } + // Shared folders can be just removed + if (!empty($folders)) { + SharedFolder::withTrashed()->whereIn('id', array_unique($folders))->forceDelete(); + } + // Remove transactions, they also have no foreign key constraint Transaction::where('object_type', Entitlement::class) ->whereIn('object_id', $entitlements) diff --git a/src/app/Providers/AppServiceProvider.php b/src/app/Providers/AppServiceProvider.php --- a/src/app/Providers/AppServiceProvider.php +++ b/src/app/Providers/AppServiceProvider.php @@ -53,6 +53,8 @@ \App\PlanPackage::observe(\App\Observers\PlanPackageObserver::class); \App\Resource::observe(\App\Observers\ResourceObserver::class); \App\ResourceSetting::observe(\App\Observers\ResourceSettingObserver::class); + \App\SharedFolder::observe(\App\Observers\SharedFolderObserver::class); + \App\SharedFolderSetting::observe(\App\Observers\SharedFolderSettingObserver::class); \App\SignupCode::observe(\App\Observers\SignupCodeObserver::class); \App\SignupInvitation::observe(\App\Observers\SignupInvitationObserver::class); \App\Transaction::observe(\App\Observers\TransactionObserver::class); diff --git a/src/app/Resource.php b/src/app/Resource.php --- a/src/app/Resource.php +++ b/src/app/Resource.php @@ -48,8 +48,7 @@ 'status', ]; - /** - * @var ?string Domain name for a resource to be created */ + /** @var ?string Domain name for a resource to be created */ public $domain; diff --git a/src/app/Rules/ResourceName.php b/src/app/Rules/ResourceName.php --- a/src/app/Rules/ResourceName.php +++ b/src/app/Rules/ResourceName.php @@ -12,6 +12,8 @@ private $owner; private $domain; + private const FORBIDDEN_CHARS = '+/^%*!`@(){}|\\?<;"'; + /** * Class constructor. * @@ -39,13 +41,18 @@ return false; } + if (strcspn($name, self::FORBIDDEN_CHARS) < strlen($name)) { + $this->message = \trans('validation.nameinvalid'); + return false; + } + // Check the max length, according to the database column length if (strlen($name) > 191) { $this->message = \trans('validation.max.string', ['max' => 191]); return false; } - // Check if specified domain is belongs to the user + // Check if specified domain belongs to the user $domains = \collect($this->owner->domains(true, false))->pluck('namespace')->all(); if (!in_array($this->domain, $domains)) { $this->message = \trans('validation.domaininvalid'); diff --git a/src/app/Rules/ResourceName.php b/src/app/Rules/SharedFolderName.php copy from src/app/Rules/ResourceName.php copy to src/app/Rules/SharedFolderName.php --- a/src/app/Rules/ResourceName.php +++ b/src/app/Rules/SharedFolderName.php @@ -6,12 +6,14 @@ use Illuminate\Support\Facades\Validator; use Illuminate\Support\Str; -class ResourceName implements Rule +class SharedFolderName implements Rule { private $message; private $owner; private $domain; + private const FORBIDDEN_CHARS = '+/^%*!`@(){}|\\?<;"'; + /** * Class constructor. * @@ -28,13 +30,18 @@ * Determine if the validation rule passes. * * @param string $attribute Attribute name - * @param mixed $name Resource name input + * @param mixed $name Shared folder name input * * @return bool */ public function passes($attribute, $name): bool { - if (empty($name) || !is_string($name)) { + if (empty($name) || !is_string($name) || $name == 'Resources') { + $this->message = \trans('validation.nameinvalid'); + return false; + } + + if (strcspn($name, self::FORBIDDEN_CHARS) < strlen($name)) { $this->message = \trans('validation.nameinvalid'); return false; } @@ -45,7 +52,7 @@ return false; } - // Check if specified domain is belongs to the user + // Check if specified domain belongs to the user $domains = \collect($this->owner->domains(true, false))->pluck('namespace')->all(); if (!in_array($this->domain, $domains)) { $this->message = \trans('validation.domaininvalid'); @@ -53,10 +60,10 @@ } // Check if the name is unique in the domain - // FIXME: Maybe just using the whole resources table would be faster than resources()? - $exists = $this->owner->resources() - ->where('resources.name', $name) - ->where('resources.email', 'like', '%@' . $this->domain) + // FIXME: Maybe just using the whole shared_folders table would be faster than sharedFolders()? + $exists = $this->owner->sharedFolders() + ->where('shared_folders.name', $name) + ->where('shared_folders.email', 'like', '%@' . $this->domain) ->exists(); if ($exists) { diff --git a/src/app/Rules/SharedFolderType.php b/src/app/Rules/SharedFolderType.php new file mode 100644 --- /dev/null +++ b/src/app/Rules/SharedFolderType.php @@ -0,0 +1,40 @@ +message = \trans('validation.entryinvalid', ['attribute' => $attribute]); + return false; + } + + return true; + } + + /** + * Get the validation error message. + * + * @return string + */ + public function message(): ?string + { + return $this->message; + } +} diff --git a/src/app/Resource.php b/src/app/SharedFolder.php copy from src/app/Resource.php copy to src/app/SharedFolder.php --- a/src/app/Resource.php +++ b/src/app/SharedFolder.php @@ -4,7 +4,7 @@ use App\Traits\BelongsToTenantTrait; use App\Traits\EntitleableTrait; -use App\Traits\ResourceConfigTrait; +use App\Traits\SharedFolderConfigTrait; use App\Traits\SettingsTrait; use App\Traits\UuidIntKeyTrait; use App\Wallet; @@ -12,66 +12,71 @@ use Illuminate\Database\Eloquent\SoftDeletes; /** - * The eloquent definition of a Resource. + * The eloquent definition of a SharedFolder. * - * @property int $id The resource identifier * @property string $email An email address - * @property string $name The resource name - * @property int $status The resource status + * @property int $id The folder identifier + * @property string $name The folder name + * @property int $status The folder status * @property int $tenant_id Tenant identifier + * @property string $type The folder type */ -class Resource extends Model +class SharedFolder extends Model { use BelongsToTenantTrait; use EntitleableTrait; - use ResourceConfigTrait; + use SharedFolderConfigTrait; use SettingsTrait; use SoftDeletes; use UuidIntKeyTrait; - // we've simply never heard of this resource + // we've simply never heard of this folder public const STATUS_NEW = 1 << 0; - // resource has been activated + // folder has been activated public const STATUS_ACTIVE = 1 << 1; - // resource has been suspended. + // folder has been suspended. // public const STATUS_SUSPENDED = 1 << 2; - // resource has been deleted + // folder has been deleted public const STATUS_DELETED = 1 << 3; - // resource has been created in LDAP + // folder has been created in LDAP public const STATUS_LDAP_READY = 1 << 4; - // resource has been created in IMAP + // folder has been created in IMAP public const STATUS_IMAP_READY = 1 << 8; + /** @const array Supported folder type labels */ + public const SUPPORTED_TYPES = ['mail', 'event', 'contact', 'task', 'note']; + + /** @var array Mass-assignable properties */ protected $fillable = [ 'email', 'name', 'status', + 'type', ]; - /** - * @var ?string Domain name for a resource to be created */ + /** @var ?string Domain name for a shared folder to be created */ public $domain; /** - * Assign the resource to a wallet. + * Assign the folder to a wallet. * * @param \App\Wallet $wallet The wallet * - * @return \App\Resource Self + * @return \App\SharedFolder Self * @throws \Exception */ - public function assignToWallet(Wallet $wallet): Resource + public function assignToWallet(Wallet $wallet): SharedFolder { if (empty($this->id)) { - throw new \Exception("Resource not yet exists"); + throw new \Exception("Shared folder not yet exists"); } if ($this->entitlements()->count()) { - throw new \Exception("Resource already assigned to a wallet"); + throw new \Exception("Shared folder already assigned to a wallet"); } - $sku = \App\Sku::withObjectTenantContext($this)->where('title', 'resource')->first(); + $sku = \App\Sku::withObjectTenantContext($this)->where('title', 'shared-folder')->first(); $exists = $wallet->entitlements()->where('sku_id', $sku->id)->count(); \App\Entitlement::create([ @@ -80,16 +85,16 @@ 'cost' => $exists >= $sku->units_free ? $sku->cost : 0, 'fee' => $exists >= $sku->units_free ? $sku->fee : 0, 'entitleable_id' => $this->id, - 'entitleable_type' => Resource::class + 'entitleable_type' => SharedFolder::class ]); return $this; } /** - * Returns the resource domain. + * Returns the shared folder domain. * - * @return ?\App\Domain The domain to which the resource belongs to, NULL if it does not exist + * @return ?\App\Domain The domain to which the folder belongs to, NULL if it does not exist */ public function domain(): ?Domain { @@ -103,14 +108,14 @@ } /** - * Find whether an email address exists as a resource (including deleted resources). + * Find whether an email address exists as a shared folder (including deleted folders). * - * @param string $email Email address - * @param bool $return_resource Return Resource instance instead of boolean + * @param string $email Email address + * @param bool $return_folder Return SharedFolder instance instead of boolean * - * @return \App\Resource|bool True or Resource model object if found, False otherwise + * @return \App\SharedFolder|bool True or Resource model object if found, False otherwise */ - public static function emailExists(string $email, bool $return_resource = false) + public static function emailExists(string $email, bool $return_folder = false) { if (strpos($email, '@') === false) { return false; @@ -118,17 +123,17 @@ $email = \strtolower($email); - $resource = self::withTrashed()->where('email', $email)->first(); + $folder = self::withTrashed()->where('email', $email)->first(); - if ($resource) { - return $return_resource ? $resource : true; + if ($folder) { + return $return_folder ? $folder : true; } return false; } /** - * Returns whether this resource is active. + * Returns whether this folder is active. * * @return bool */ @@ -138,7 +143,7 @@ } /** - * Returns whether this resource is deleted. + * Returns whether this folder is deleted. * * @return bool */ @@ -148,7 +153,7 @@ } /** - * Returns whether this resource's folder exists in IMAP. + * Returns whether this folder exists in IMAP. * * @return bool */ @@ -158,7 +163,7 @@ } /** - * Returns whether this resource is registered in LDAP. + * Returns whether this folder is registered in LDAP. * * @return bool */ @@ -168,7 +173,7 @@ } /** - * Returns whether this resource is new. + * Returns whether this folder is new. * * @return bool */ @@ -178,7 +183,7 @@ } /** - * Resource status mutator + * Folder status mutator * * @throws \Exception */ @@ -202,9 +207,23 @@ } if ($status > 0) { - throw new \Exception("Invalid resource status: {$status}"); + throw new \Exception("Invalid shared folder status: {$status}"); } $this->attributes['status'] = $new_status; } + + /** + * Folder type mutator + * + * @throws \Exception + */ + public function setTypeAttribute($type) + { + if (!in_array($type, self::SUPPORTED_TYPES)) { + throw new \Exception("Invalid shared folder type: {$type}"); + } + + $this->attributes['type'] = $type; + } } diff --git a/src/app/SharedFolderSetting.php b/src/app/SharedFolderSetting.php new file mode 100644 --- /dev/null +++ b/src/app/SharedFolderSetting.php @@ -0,0 +1,30 @@ +belongsTo(\App\SharedFolder::class, 'shared_folder_id', 'id'); + } +} diff --git a/src/app/Traits/SharedFolderConfigTrait.php b/src/app/Traits/SharedFolderConfigTrait.php new file mode 100644 --- /dev/null +++ b/src/app/Traits/SharedFolderConfigTrait.php @@ -0,0 +1,118 @@ +getSettings(['acl']); + + $config['acl'] = !empty($settings['acl']) ? json_decode($settings['acl'], true) : []; + + return $config; + } + + /** + * A helper to update a shared folder configuration. + * + * @param array $config An array of configuration options + * + * @return array A list of input validation errors + */ + public function setConfig(array $config): array + { + $errors = []; + + foreach ($config as $key => $value) { + // validate and save the acl + if ($key === 'acl') { + // Here's the list of acl labels supported by kolabd + // 'all': 'lrsedntxakcpiw', + // 'append': 'wip', + // 'full': 'lrswipkxtecdn', + // 'read': 'lrs', + // 'read-only': 'lrs', + // 'read-write': 'lrswitedn', + // 'post': 'p', + // 'semi-full': 'lrswit', + // 'write': 'lrswite', + // For now we support read-only, read-write, and full + + if (!is_array($value)) { + $value = (array) $value; + } + + $users = []; + + foreach ($value as $i => $v) { + if (!is_string($v) || empty($v) || !substr_count($v, ',')) { + $errors[$key][$i] = \trans('validation.acl-entry-invalid'); + } else { + list($user, $acl) = explode(',', $v, 2); + $user = trim($user); + $acl = trim($acl); + $error = null; + + if ( + !in_array($acl, ['read-only', 'read-write', 'full']) + || ($error = $this->validateAclIdentifier($user)) + || in_array($user, $users) + ) { + $errors[$key][$i] = $error ?: \trans('validation.acl-entry-invalid'); + } + + $value[$i] = "$user, $acl"; + $users[] = $user; + } + } + + if (empty($errors[$key])) { + $this->setSetting($key, json_encode($value)); + } + } else { + $errors[$key] = \trans('validation.invalid-config-parameter'); + } + } + + return $errors; + } + + /** + * Validate an ACL identifier. + * + * @param string $identifier Email address or a special identifier + * + * @return ?string Error message on validation error + */ + protected function validateAclIdentifier(string $identifier): ?string + { + if ($identifier === 'anyone') { + return null; + } + + $v = Validator::make(['email' => $identifier], ['email' => 'required|email']); + + if ($v->fails()) { + return \trans('validation.emailinvalid'); + } + + $user = \App\User::where('email', \strtolower($identifier))->first(); + + // The user and shared folder must be in the same wallet + if ($user && ($wallet = $user->wallet())) { + if ($wallet->user_id == $this->wallet()->user_id) { + return null; + } + } + + return \trans('validation.notalocaluser'); + } +} diff --git a/src/app/User.php b/src/app/User.php --- a/src/app/User.php +++ b/src/app/User.php @@ -591,6 +591,29 @@ ->where('entitlements.entitleable_type', \App\Resource::class); } + /** + * Return shared folders controlled by the current user. + * + * @param bool $with_accounts Include folders assigned to wallets + * the current user controls but not owns. + * + * @return \Illuminate\Database\Eloquent\Builder Query builder + */ + public function sharedFolders($with_accounts = true) + { + $wallets = $this->wallets()->pluck('id')->all(); + + if ($with_accounts) { + $wallets = array_merge($wallets, $this->accounts()->pluck('wallet_id')->all()); + } + + return \App\SharedFolder::select(['shared_folders.*', 'entitlements.wallet_id']) + ->distinct() + ->join('entitlements', 'entitlements.entitleable_id', '=', 'shared_folders.id') + ->whereIn('entitlements.wallet_id', $wallets) + ->where('entitlements.entitleable_type', \App\SharedFolder::class); + } + public function senderPolicyFrameworkWhitelist($clientName) { $setting = $this->getSetting('spf_whitelist'); diff --git a/src/database/migrations/2021_11_25_100000_create_shared_folders_table.php b/src/database/migrations/2021_11_25_100000_create_shared_folders_table.php new file mode 100644 --- /dev/null +++ b/src/database/migrations/2021_11_25_100000_create_shared_folders_table.php @@ -0,0 +1,83 @@ +unsignedBigInteger('id'); + $table->string('email')->unique(); + $table->string('name'); + $table->string('type', 8); + $table->smallInteger('status'); + $table->unsignedBigInteger('tenant_id')->nullable(); + + $table->timestamps(); + $table->softDeletes(); + + $table->primary('id'); + $table->foreign('tenant_id')->references('id')->on('tenants')->onDelete('set null'); + } + ); + + Schema::create( + 'shared_folder_settings', + function (Blueprint $table) { + $table->bigIncrements('id'); + $table->unsignedBigInteger('shared_folder_id'); + $table->string('key'); + $table->text('value'); + $table->timestamps(); + + $table->foreign('shared_folder_id')->references('id')->on('shared_folders') + ->onDelete('cascade')->onUpdate('cascade'); + + $table->unique(['shared_folder_id', 'key']); + } + ); + + \App\Sku::where('title', 'shared_folder')->update([ + 'active' => true, + 'cost' => 0, + 'title' => 'shared-folder', + ]); + + if (!\App\Sku::where('title', 'beta-shared-folders')->first()) { + \App\Sku::create([ + 'title' => 'beta-shared-folders', + 'name' => 'Shared folders', + 'description' => 'Access to shared folders', + 'cost' => 0, + 'units_free' => 0, + 'period' => 'monthly', + 'handler_class' => 'App\Handlers\Beta\SharedFolders', + 'active' => true, + ]); + } + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('shared_folder_settings'); + Schema::dropIfExists('shared_folders'); + + // there's no need to remove the SKU + } +} diff --git a/src/database/seeds/DatabaseSeeder.php b/src/database/seeds/DatabaseSeeder.php --- a/src/database/seeds/DatabaseSeeder.php +++ b/src/database/seeds/DatabaseSeeder.php @@ -26,6 +26,7 @@ 'OpenViduRoomSeeder', 'OauthClientSeeder', 'ResourceSeeder', + 'SharedFolderSeeder', ]; $env = ucfirst(App::environment()); diff --git a/src/database/seeds/local/SharedFolderSeeder.php b/src/database/seeds/local/SharedFolderSeeder.php new file mode 100644 --- /dev/null +++ b/src/database/seeds/local/SharedFolderSeeder.php @@ -0,0 +1,35 @@ +first(); + $wallet = $john->wallets()->first(); + + $folder = SharedFolder::create([ + 'name' => 'Calendar', + 'email' => 'folder-event@kolab.org', + 'type' => 'event', + ]); + $folder->assignToWallet($wallet); + + $folder = SharedFolder::create([ + 'name' => 'Contacts', + 'email' => 'folder-contact@kolab.org', + 'type' => 'contact', + ]); + $folder->assignToWallet($wallet); + } +} diff --git a/src/database/seeds/local/SkuSeeder.php b/src/database/seeds/local/SkuSeeder.php --- a/src/database/seeds/local/SkuSeeder.php +++ b/src/database/seeds/local/SkuSeeder.php @@ -116,13 +116,13 @@ Sku::create( [ - 'title' => 'shared_folder', + 'title' => 'shared-folder', 'name' => 'Shared Folder', 'description' => 'A shared folder', 'cost' => 89, 'period' => 'monthly', 'handler_class' => 'App\Handlers\SharedFolder', - 'active' => false, + 'active' => true, ] ); @@ -240,6 +240,22 @@ ]); } + // Check existence because migration might have added this already + $sku = Sku::where(['title' => 'beta-shared-folders', 'tenant_id' => \config('app.tenant_id')])->first(); + + if (!$sku) { + Sku::create([ + 'title' => 'beta-shared-folders', + 'name' => 'Shared folders', + 'description' => 'Access to shared folders', + 'cost' => 0, + 'units_free' => 0, + 'period' => 'monthly', + 'handler_class' => 'App\Handlers\Beta\SharedFolders', + 'active' => true, + ]); + } + // for tenants that are not the configured tenant id $tenants = \App\Tenant::where('id', '!=', \config('app.tenant_id'))->get(); diff --git a/src/database/seeds/production/SkuSeeder.php b/src/database/seeds/production/SkuSeeder.php --- a/src/database/seeds/production/SkuSeeder.php +++ b/src/database/seeds/production/SkuSeeder.php @@ -116,7 +116,7 @@ Sku::create( [ - 'title' => 'shared_folder', + 'title' => 'shared-folder', 'name' => 'Shared Folder', 'description' => 'A shared folder', 'cost' => 89, @@ -227,5 +227,19 @@ 'active' => true, ]); } + + // Check existence because migration might have added this already + if (!Sku::where('title', 'beta-shared-folders')->first()) { + Sku::create([ + 'title' => 'beta-shared-folders', + 'name' => 'Shared folders', + 'description' => 'Access to shared folders', + 'cost' => 0, + 'units_free' => 0, + 'period' => 'monthly', + 'handler_class' => 'App\Handlers\Beta\SharedFolders', + 'active' => true, + ]); + } } } diff --git a/src/phpstan.neon b/src/phpstan.neon --- a/src/phpstan.neon +++ b/src/phpstan.neon @@ -12,4 +12,5 @@ processTimeout: 300.0 paths: - app/ + - config/ - tests/ diff --git a/src/resources/js/admin/routes.js b/src/resources/js/admin/routes.js --- a/src/resources/js/admin/routes.js +++ b/src/resources/js/admin/routes.js @@ -5,6 +5,7 @@ import LogoutComponent from '../../vue/Logout' import PageComponent from '../../vue/Page' import ResourceComponent from '../../vue/Admin/Resource' +import SharedFolderComponent from '../../vue/Admin/SharedFolder' import StatsComponent from '../../vue/Admin/Stats' import UserComponent from '../../vue/Admin/User' @@ -47,6 +48,12 @@ component: ResourceComponent, meta: { requiresAuth: true } }, + { + path: '/shared-folder/:folder', + name: 'shared-folder', + component: SharedFolderComponent, + meta: { requiresAuth: true } + }, { path: '/stats', name: 'stats', diff --git a/src/resources/js/app.js b/src/resources/js/app.js --- a/src/resources/js/app.js +++ b/src/resources/js/app.js @@ -351,6 +351,12 @@ return this.$t('status.active') }, + folderStatusClass(folder) { + return this.userStatusClass(folder) + }, + folderStatusText(folder) { + return this.userStatusText(folder) + }, pageName(path) { let page = this.$route.path diff --git a/src/resources/js/fontawesome.js b/src/resources/js/fontawesome.js --- a/src/resources/js/fontawesome.js +++ b/src/resources/js/fontawesome.js @@ -15,6 +15,7 @@ faComments, faDownload, faEnvelope, + faFolderOpen, faGlobe, faUniversity, faExclamationCircle, @@ -50,6 +51,7 @@ faDownload, faEnvelope, faExclamationCircle, + faFolderOpen, faGlobe, faInfoCircle, faLock, diff --git a/src/resources/js/reseller/routes.js b/src/resources/js/reseller/routes.js --- a/src/resources/js/reseller/routes.js +++ b/src/resources/js/reseller/routes.js @@ -6,6 +6,7 @@ import LogoutComponent from '../../vue/Logout' import PageComponent from '../../vue/Page' import ResourceComponent from '../../vue/Admin/Resource' +import SharedFolderComponent from '../../vue/Admin/SharedFolder' import StatsComponent from '../../vue/Reseller/Stats' import UserComponent from '../../vue/Admin/User' import WalletComponent from '../../vue/Wallet' @@ -55,6 +56,12 @@ component: ResourceComponent, meta: { requiresAuth: true } }, + { + path: '/shared-folder/:folder', + name: 'shared-folder', + component: SharedFolderComponent, + meta: { requiresAuth: true } + }, { path: '/stats', name: 'stats', diff --git a/src/resources/js/user/routes.js b/src/resources/js/user/routes.js --- a/src/resources/js/user/routes.js +++ b/src/resources/js/user/routes.js @@ -10,6 +10,8 @@ import PasswordResetComponent from '../../vue/PasswordReset' import ResourceInfoComponent from '../../vue/Resource/Info' import ResourceListComponent from '../../vue/Resource/List' +import SharedFolderInfoComponent from '../../vue/SharedFolder/Info' +import SharedFolderListComponent from '../../vue/SharedFolder/List' import SignupComponent from '../../vue/Signup' import UserInfoComponent from '../../vue/User/Info' import UserListComponent from '../../vue/User/List' @@ -104,6 +106,18 @@ component: MeetComponent, meta: { requiresAuth: true } }, + { + path: '/shared-folder/:folder', + name: 'shared-folder', + component: SharedFolderInfoComponent, + meta: { requiresAuth: true, perm: 'folders' } + }, + { + path: '/shared-folders', + name: 'shared-folders', + component: SharedFolderListComponent, + meta: { requiresAuth: true, perm: 'folders' } + }, { path: '/signup/invite/:param', name: 'signup-invite', diff --git a/src/resources/lang/en/app.php b/src/resources/lang/en/app.php --- a/src/resources/lang/en/app.php +++ b/src/resources/lang/en/app.php @@ -39,6 +39,8 @@ 'process-error-domain-confirmed' => 'Failed to verify an ownership of a domain.', 'process-error-resource-imap-ready' => 'Failed to verify that a shared folder exists.', 'process-error-resource-ldap-ready' => 'Failed to create a resource.', + 'process-error-shared-folder-imap-ready' => 'Failed to verify that a shared folder exists.', + 'process-error-shared-folder-ldap-ready' => 'Failed to create a shared folder.', 'process-error-user-ldap-ready' => 'Failed to create a user.', 'process-error-user-imap-ready' => 'Failed to verify that a mailbox exists.', 'process-distlist-new' => 'Registering a distribution list...', @@ -46,6 +48,9 @@ 'process-resource-new' => 'Registering a resource...', 'process-resource-imap-ready' => 'Creating a shared folder...', 'process-resource-ldap-ready' => 'Creating a resource...', + 'process-shared-folder-new' => 'Registering a shared folder...', + 'process-shared-folder-imap-ready' => 'Creating a shared folder...', + 'process-shared-folder-ldap-ready' => 'Creating a shared folder...', 'distlist-update-success' => 'Distribution list updated successfully.', 'distlist-create-success' => 'Distribution list created successfully.', @@ -68,6 +73,11 @@ 'resource-delete-success' => 'Resource deleted successfully.', 'resource-setconfig-success' => 'Resource settings updated successfully.', + 'shared-folder-update-success' => 'Shared folder updated successfully.', + 'shared-folder-create-success' => 'Shared folder created successfully.', + 'shared-folder-delete-success' => 'Shared folder deleted successfully.', + 'shared-folder-setconfig-success' => 'Shared folder settings updated successfully.', + 'user-update-success' => 'User data updated successfully.', 'user-create-success' => 'User created successfully.', 'user-delete-success' => 'User deleted successfully.', @@ -81,6 +91,7 @@ 'search-foundxdomains' => ':x domains have been found.', 'search-foundxdistlists' => ':x distribution lists have been found.', 'search-foundxresources' => ':x resources have been found.', + 'search-foundxsharedfolders' => ':x shared folders have been found.', 'search-foundxusers' => ':x user accounts have been found.', 'signup-invitations-created' => 'The invitation has been created.|:count invitations has been created.', diff --git a/src/resources/lang/en/ui.php b/src/resources/lang/en/ui.php --- a/src/resources/lang/en/ui.php +++ b/src/resources/lang/en/ui.php @@ -45,6 +45,7 @@ 'invitations' => "Invitations", 'profile' => "Your profile", 'resources' => "Resources", + 'shared-folders' => "Shared folders", 'users' => "User accounts", 'wallet' => "Wallet", 'webmail' => "Webmail", @@ -107,7 +108,12 @@ ], 'form' => [ + 'acl' => "Access rights", + 'acl-full' => "All", + 'acl-read-only' => "Read-only", + 'acl-read-write' => "Read-write", 'amount' => "Amount", + 'anyone' => "Anyone", 'code' => "Confirmation Code", 'config' => "Configuration", 'date' => "Date", @@ -130,6 +136,7 @@ 'shared-folder' => "Shared Folder", 'status' => "Status", 'surname' => "Surname", + 'type' => "Type", 'user' => "User", 'primary-email' => "Primary Email", 'id' => "ID", @@ -307,6 +314,21 @@ 'new' => "New resource", ], + 'shf' => [ + 'create' => "Create folder", + 'delete' => "Delete folder", + 'acl-text' => "Defines user permissions to access the shared folder.", + 'list-title' => "Shared folder | Shared folders", + 'list-empty' => "There are no shared folders in this account.", + 'new' => "New shared folder", + 'type-mail' => "Mail", + 'type-event' => "Calendar", + 'type-contact' => "Address Book", + 'type-task' => "Tasks", + 'type-note' => "Notes", + 'type-file' => "Files", + ], + 'signup' => [ 'email' => "Existing Email Address", 'login' => "Login", @@ -322,6 +344,7 @@ 'prepare-domain' => "We are preparing the domain.", 'prepare-distlist' => "We are preparing the distribution list.", 'prepare-resource' => "We are preparing the resource.", + 'prepare-shared-folder' => "We are preparing the shared folder.", 'prepare-user' => "We are preparing the user account.", 'prepare-hint' => "Some features may be missing or readonly at the moment.", 'prepare-refresh' => "The process never ends? Press the \"Refresh\" button, please.", @@ -329,6 +352,7 @@ 'ready-domain' => "The domain is almost ready.", 'ready-distlist' => "The distribution list is almost ready.", 'ready-resource' => "The resource is almost ready.", + 'ready-shared-folder' => "The shared-folder is almost ready.", 'ready-user' => "The user account is almost ready.", 'verify' => "Verify your domain to finish the setup process.", 'verify-domain' => "Verify domain", diff --git a/src/resources/lang/en/validation.php b/src/resources/lang/en/validation.php --- a/src/resources/lang/en/validation.php +++ b/src/resources/lang/en/validation.php @@ -140,6 +140,7 @@ 'listmembersrequired' => 'At least one recipient is required.', 'spf-entry-invalid' => 'The entry format is invalid. Expected a domain name starting with a dot.', 'sp-entry-invalid' => 'The entry format is invalid. Expected an email, domain, or part of it.', + 'acl-entry-invalid' => 'The entry format is invalid. Expected an email address.', 'ipolicy-invalid' => 'The specified invitation policy is invalid.', 'invalid-config-parameter' => 'The requested configuration parameter is not supported.', 'nameexists' => 'The specified name is not available.', diff --git a/src/resources/themes/forms.scss b/src/resources/themes/forms.scss --- a/src/resources/themes/forms.scss +++ b/src/resources/themes/forms.scss @@ -28,6 +28,13 @@ } } +.acl-input { + select.acl, + select.mod-user { + max-width: fit-content; + } +} + .range-input { display: flex; diff --git a/src/resources/vue/Admin/SharedFolder.vue b/src/resources/vue/Admin/SharedFolder.vue new file mode 100644 --- /dev/null +++ b/src/resources/vue/Admin/SharedFolder.vue @@ -0,0 +1,91 @@ + + + diff --git a/src/resources/vue/Admin/User.vue b/src/resources/vue/Admin/User.vue --- a/src/resources/vue/Admin/User.vue +++ b/src/resources/vue/Admin/User.vue @@ -122,9 +122,14 @@ {{ $t('user.resources') }} ({{ resources.length }}) + @@ -349,6 +354,36 @@ +
+
+
+ + + + + + + + + + + + + + + + + + + + +
{{ $t('form.name') }}{{ $t('form.type') }}{{ $t('form.email') }}
+ + {{ folder.name }} + {{ $t('shf.type-' + folder.type) }}{{ folder.email }}
{{ $t('shf.list-empty') }}
+
+
+
@@ -492,6 +527,7 @@ discount_description: '', discounts: [], external_email: '', + folders: [], has2FA: false, hasBeta: false, wallet: {}, @@ -604,6 +640,12 @@ .then(response => { this.resources = response.data.list }) + + // Fetch shared folders lists + axios.get('/api/v4/shared-folders?owner=' + user_id) + .then(response => { + this.folders = response.data.list + }) }) .catch(this.$root.errorHandler) }, diff --git a/src/resources/vue/Dashboard.vue b/src/resources/vue/Dashboard.vue --- a/src/resources/vue/Dashboard.vue +++ b/src/resources/vue/Dashboard.vue @@ -18,6 +18,9 @@ {{ $t('dashboard.resources') }} + + {{ $t('dashboard.shared-folders') }} + {{ $t('dashboard.wallet') }} {{ $root.price(balance, currency) }} diff --git a/src/resources/vue/Resource/Info.vue b/src/resources/vue/Resource/Info.vue --- a/src/resources/vue/Resource/Info.vue +++ b/src/resources/vue/Resource/Info.vue @@ -43,7 +43,7 @@
diff --git a/src/resources/vue/Resource/Info.vue b/src/resources/vue/SharedFolder/Info.vue copy from src/resources/vue/Resource/Info.vue copy to src/resources/vue/SharedFolder/Info.vue --- a/src/resources/vue/Resource/Info.vue +++ b/src/resources/vue/SharedFolder/Info.vue @@ -1,16 +1,16 @@ diff --git a/src/resources/vue/Widgets/AclInput.vue b/src/resources/vue/Widgets/AclInput.vue new file mode 100644 --- /dev/null +++ b/src/resources/vue/Widgets/AclInput.vue @@ -0,0 +1,118 @@ + + + diff --git a/src/resources/vue/Widgets/Status.vue b/src/resources/vue/Widgets/Status.vue --- a/src/resources/vue/Widgets/Status.vue +++ b/src/resources/vue/Widgets/Status.vue @@ -2,11 +2,7 @@

- {{ $t('status.prepare-account') }} - {{ $t('status.prepare-domain') }} - {{ $t('status.prepare-distlist') }} - {{ $t('status.prepare-resource') }} - {{ $t('status.prepare-user') }} + {{ $t('status.prepare-' + scopeLabel()) }}
{{ $t('status.prepare-hint') }}
@@ -18,11 +14,7 @@

- {{ $t('status.ready-account') }} - {{ $t('status.ready-domain') }} - {{ $t('status.ready-distlist') }} - {{ $t('status.ready-resource') }} - {{ $t('status.ready-user') }} + {{ $t('status.ready-' + scopeLabel()) }}
{{ $t('status.verify') }}

@@ -183,20 +175,23 @@ this.$emit('status-update', data) }, getUrl() { - let url - - switch (this.scope) { - case 'dashboard': - url = '/api/v4/users/' + this.$store.state.authInfo.id + '/status' - break - case 'distlist': - url = '/api/v4/groups/' + this.$route.params.list + '/status' - break - default: - url = '/api/v4/' + this.scope + 's/' + this.$route.params[this.scope] + '/status' + let scope = this.scope + let id = this.$route.params[scope] + + if (scope == 'dashboard') { + id = this.$store.state.authInfo.id + scope = 'user' + } else if (scope =='distlist') { + id = this.$route.params.list + scope = 'group' + } else if (scope == 'shared-folder') { + id = this.$route.params.folder } - return url + return '/api/v4/' + scope + 's/' + id + '/status' + }, + scopeLabel() { + return this.scope == 'dashboard' ? 'account' : this.scope } } } diff --git a/src/routes/api.php b/src/routes/api.php --- a/src/routes/api.php +++ b/src/routes/api.php @@ -84,6 +84,10 @@ Route::get('resources/{id}/status', 'API\V4\ResourcesController@status'); Route::post('resources/{id}/config', 'API\V4\ResourcesController@setConfig'); + Route::apiResource('shared-folders', API\V4\SharedFoldersController::class); + Route::get('shared-folders/{id}/status', 'API\V4\SharedFoldersController@status'); + Route::post('shared-folders/{id}/config', 'API\V4\SharedFoldersController@setConfig'); + Route::apiResource('skus', API\V4\SkusController::class); Route::apiResource('users', API\V4\UsersController::class); @@ -190,6 +194,7 @@ Route::post('groups/{id}/unsuspend', 'API\V4\Admin\GroupsController@unsuspend'); Route::apiResource('resources', API\V4\Admin\ResourcesController::class); + Route::apiResource('shared-folders', API\V4\Admin\SharedFoldersController::class); Route::apiResource('skus', API\V4\Admin\SkusController::class); Route::apiResource('users', API\V4\Admin\UsersController::class); Route::get('users/{id}/discounts', 'API\V4\Reseller\DiscountsController@userDiscounts'); @@ -237,6 +242,7 @@ Route::get('payments/has-pending', 'API\V4\Reseller\PaymentsController@hasPayments'); Route::apiResource('resources', API\V4\Reseller\ResourcesController::class); + Route::apiResource('shared-folders', API\V4\Reseller\SharedFoldersController::class); Route::apiResource('skus', API\V4\Reseller\SkusController::class); Route::apiResource('users', API\V4\Reseller\UsersController::class); Route::get('users/{id}/discounts', 'API\V4\Reseller\DiscountsController@userDiscounts'); diff --git a/src/tests/Browser/Admin/ResourceTest.php b/src/tests/Browser/Admin/ResourceTest.php --- a/src/tests/Browser/Admin/ResourceTest.php +++ b/src/tests/Browser/Admin/ResourceTest.php @@ -56,6 +56,9 @@ $user = $this->getTestUser('john@kolab.org'); $resource = $this->getTestResource('resource-test1@kolab.org'); $resource->setSetting('invitation_policy', 'accept'); + $resource->status = Resource::STATUS_NEW | Resource::STATUS_ACTIVE + | Resource::STATUS_LDAP_READY | Resource::STATUS_IMAP_READY; + $resource->save(); $resource_page = new ResourcePage($resource->id); $user_page = new UserPage($user->id); diff --git a/src/tests/Browser/Admin/ResourceTest.php b/src/tests/Browser/Admin/SharedFolderTest.php copy from src/tests/Browser/Admin/ResourceTest.php copy to src/tests/Browser/Admin/SharedFolderTest.php --- a/src/tests/Browser/Admin/ResourceTest.php +++ b/src/tests/Browser/Admin/SharedFolderTest.php @@ -2,17 +2,17 @@ namespace Tests\Browser\Admin; -use App\Resource; +use App\SharedFolder; use Illuminate\Support\Facades\Queue; use Tests\Browser; use Tests\Browser\Components\Toast; -use Tests\Browser\Pages\Admin\Resource as ResourcePage; +use Tests\Browser\Pages\Admin\SharedFolder as SharedFolderPage; use Tests\Browser\Pages\Admin\User as UserPage; use Tests\Browser\Pages\Dashboard; use Tests\Browser\Pages\Home; use Tests\TestCaseDusk; -class ResourceTest extends TestCaseDusk +class SharedFolderTest extends TestCaseDusk { /** * {@inheritDoc} @@ -32,21 +32,21 @@ } /** - * Test resource info page (unauthenticated) + * Test shared folder info page (unauthenticated) */ - public function testResourceUnauth(): void + public function testSharedFolderUnauth(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $user = $this->getTestUser('john@kolab.org'); - $resource = $this->getTestResource('resource-test1@kolab.org'); + $folder = $this->getTestSharedFolder('folder-event@kolab.org'); - $browser->visit('/resource/' . $resource->id)->on(new Home()); + $browser->visit('/shared-folder/' . $folder->id)->on(new Home()); }); } /** - * Test resource info page + * Test shared folder info page */ public function testInfo(): void { @@ -54,42 +54,48 @@ $this->browse(function (Browser $browser) { $user = $this->getTestUser('john@kolab.org'); - $resource = $this->getTestResource('resource-test1@kolab.org'); - $resource->setSetting('invitation_policy', 'accept'); + $folder = $this->getTestSharedFolder('folder-event@kolab.org'); + $folder->setConfig(['acl' => ['anyone, read-only', 'jack@kolab.org, read-write']]); + $folder->status = SharedFolder::STATUS_NEW | SharedFolder::STATUS_ACTIVE + | SharedFolder::STATUS_LDAP_READY | SharedFolder::STATUS_IMAP_READY; + $folder->save(); - $resource_page = new ResourcePage($resource->id); + $folder_page = new SharedFolderPage($folder->id); $user_page = new UserPage($user->id); - // Goto the resource page + // Goto the folder page $browser->visit(new Home()) ->submitLogon('jeroen@jeroen.jeroen', \App\Utils::generatePassphrase(), true) ->on(new Dashboard()) ->visit($user_page) ->on($user_page) - ->click('@nav #tab-resources') + ->click('@nav #tab-shared-folders') ->pause(1000) - ->click('@user-resources table tbody tr:first-child td:first-child a') - ->on($resource_page) - ->assertSeeIn('@resource-info .card-title', $resource->email) - ->with('@resource-info form', function (Browser $browser) use ($resource) { - $browser->assertElementsCount('.row', 3) + ->click('@user-shared-folders table tbody tr:first-child td:first-child a') + ->on($folder_page) + ->assertSeeIn('@folder-info .card-title', $folder->email) + ->with('@folder-info form', function (Browser $browser) use ($folder) { + $browser->assertElementsCount('.row', 4) ->assertSeeIn('.row:nth-child(1) label', 'ID (Created)') - ->assertSeeIn('.row:nth-child(1) #resourceid', "{$resource->id} ({$resource->created_at})") + ->assertSeeIn('.row:nth-child(1) #folderid', "{$folder->id} ({$folder->created_at})") ->assertSeeIn('.row:nth-child(2) label', 'Status') ->assertSeeIn('.row:nth-child(2) #status.text-success', 'Active') ->assertSeeIn('.row:nth-child(3) label', 'Name') - ->assertSeeIn('.row:nth-child(3) #name', $resource->name); + ->assertSeeIn('.row:nth-child(3) #name', $folder->name) + ->assertSeeIn('.row:nth-child(4) label', 'Type') + ->assertSeeIn('.row:nth-child(4) #type', 'Calendar'); }) ->assertElementsCount('ul.nav-tabs', 1) ->assertSeeIn('ul.nav-tabs .nav-link', 'Settings') - ->with('@resource-settings form', function (Browser $browser) { + ->with('@folder-settings form', function (Browser $browser) { $browser->assertElementsCount('.row', 1) - ->assertSeeIn('.row:nth-child(1) label', 'Invitation policy') - ->assertSeeIn('.row:nth-child(1) #invitation_policy', 'accept'); + ->assertSeeIn('.row:nth-child(1) label', 'Access rights') + ->assertSeeIn('.row:nth-child(1) #acl', 'anyone: read-only') + ->assertSeeIn('.row:nth-child(1) #acl', 'jack@kolab.org: read-write'); }); - // Test invalid resource identifier - $browser->visit('/resource/abc')->assertErrorPage(404); + // Test invalid shared folder identifier + $browser->visit('/shared-folder/abc')->assertErrorPage(404); }); } } diff --git a/src/tests/Browser/Admin/UserTest.php b/src/tests/Browser/Admin/UserTest.php --- a/src/tests/Browser/Admin/UserTest.php +++ b/src/tests/Browser/Admin/UserTest.php @@ -116,7 +116,7 @@ // Some tabs are loaded in background, wait a second $browser->pause(500) - ->assertElementsCount('@nav a', 8); + ->assertElementsCount('@nav a', 9); // Note: Finances tab is tested in UserFinancesTest.php $browser->assertSeeIn('@nav #tab-finances', 'Finances'); @@ -177,6 +177,14 @@ ->assertSeeIn('table tfoot tr td', 'There are no resources in this account.'); }); + // Assert Shared folders tab + $browser->assertSeeIn('@nav #tab-shared-folders', 'Shared folders (0)') + ->click('@nav #tab-shared-folders') + ->with('@user-shared-folders', function (Browser $browser) { + $browser->assertElementsCount('table tbody tr', 0) + ->assertSeeIn('table tfoot tr td', 'There are no shared folders in this account.'); + }); + // Assert Settings tab $browser->assertSeeIn('@nav #tab-settings', 'Settings') ->click('@nav #tab-settings') @@ -240,7 +248,7 @@ // Some tabs are loaded in background, wait a second $browser->pause(500) - ->assertElementsCount('@nav a', 8); + ->assertElementsCount('@nav a', 9); // Note: Finances tab is tested in UserFinancesTest.php $browser->assertSeeIn('@nav #tab-finances', 'Finances'); @@ -279,6 +287,22 @@ ->assertMissing('tfoot'); }); + // Assert Users tab + $browser->assertSeeIn('@nav #tab-users', 'Users (4)') + ->click('@nav #tab-users') + ->with('@user-users table', function (Browser $browser) { + $browser->assertElementsCount('tbody tr', 4) + ->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'jack@kolab.org') + ->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-success') + ->assertSeeIn('tbody tr:nth-child(2) td:first-child a', 'joe@kolab.org') + ->assertVisible('tbody tr:nth-child(2) td:first-child svg.text-success') + ->assertSeeIn('tbody tr:nth-child(3) td:first-child span', 'john@kolab.org') + ->assertVisible('tbody tr:nth-child(3) td:first-child svg.text-success') + ->assertSeeIn('tbody tr:nth-child(4) td:first-child a', 'ned@kolab.org') + ->assertVisible('tbody tr:nth-child(4) td:first-child svg.text-success') + ->assertMissing('tfoot'); + }); + // Assert Distribution lists tab $browser->assertSeeIn('@nav #tab-distlists', 'Distribution lists (1)') ->click('@nav #tab-distlists') @@ -302,20 +326,18 @@ ->assertMissing('table tfoot'); }); - // Assert Users tab - $browser->assertSeeIn('@nav #tab-users', 'Users (4)') - ->click('@nav #tab-users') - ->with('@user-users table', function (Browser $browser) { - $browser->assertElementsCount('tbody tr', 4) - ->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'jack@kolab.org') - ->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-success') - ->assertSeeIn('tbody tr:nth-child(2) td:first-child a', 'joe@kolab.org') - ->assertVisible('tbody tr:nth-child(2) td:first-child svg.text-success') - ->assertSeeIn('tbody tr:nth-child(3) td:first-child span', 'john@kolab.org') - ->assertVisible('tbody tr:nth-child(3) td:first-child svg.text-success') - ->assertSeeIn('tbody tr:nth-child(4) td:first-child a', 'ned@kolab.org') - ->assertVisible('tbody tr:nth-child(4) td:first-child svg.text-success') - ->assertMissing('tfoot'); + // Assert Shared folders tab + $browser->assertSeeIn('@nav #tab-shared-folders', 'Shared folders (2)') + ->click('@nav #tab-shared-folders') + ->with('@user-shared-folders', function (Browser $browser) { + $browser->assertElementsCount('table tbody tr', 2) + ->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'Calendar') + ->assertSeeIn('table tbody tr:nth-child(1) td:nth-child(2)', 'Calendar') + ->assertSeeIn('table tbody tr:nth-child(1) td:last-child', 'folder-event@kolab.org') + ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Contacts') + ->assertSeeIn('table tbody tr:nth-child(2) td:nth-child(2)', 'Address Book') + ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', 'folder-contact@kolab.org') + ->assertMissing('table tfoot'); }); }); @@ -345,7 +367,8 @@ $page = new UserPage($ned->id); $ned->setSetting('greylist_enabled', 'false'); - $browser->click('@user-users tbody tr:nth-child(4) td:first-child a') + $browser->click('@nav #tab-users') + ->click('@user-users tbody tr:nth-child(4) td:first-child a') ->on($page); // Assert main info box content @@ -357,7 +380,7 @@ // Some tabs are loaded in background, wait a second $browser->pause(500) - ->assertElementsCount('@nav a', 8); + ->assertElementsCount('@nav a', 9); // Note: Finances tab is tested in UserFinancesTest.php $browser->assertSeeIn('@nav #tab-finances', 'Finances'); @@ -425,6 +448,14 @@ ->assertSeeIn('table tfoot tr td', 'There are no resources in this account.'); }); + // We don't expect John's folders here + $browser->assertSeeIn('@nav #tab-shared-folders', 'Shared folders (0)') + ->click('@nav #tab-shared-folders') + ->with('@user-shared-folders', function (Browser $browser) { + $browser->assertElementsCount('table tbody tr', 0) + ->assertSeeIn('table tfoot tr td', 'There are no shared folders in this account.'); + }); + // Assert Settings tab $browser->assertSeeIn('@nav #tab-settings', 'Settings') ->click('@nav #tab-settings') diff --git a/src/tests/Browser/Components/AclInput.php b/src/tests/Browser/Components/AclInput.php new file mode 100644 --- /dev/null +++ b/src/tests/Browser/Components/AclInput.php @@ -0,0 +1,141 @@ +selector = $selector; + } + + /** + * Get the root selector for the component. + * + * @return string + */ + public function selector() + { + return $this->selector; + } + + /** + * Assert that the browser page contains the component. + * + * @param \Laravel\Dusk\Browser $browser + * + * @return void + */ + public function assert($browser) + { + $browser->assertVisible($this->selector) + ->assertVisible("{$this->selector} @input") + ->assertVisible("{$this->selector} @add-btn") + ->assertSelectHasOptions("{$this->selector} @mod-select", ['user', 'anyone']) + ->assertSelectHasOptions("{$this->selector} @acl-select", ['read-only', 'read-write', 'full']); + } + + /** + * Get the element shortcuts for the component. + * + * @return array + */ + public function elements() + { + return [ + '@add-btn' => '.input-group:first-child a.btn', + '@input' => '.input-group:first-child input', + '@acl-select' => '.input-group:first-child select.acl', + '@mod-select' => '.input-group:first-child select.mod', + ]; + } + + /** + * Assert acl input content + */ + public function assertAclValue($browser, array $list) + { + if (empty($list)) { + $browser->assertMissing('.input-group:not(:first-child)'); + return; + } + + foreach ($list as $idx => $value) { + $selector = '.input-group:nth-child(' . ($idx + 2) . ')'; + list($ident, $acl) = preg_split('/\s*,\s*/', $value); + + $input = $ident == 'anyone' ? 'input:read-only' : 'input:not(:read-only)'; + + $browser->assertVisible("$selector $input") + ->assertVisible("$selector select") + ->assertVisible("$selector a.btn") + ->assertValue("$selector $input", $ident) + ->assertSelected("$selector select", $acl); + } + } + + /** + * Add acl entry + */ + public function addAclEntry($browser, string $value) + { + list($ident, $acl) = preg_split('/\s*,\s*/', $value); + + $browser->select('@mod-select', $ident == 'anyone' ? 'anyone' : 'user') + ->select('@acl-select', $acl); + + if ($ident == 'anyone') { + $browser->assertValue('@input', '')->assertMissing('@input'); + } else { + $browser->type('@input', $ident); + } + + $browser->click('@add-btn') + ->assertSelected('@mod-select', 'user') + ->assertSelected('@acl-select', 'read-only') + ->assertValue('@input', ''); + } + + /** + * Remove acl entry + */ + public function removeAclEntry($browser, int $num) + { + $selector = '.input-group:nth-child(' . ($num + 1) . ') a.btn'; + $browser->click($selector); + } + + /** + * Update acl entry + */ + public function updateAclEntry($browser, int $num, $value) + { + list($ident, $acl) = preg_split('/\s*,\s*/', $value); + + $selector = '.input-group:nth-child(' . ($num + 1) . ')'; + + $browser->select("$selector select.acl", $acl) + ->type("$selector input", $ident); + } + + /** + * Assert an error message on the widget + */ + public function assertFormError($browser, int $num, string $msg, bool $focused = false) + { + $selector = '.input-group:nth-child(' . ($num + 1) . ') input.is-invalid'; + + $browser->waitFor($selector) + ->assertSeeIn(' + .invalid-feedback', $msg); + + if ($focused) { + $browser->assertFocused($selector); + } + } +} diff --git a/src/tests/Browser/Pages/Admin/SharedFolder.php b/src/tests/Browser/Pages/Admin/SharedFolder.php new file mode 100644 --- /dev/null +++ b/src/tests/Browser/Pages/Admin/SharedFolder.php @@ -0,0 +1,57 @@ +folderId = $id; + } + + /** + * Get the URL for the page. + * + * @return string + */ + public function url(): string + { + return '/shared-folder/' . $this->folderId; + } + + /** + * Assert that the browser is on the page. + * + * @param \Laravel\Dusk\Browser $browser The browser object + * + * @return void + */ + public function assert($browser): void + { + $browser->waitForLocation($this->url()) + ->waitFor('@folder-info'); + } + + /** + * Get the element shortcuts for the page. + * + * @return array + */ + public function elements(): array + { + return [ + '@app' => '#app', + '@folder-info' => '#folder-info', + '@folder-settings' => '#folder-settings', + ]; + } +} diff --git a/src/tests/Browser/Pages/Admin/User.php b/src/tests/Browser/Pages/Admin/User.php --- a/src/tests/Browser/Pages/Admin/User.php +++ b/src/tests/Browser/Pages/Admin/User.php @@ -59,6 +59,7 @@ '@user-distlists' => '#user-distlists', '@user-domains' => '#user-domains', '@user-resources' => '#user-resources', + '@user-shared-folders' => '#user-shared-folders', '@user-users' => '#user-users', '@user-settings' => '#user-settings', ]; diff --git a/src/tests/Browser/Pages/SharedFolderInfo.php b/src/tests/Browser/Pages/SharedFolderInfo.php new file mode 100644 --- /dev/null +++ b/src/tests/Browser/Pages/SharedFolderInfo.php @@ -0,0 +1,47 @@ +waitFor('@general') + ->waitUntilMissing('.app-loader'); + } + + /** + * Get the element shortcuts for the page. + * + * @return array + */ + public function elements(): array + { + return [ + '@app' => '#app', + '@general' => '#general', + '@nav' => 'ul.nav-tabs', + '@settings' => '#settings', + '@status' => '#status-box', + ]; + } +} diff --git a/src/tests/Browser/Pages/SharedFolderList.php b/src/tests/Browser/Pages/SharedFolderList.php new file mode 100644 --- /dev/null +++ b/src/tests/Browser/Pages/SharedFolderList.php @@ -0,0 +1,45 @@ +assertPathIs($this->url()) + ->waitUntilMissing('@app .app-loader') + ->assertSeeIn('#folder-list .card-title', 'Shared folders'); + } + + /** + * Get the element shortcuts for the page. + * + * @return array + */ + public function elements(): array + { + return [ + '@app' => '#app', + '@table' => '#folder-list table', + ]; + } +} diff --git a/src/tests/Browser/Reseller/ResourceTest.php b/src/tests/Browser/Reseller/ResourceTest.php --- a/src/tests/Browser/Reseller/ResourceTest.php +++ b/src/tests/Browser/Reseller/ResourceTest.php @@ -56,6 +56,9 @@ $user = $this->getTestUser('john@kolab.org'); $resource = $this->getTestResource('resource-test1@kolab.org'); $resource->setSetting('invitation_policy', 'accept'); + $resource->status = Resource::STATUS_NEW | Resource::STATUS_ACTIVE + | Resource::STATUS_LDAP_READY | Resource::STATUS_IMAP_READY; + $resource->save(); $resource_page = new ResourcePage($resource->id); $user_page = new UserPage($user->id); diff --git a/src/tests/Browser/Reseller/ResourceTest.php b/src/tests/Browser/Reseller/SharedFolderTest.php copy from src/tests/Browser/Reseller/ResourceTest.php copy to src/tests/Browser/Reseller/SharedFolderTest.php --- a/src/tests/Browser/Reseller/ResourceTest.php +++ b/src/tests/Browser/Reseller/SharedFolderTest.php @@ -2,17 +2,17 @@ namespace Tests\Browser\Reseller; -use App\Resource; +use App\SharedFolder; use Illuminate\Support\Facades\Queue; use Tests\Browser; use Tests\Browser\Components\Toast; -use Tests\Browser\Pages\Admin\Resource as ResourcePage; +use Tests\Browser\Pages\Admin\SharedFolder as SharedFolderPage; use Tests\Browser\Pages\Admin\User as UserPage; use Tests\Browser\Pages\Dashboard; use Tests\Browser\Pages\Home; use Tests\TestCaseDusk; -class ResourceTest extends TestCaseDusk +class SharedFolderTest extends TestCaseDusk { /** * {@inheritDoc} @@ -32,21 +32,21 @@ } /** - * Test resource info page (unauthenticated) + * Test shared folder info page (unauthenticated) */ - public function testResourceUnauth(): void + public function testSharedFolderUnauth(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $user = $this->getTestUser('john@kolab.org'); - $resource = $this->getTestResource('resource-test1@kolab.org'); + $folder = $this->getTestSharedFolder('folder-event@kolab.org'); - $browser->visit('/resource/' . $resource->id)->on(new Home()); + $browser->visit('/shared-folder/' . $folder->id)->on(new Home()); }); } /** - * Test distribution list info page + * Test shared folder info page */ public function testInfo(): void { @@ -54,42 +54,48 @@ $this->browse(function (Browser $browser) { $user = $this->getTestUser('john@kolab.org'); - $resource = $this->getTestResource('resource-test1@kolab.org'); - $resource->setSetting('invitation_policy', 'accept'); + $folder = $this->getTestSharedFolder('folder-event@kolab.org'); + $folder->setConfig(['acl' => ['anyone, read-only', 'jack@kolab.org, read-write']]); + $folder->status = SharedFolder::STATUS_NEW | SharedFolder::STATUS_ACTIVE + | SharedFolder::STATUS_LDAP_READY | SharedFolder::STATUS_IMAP_READY; + $folder->save(); - $resource_page = new ResourcePage($resource->id); + $folder_page = new SharedFolderPage($folder->id); $user_page = new UserPage($user->id); - // Goto the distlist page + // Goto the folder page $browser->visit(new Home()) ->submitLogon('reseller@' . \config('app.domain'), \App\Utils::generatePassphrase(), true) ->on(new Dashboard()) ->visit($user_page) ->on($user_page) - ->click('@nav #tab-resources') + ->click('@nav #tab-shared-folders') ->pause(1000) - ->click('@user-resources table tbody tr:first-child td:first-child a') - ->on($resource_page) - ->assertSeeIn('@resource-info .card-title', $resource->email) - ->with('@resource-info form', function (Browser $browser) use ($resource) { - $browser->assertElementsCount('.row', 3) + ->click('@user-shared-folders table tbody tr:first-child td:first-child a') + ->on($folder_page) + ->assertSeeIn('@folder-info .card-title', $folder->email) + ->with('@folder-info form', function (Browser $browser) use ($folder) { + $browser->assertElementsCount('.row', 4) ->assertSeeIn('.row:nth-child(1) label', 'ID (Created)') - ->assertSeeIn('.row:nth-child(1) #resourceid', "{$resource->id} ({$resource->created_at})") + ->assertSeeIn('.row:nth-child(1) #folderid', "{$folder->id} ({$folder->created_at})") ->assertSeeIn('.row:nth-child(2) label', 'Status') ->assertSeeIn('.row:nth-child(2) #status.text-success', 'Active') ->assertSeeIn('.row:nth-child(3) label', 'Name') - ->assertSeeIn('.row:nth-child(3) #name', $resource->name); + ->assertSeeIn('.row:nth-child(3) #name', $folder->name) + ->assertSeeIn('.row:nth-child(4) label', 'Type') + ->assertSeeIn('.row:nth-child(4) #type', 'Calendar'); }) ->assertElementsCount('ul.nav-tabs', 1) ->assertSeeIn('ul.nav-tabs .nav-link', 'Settings') - ->with('@resource-settings form', function (Browser $browser) { + ->with('@folder-settings form', function (Browser $browser) { $browser->assertElementsCount('.row', 1) - ->assertSeeIn('.row:nth-child(1) label', 'Invitation policy') - ->assertSeeIn('.row:nth-child(1) #invitation_policy', 'accept'); + ->assertSeeIn('.row:nth-child(1) label', 'Access rights') + ->assertSeeIn('.row:nth-child(1) #acl', 'anyone: read-only') + ->assertSeeIn('.row:nth-child(1) #acl', 'jack@kolab.org: read-write'); }); - // Test invalid resource identifier - $browser->visit('/resource/abc')->assertErrorPage(404); + // Test invalid shared folder identifier + $browser->visit('/shared-folder/abc')->assertErrorPage(404); }); } } diff --git a/src/tests/Browser/Reseller/UserTest.php b/src/tests/Browser/Reseller/UserTest.php --- a/src/tests/Browser/Reseller/UserTest.php +++ b/src/tests/Browser/Reseller/UserTest.php @@ -113,7 +113,7 @@ // Some tabs are loaded in background, wait a second $browser->pause(500) - ->assertElementsCount('@nav a', 8); + ->assertElementsCount('@nav a', 9); // Note: Finances tab is tested in UserFinancesTest.php $browser->assertSeeIn('@nav #tab-finances', 'Finances'); @@ -174,6 +174,14 @@ ->assertSeeIn('table tfoot tr td', 'There are no resources in this account.'); }); + // Assert Shared folders tab + $browser->assertSeeIn('@nav #tab-shared-folders', 'Shared folders (0)') + ->click('@nav #tab-shared-folders') + ->with('@user-shared-folders', function (Browser $browser) { + $browser->assertElementsCount('table tbody tr', 0) + ->assertSeeIn('table tfoot tr td', 'There are no shared folders in this account.'); + }); + // Assert Settings tab $browser->assertSeeIn('@nav #tab-settings', 'Settings') ->click('@nav #tab-settings') @@ -236,7 +244,7 @@ // Some tabs are loaded in background, wait a second $browser->pause(500) - ->assertElementsCount('@nav a', 8); + ->assertElementsCount('@nav a', 9); // Note: Finances tab is tested in UserFinancesTest.php $browser->assertSeeIn('@nav #tab-finances', 'Finances'); @@ -265,6 +273,22 @@ ->assertSeeIn('table + .hint', '¹ applied discount: 10% - Test voucher'); }); + // Assert Users tab + $browser->assertSeeIn('@nav #tab-users', 'Users (4)') + ->click('@nav #tab-users') + ->with('@user-users table', function (Browser $browser) { + $browser->assertElementsCount('tbody tr', 4) + ->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'jack@kolab.org') + ->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-success') + ->assertSeeIn('tbody tr:nth-child(2) td:first-child a', 'joe@kolab.org') + ->assertVisible('tbody tr:nth-child(2) td:first-child svg.text-success') + ->assertSeeIn('tbody tr:nth-child(3) td:first-child span', 'john@kolab.org') + ->assertVisible('tbody tr:nth-child(3) td:first-child svg.text-success') + ->assertSeeIn('tbody tr:nth-child(4) td:first-child a', 'ned@kolab.org') + ->assertVisible('tbody tr:nth-child(4) td:first-child svg.text-success') + ->assertMissing('tfoot'); + }); + // Assert Domains tab $browser->assertSeeIn('@nav #tab-domains', 'Domains (1)') ->click('@nav #tab-domains') @@ -298,20 +322,18 @@ ->assertMissing('table tfoot'); }); - // Assert Users tab - $browser->assertSeeIn('@nav #tab-users', 'Users (4)') - ->click('@nav #tab-users') - ->with('@user-users table', function (Browser $browser) { - $browser->assertElementsCount('tbody tr', 4) - ->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'jack@kolab.org') - ->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-success') - ->assertSeeIn('tbody tr:nth-child(2) td:first-child a', 'joe@kolab.org') - ->assertVisible('tbody tr:nth-child(2) td:first-child svg.text-success') - ->assertSeeIn('tbody tr:nth-child(3) td:first-child span', 'john@kolab.org') - ->assertVisible('tbody tr:nth-child(3) td:first-child svg.text-success') - ->assertSeeIn('tbody tr:nth-child(4) td:first-child a', 'ned@kolab.org') - ->assertVisible('tbody tr:nth-child(4) td:first-child svg.text-success') - ->assertMissing('tfoot'); + // Assert Shared folders tab + $browser->assertSeeIn('@nav #tab-shared-folders', 'Shared folders (2)') + ->click('@nav #tab-shared-folders') + ->with('@user-shared-folders', function (Browser $browser) { + $browser->assertElementsCount('table tbody tr', 2) + ->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'Calendar') + ->assertSeeIn('table tbody tr:nth-child(1) td:nth-child(2)', 'Calendar') + ->assertSeeIn('table tbody tr:nth-child(1) td:last-child', 'folder-event@kolab.org') + ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Contacts') + ->assertSeeIn('table tbody tr:nth-child(2) td:nth-child(2)', 'Address Book') + ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', 'folder-contact@kolab.org') + ->assertMissing('table tfoot'); }); }); @@ -321,7 +343,8 @@ $ned->setSetting('greylist_enabled', 'false'); $page = new UserPage($ned->id); - $browser->click('@user-users tbody tr:nth-child(4) td:first-child a') + $browser->click('@nav #tab-users') + ->click('@user-users tbody tr:nth-child(4) td:first-child a') ->on($page); // Assert main info box content @@ -333,7 +356,7 @@ // Some tabs are loaded in background, wait a second $browser->pause(500) - ->assertElementsCount('@nav a', 8); + ->assertElementsCount('@nav a', 9); // Note: Finances tab is tested in UserFinancesTest.php $browser->assertSeeIn('@nav #tab-finances', 'Finances'); @@ -398,6 +421,14 @@ ->assertSeeIn('table tfoot tr td', 'There are no resources in this account.'); }); + // Assert Shared folders tab + $browser->assertSeeIn('@nav #tab-shared-folders', 'Shared folders (0)') + ->click('@nav #tab-shared-folders') + ->with('@user-shared-folders', function (Browser $browser) { + $browser->assertElementsCount('table tbody tr', 0) + ->assertSeeIn('table tfoot tr td', 'There are no shared folders in this account.'); + }); + // Assert Settings tab $browser->assertSeeIn('@nav #tab-settings', 'Settings') ->click('@nav #tab-settings') diff --git a/src/tests/Browser/SharedFolderTest.php b/src/tests/Browser/SharedFolderTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Browser/SharedFolderTest.php @@ -0,0 +1,333 @@ +delete(); + $this->clearBetaEntitlements(); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + SharedFolder::whereNotIn('email', ['folder-event@kolab.org', 'folder-contact@kolab.org'])->delete(); + $this->clearBetaEntitlements(); + + parent::tearDown(); + } + + /** + * Test shared folder info page (unauthenticated) + */ + public function testInfoUnauth(): void + { + // Test that the page requires authentication + $this->browse(function (Browser $browser) { + $browser->visit('/shared-folder/abc')->on(new Home()); + }); + } + + /** + * Test shared folder list page (unauthenticated) + */ + public function testListUnauth(): void + { + // Test that the page requires authentication + $this->browse(function (Browser $browser) { + $browser->visit('/shared-folders')->on(new Home()); + }); + } + + /** + * Test shared folders list page + */ + public function testList(): void + { + // Log on the user + $this->browse(function (Browser $browser) { + $browser->visit(new Home()) + ->submitLogon('john@kolab.org', 'simple123', true) + ->on(new Dashboard()) + ->assertMissing('@links .link-shared-folders'); + }); + + // Test that shared folders lists page is not accessible without the 'beta-shared-folders' entitlement + $this->browse(function (Browser $browser) { + $browser->visit('/shared-folders') + ->assertErrorPage(403); + }); + + // Add beta+beta-shared-folders entitlements + $john = $this->getTestUser('john@kolab.org'); + $this->addBetaEntitlement($john, 'beta-shared-folders'); + // Make sure the first folder is active + $folder = $this->getTestSharedFolder('folder-event@kolab.org'); + $folder->status = SharedFolder::STATUS_NEW | SharedFolder::STATUS_ACTIVE + | SharedFolder::STATUS_LDAP_READY | SharedFolder::STATUS_IMAP_READY; + $folder->save(); + + // Test shared folders lists page + $this->browse(function (Browser $browser) { + $browser->visit(new Dashboard()) + ->assertSeeIn('@links .link-shared-folders', 'Shared folders') + ->click('@links .link-shared-folders') + ->on(new SharedFolderList()) + ->whenAvailable('@table', function (Browser $browser) { + $browser->waitFor('tbody tr') + ->assertSeeIn('thead tr th:nth-child(1)', 'Name') + ->assertSeeIn('thead tr th:nth-child(2)', 'Type') + ->assertSeeIn('thead tr th:nth-child(3)', 'Email Address') + ->assertElementsCount('tbody tr', 2) + ->assertSeeIn('tbody tr:nth-child(1) td:nth-child(1) a', 'Calendar') + ->assertSeeIn('tbody tr:nth-child(1) td:nth-child(2)', 'Calendar') + ->assertSeeIn('tbody tr:nth-child(1) td:nth-child(3) a', 'folder-event@kolab.org') + ->assertText('tbody tr:nth-child(1) td:nth-child(1) svg.text-success title', 'Active') + ->assertSeeIn('tbody tr:nth-child(2) td:nth-child(1) a', 'Contacts') + ->assertSeeIn('tbody tr:nth-child(2) td:nth-child(2)', 'Address Book') + ->assertSeeIn('tbody tr:nth-child(2) td:nth-child(3) a', 'folder-contact@kolab.org') + ->assertMissing('tfoot'); + }); + }); + } + + /** + * Test shared folder creation/editing/deleting + * + * @depends testList + */ + public function testCreateUpdateDelete(): void + { + // Test that the page is not available accessible without the 'beta-shared-folders' entitlement + $this->browse(function (Browser $browser) { + $browser->visit('/shared-folder/new') + ->assertErrorPage(403); + }); + + // Add beta+beta-shared-folders entitlements + $john = $this->getTestUser('john@kolab.org'); + $this->addBetaEntitlement($john, 'beta-shared-folders'); + + $this->browse(function (Browser $browser) { + // Create a folder + $browser->visit(new SharedFolderList()) + ->assertSeeIn('button.create-folder', 'Create folder') + ->click('button.create-folder') + ->on(new SharedFolderInfo()) + ->assertSeeIn('#folder-info .card-title', 'New shared folder') + ->assertSeeIn('@nav #tab-general', 'General') + ->assertMissing('@nav #tab-settings') + ->with('@general', function (Browser $browser) { + // Assert form content + $browser->assertMissing('#status') + ->assertFocused('#name') + ->assertSeeIn('div.row:nth-child(1) label', 'Name') + ->assertValue('div.row:nth-child(1) input[type=text]', '') + ->assertSeeIn('div.row:nth-child(2) label', 'Type') + ->assertSelectHasOptions( + 'div.row:nth-child(2) select', + ['mail', 'event', 'task', 'contact', 'note', 'file'] + ) + ->assertValue('div.row:nth-child(2) select', 'mail') + ->assertSeeIn('div.row:nth-child(3) label', 'Domain') + ->assertSelectHasOptions('div.row:nth-child(3) select', ['kolab.org']) + ->assertValue('div.row:nth-child(3) select', 'kolab.org') + ->assertSeeIn('button[type=submit]', 'Submit'); + }) + // Test error conditions + ->type('#name', str_repeat('A', 192)) + ->click('@general button[type=submit]') + ->waitFor('#name + .invalid-feedback') + ->assertSeeIn('#name + .invalid-feedback', 'The name may not be greater than 191 characters.') + ->assertFocused('#name') + ->assertToast(Toast::TYPE_ERROR, 'Form validation error') + // Test successful folder creation + ->type('#name', 'Test Folder') + ->select('#type', 'event') + ->click('@general button[type=submit]') + ->assertToast(Toast::TYPE_SUCCESS, 'Shared folder created successfully.') + ->on(new SharedFolderList()) + ->assertElementsCount('@table tbody tr', 3); + + // Test folder update + $browser->click('@table tr:nth-child(3) td:first-child a') + ->on(new SharedFolderInfo()) + ->assertSeeIn('#folder-info .card-title', 'Shared folder') + ->with('@general', function (Browser $browser) { + // Assert form content + $browser->assertFocused('#name') + ->assertSeeIn('div.row:nth-child(1) label', 'Status') + ->assertSeeIn('div.row:nth-child(1) span.text-danger', 'Not Ready') + ->assertSeeIn('div.row:nth-child(2) label', 'Name') + ->assertValue('div.row:nth-child(2) input[type=text]', 'Test Folder') + ->assertSeeIn('div.row:nth-child(3) label', 'Type') + ->assertSelected('div.row:nth-child(3) select:disabled', 'event') + ->assertSeeIn('div.row:nth-child(4) label', 'Email Address') + ->assertAttributeRegExp( + 'div.row:nth-child(4) input[type=text]:disabled', + 'value', + '/^event-[0-9]+@kolab\.org$/' + ) + ->assertSeeIn('button[type=submit]', 'Submit'); + }) + // Test error handling + ->type('#name', str_repeat('A', 192)) + ->click('@general button[type=submit]') + ->waitFor('#name + .invalid-feedback') + ->assertSeeIn('#name + .invalid-feedback', 'The name may not be greater than 191 characters.') + ->assertVisible('#name.is-invalid') + ->assertFocused('#name') + ->assertToast(Toast::TYPE_ERROR, 'Form validation error') + // Test successful update + ->type('#name', 'Test Folder Update') + ->click('@general button[type=submit]') + ->assertToast(Toast::TYPE_SUCCESS, 'Shared folder updated successfully.') + ->on(new SharedFolderList()) + ->assertElementsCount('@table tbody tr', 3) + ->assertSeeIn('@table tr:nth-child(3) td:first-child a', 'Test Folder Update'); + + $this->assertSame(1, SharedFolder::where('name', 'Test Folder Update')->count()); + + // Test folder deletion + $browser->click('@table tr:nth-child(3) td:first-child a') + ->on(new SharedFolderInfo()) + ->assertSeeIn('button.button-delete', 'Delete folder') + ->click('button.button-delete') + ->assertToast(Toast::TYPE_SUCCESS, 'Shared folder deleted successfully.') + ->on(new SharedFolderList()) + ->assertElementsCount('@table tbody tr', 2); + + $this->assertNull(SharedFolder::where('name', 'Test Folder Update')->first()); + }); + } + + /** + * Test shared folder status + * + * @depends testList + */ + public function testStatus(): void + { + $john = $this->getTestUser('john@kolab.org'); + $this->addBetaEntitlement($john, 'beta-shared-folders'); + $folder = $this->getTestSharedFolder('folder-event@kolab.org'); + $folder->status = SharedFolder::STATUS_NEW | SharedFolder::STATUS_ACTIVE | SharedFolder::STATUS_LDAP_READY; + $folder->created_at = \now(); + $folder->save(); + + $this->assertFalse($folder->isImapReady()); + + $this->browse(function ($browser) use ($folder) { + // Test auto-refresh + $browser->visit('/shared-folder/' . $folder->id) + ->on(new SharedFolderInfo()) + ->with(new Status(), function ($browser) { + $browser->assertSeeIn('@body', 'We are preparing the shared folder') + ->assertProgress(85, 'Creating a shared folder...', 'pending') + ->assertMissing('@refresh-button') + ->assertMissing('@refresh-text') + ->assertMissing('#status-link') + ->assertMissing('#status-verify'); + }); + + $folder->status |= SharedFolder::STATUS_IMAP_READY; + $folder->save(); + + // Test Verify button + $browser->waitUntilMissing('@status', 10); + }); + + // TODO: Test all shared folder statuses on the list + } + + /** + * Test shared folder settings + */ + public function testSettings(): void + { + $john = $this->getTestUser('john@kolab.org'); + $this->addBetaEntitlement($john, 'beta-shared-folders'); + $folder = $this->getTestSharedFolder('folder-event@kolab.org'); + $folder->setSetting('acl', null); + + $this->browse(function ($browser) use ($folder) { + $aclInput = new AclInput('@settings #acl'); + // Test auto-refresh + $browser->visit('/shared-folder/' . $folder->id) + ->on(new SharedFolderInfo()) + ->assertSeeIn('@nav #tab-general', 'General') + ->assertSeeIn('@nav #tab-settings', 'Settings') + ->click('@nav #tab-settings') + ->with('@settings form', function (Browser $browser) { + // Assert form content + $browser->assertSeeIn('div.row:nth-child(1) label', 'Access rights') + ->assertSeeIn('div.row:nth-child(1) #acl-hint', 'permissions') + ->assertSeeIn('button[type=submit]', 'Submit'); + }) + // Test the AclInput widget + ->with($aclInput, function (Browser $browser) { + $browser->assertAclValue([]) + ->addAclEntry('anyone, read-only') + ->addAclEntry('test, read-write') + ->addAclEntry('john@kolab.org, full') + ->assertAclValue([ + 'anyone, read-only', + 'test, read-write', + 'john@kolab.org, full', + ]); + }) + // Test error handling + ->click('@settings button[type=submit]') + ->with($aclInput, function (Browser $browser) { + $browser->assertFormError(2, 'The specified email address is invalid.'); + }) + ->assertToast(Toast::TYPE_ERROR, 'Form validation error') + // Test successful update + ->with($aclInput, function (Browser $browser) { + $browser->removeAclEntry(2) + ->assertAclValue([ + 'anyone, read-only', + 'john@kolab.org, full', + ]) + ->updateAclEntry(2, 'jack@kolab.org, read-write') + ->assertAclValue([ + 'anyone, read-only', + 'jack@kolab.org, read-write', + ]); + }) + ->click('@settings button[type=submit]') + ->assertToast(Toast::TYPE_SUCCESS, 'Shared folder settings updated successfully.') + ->assertMissing('.invalid-feedback') + // Refresh the page and check if everything was saved + ->refresh() + ->on(new SharedFolderInfo()) + ->click('@nav #tab-settings') + ->with($aclInput, function (Browser $browser) { + $browser->assertAclValue([ + 'anyone, read-only', + 'jack@kolab.org, read-write', + ]); + }); + }); + } +} diff --git a/src/tests/Browser/UsersTest.php b/src/tests/Browser/UsersTest.php --- a/src/tests/Browser/UsersTest.php +++ b/src/tests/Browser/UsersTest.php @@ -711,7 +711,7 @@ $browser->visit('/user/' . $john->id) ->on(new UserInfo()) ->with('@skus', function ($browser) { - $browser->assertElementsCount('tbody tr', 9) + $browser->assertElementsCount('tbody tr', 10) // Meet SKU ->assertSeeIn('tbody tr:nth-child(6) td.name', 'Voice & Video Conferencing (public beta)') ->assertSeeIn('tr:nth-child(6) td.price', '0,00 CHF/month') @@ -739,13 +739,22 @@ 'tbody tr:nth-child(8) td.buttons button', 'Access to calendaring resources' ) - // Distlist SKU - ->assertSeeIn('tbody tr:nth-child(9) td.name', 'Distribution lists') + // Shared folders SKU + ->assertSeeIn('tbody tr:nth-child(9) td.name', 'Shared folders') ->assertSeeIn('tr:nth-child(9) td.price', '0,00 CHF/month') ->assertNotChecked('tbody tr:nth-child(9) td.selection input') ->assertEnabled('tbody tr:nth-child(9) td.selection input') ->assertTip( 'tbody tr:nth-child(9) td.buttons button', + 'Access to shared folders' + ) + // Distlist SKU + ->assertSeeIn('tbody tr:nth-child(10) td.name', 'Distribution lists') + ->assertSeeIn('tr:nth-child(10) td.price', '0,00 CHF/month') + ->assertNotChecked('tbody tr:nth-child(10) td.selection input') + ->assertEnabled('tbody tr:nth-child(10) td.selection input') + ->assertTip( + 'tbody tr:nth-child(10) td.buttons button', 'Access to mail distribution lists' ) // Check Distlist, Uncheck Beta, expect Distlist unchecked diff --git a/src/tests/Feature/Backends/IMAPTest.php b/src/tests/Feature/Backends/IMAPTest.php --- a/src/tests/Feature/Backends/IMAPTest.php +++ b/src/tests/Feature/Backends/IMAPTest.php @@ -14,25 +14,12 @@ */ public function testVerifyAccountExisting(): void { + // existing user $result = IMAP::verifyAccount('john@kolab.org'); + $this->assertTrue($result); - // TODO: Mocking rcube_imap_generic is not that nice, - // Find a way to be sure some testing account has folders - // initialized, and some other not, so we can make assertions - // on the verifyAccount() result - - $this->markTestIncomplete(); - } - - /** - * Test verifying IMAP account existence (non-existing account) - * - * @group imap - */ - public function testVerifyAccountNonExisting(): void - { + // non-existing user $this->expectException(\Exception::class); - IMAP::verifyAccount('non-existing@domain.tld'); } @@ -43,10 +30,12 @@ */ public function testVerifySharedFolder(): void { + // non-existing $result = IMAP::verifySharedFolder('shared/Resources/UnknownResource@kolab.org'); $this->assertFalse($result); - // TODO: Test with an existing shared folder - $this->markTestIncomplete(); + // existing + $result = IMAP::verifySharedFolder('shared/Calendar@kolab.org'); + $this->assertTrue($result); } } diff --git a/src/tests/Feature/Backends/LDAPTest.php b/src/tests/Feature/Backends/LDAPTest.php --- a/src/tests/Feature/Backends/LDAPTest.php +++ b/src/tests/Feature/Backends/LDAPTest.php @@ -7,6 +7,7 @@ use App\Group; use App\Entitlement; use App\Resource; +use App\SharedFolder; use App\User; use Illuminate\Support\Facades\Queue; use Tests\TestCase; @@ -30,6 +31,7 @@ $this->deleteTestDomain('testldap.com'); $this->deleteTestGroup('group@kolab.org'); $this->deleteTestResource('test-resource@kolab.org'); + $this->deleteTestSharedFolder('test-folder@kolab.org'); // TODO: Remove group members } @@ -44,6 +46,7 @@ $this->deleteTestDomain('testldap.com'); $this->deleteTestGroup('group@kolab.org'); $this->deleteTestResource('test-resource@kolab.org'); + $this->deleteTestSharedFolder('test-folder@kolab.org'); // TODO: Remove group members parent::tearDown(); @@ -277,6 +280,71 @@ $this->assertSame(null, LDAP::getResource($resource->email)); } + /** + * Test creating/updating/deleting a shared folder record + * + * @group ldap + */ + public function testSharedFolder(): void + { + Queue::fake(); + + $root_dn = \config('ldap.hosted.root_dn'); + $folder = $this->getTestSharedFolder('test-folder@kolab.org', ['type' => 'event']); + $folder->setSetting('acl', null); + + // Make sure the shared folder does not exist + // LDAP::deleteSharedFolder($folder); + + // Create the shared folder + LDAP::createSharedFolder($folder); + + $ldap_folder = LDAP::getSharedFolder($folder->email); + + $expected = [ + 'cn' => 'test-folder', + 'dn' => 'cn=test-folder,ou=Shared Folders,ou=kolab.org,' . $root_dn, + 'mail' => $folder->email, + 'objectclass' => [ + 'top', + 'kolabsharedfolder', + 'mailrecipient', + ], + 'kolabfoldertype' => 'event', + 'kolabtargetfolder' => 'shared/test-folder@kolab.org', + 'acl' => null, + ]; + + foreach ($expected as $attr => $value) { + $ldap_value = isset($ldap_folder[$attr]) ? $ldap_folder[$attr] : null; + $this->assertEquals($value, $ldap_value, "Shared folder $attr attribute"); + } + + // Update folder name and acl + $folder->name = 'Te(=ść)1'; + $folder->save(); + $folder->setSetting('acl', '["john@kolab.org, read-write","anyone, read-only"]'); + + LDAP::updateSharedFolder($folder); + + $expected['kolabtargetfolder'] = 'shared/Te(=ść)1@kolab.org'; + $expected['acl'] = ['john@kolab.org, read-write', 'anyone, read-only']; + $expected['dn'] = 'cn=Te(\\3dść)1,ou=Shared Folders,ou=kolab.org,' . $root_dn; + $expected['cn'] = 'Te(=ść)1'; + + $ldap_folder = LDAP::getSharedFolder($folder->email); + + foreach ($expected as $attr => $value) { + $ldap_value = isset($ldap_folder[$attr]) ? $ldap_folder[$attr] : null; + $this->assertEquals($value, $ldap_value, "Shared folder $attr attribute"); + } + + // Delete the resource + LDAP::deleteSharedFolder($folder); + + $this->assertSame(null, LDAP::getSharedFolder($folder->email)); + } + /** * Test creating/editing/deleting a user record * @@ -402,7 +470,7 @@ $resource = new Resource([ 'email' => 'test-non-existing-ldap@non-existing.org', 'name' => 'Test', - 'status' => User::STATUS_ACTIVE, + 'status' => Resource::STATUS_ACTIVE, ]); LDAP::createResource($resource); @@ -427,6 +495,25 @@ LDAP::createGroup($group); } + /** + * Test handling errors on a shared folder creation + * + * @group ldap + */ + public function testCreateSharedFolderException(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessageMatches('/Failed to create shared folder/'); + + $folder = new SharedFolder([ + 'email' => 'test-non-existing-ldap@non-existing.org', + 'name' => 'Test', + 'status' => SharedFolder::STATUS_ACTIVE, + ]); + + LDAP::createSharedFolder($folder); + } + /** * Test handling errors on user creation * @@ -500,6 +587,23 @@ LDAP::updateResource($resource); } + /** + * Test handling update of a non-existing shared folder + * + * @group ldap + */ + public function testUpdateSharedFolderException(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessageMatches('/folder not found/'); + + $folder = new SharedFolder([ + 'email' => 'test-folder-unknown@kolab.org', + ]); + + LDAP::updateSharedFolder($folder); + } + /** * Test handling update of a non-existing user * diff --git a/src/tests/Feature/Controller/Admin/SharedFoldersTest.php b/src/tests/Feature/Controller/Admin/SharedFoldersTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Controller/Admin/SharedFoldersTest.php @@ -0,0 +1,149 @@ +getTestUser('john@kolab.org'); + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $folder = $this->getTestSharedFolder('folder-event@kolab.org'); + + // Non-admin user + $response = $this->actingAs($user)->get("api/v4/shared-folders"); + $response->assertStatus(403); + + // Search with no search criteria + $response = $this->actingAs($admin)->get("api/v4/shared-folders"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(0, $json['count']); + $this->assertSame([], $json['list']); + $this->assertSame("0 shared folders have been found.", $json['message']); + + // Search with no matches expected + $response = $this->actingAs($admin)->get("api/v4/shared-folders?search=john@kolab.org"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(0, $json['count']); + $this->assertSame([], $json['list']); + + // Search by email + $response = $this->actingAs($admin)->get("api/v4/shared-folders?search={$folder->email}"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(1, $json['count']); + $this->assertCount(1, $json['list']); + $this->assertSame($folder->email, $json['list'][0]['email']); + + // Search by owner + $response = $this->actingAs($admin)->get("api/v4/shared-folders?owner={$user->id}"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(2, $json['count']); + $this->assertCount(2, $json['list']); + $this->assertSame("2 shared folders have been found.", $json['message']); + $this->assertSame($folder->email, $json['list'][0]['email']); + $this->assertSame($folder->name, $json['list'][0]['name']); + + // Search by owner (Ned is a controller on John's wallets, + // here we expect only folders assigned to Ned's wallet(s)) + $ned = $this->getTestUser('ned@kolab.org'); + $response = $this->actingAs($admin)->get("api/v4/shared-folders?owner={$ned->id}"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(0, $json['count']); + $this->assertCount(0, $json['list']); + } + + /** + * Test fetching shared folder info (GET /api/v4/shared-folders/) + */ + public function testShow(): void + { + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $user = $this->getTestUser('john@kolab.org'); + $folder = $this->getTestSharedFolder('folder-event@kolab.org'); + + // Only admins can access it + $response = $this->actingAs($user)->get("api/v4/shared-folders/{$folder->id}"); + $response->assertStatus(403); + + $response = $this->actingAs($admin)->get("api/v4/shared-folders/{$folder->id}"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertEquals($folder->id, $json['id']); + $this->assertEquals($folder->email, $json['email']); + $this->assertEquals($folder->name, $json['name']); + $this->assertEquals($folder->type, $json['type']); + } + + /** + * Test fetching shared folder status (GET /api/v4/shared-folders//status) + */ + public function testStatus(): void + { + Queue::fake(); // disable jobs + + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $folder = $this->getTestSharedFolder('folder-event@kolab.org'); + + // This end-point does not exist for admins + $response = $this->actingAs($admin)->get("/api/v4/shared-folders/{$folder->id}/status"); + $response->assertStatus(404); + } + + /** + * Test shared folder creating (POST /api/v4/shared-folders) + */ + public function testStore(): void + { + $user = $this->getTestUser('john@kolab.org'); + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + + // Test unauthorized access to admin API + $response = $this->actingAs($user)->post("/api/v4/shared-folders", []); + $response->assertStatus(403); + + // Admin can't create shared folders + $response = $this->actingAs($admin)->post("/api/v4/shared-folders", []); + $response->assertStatus(404); + } +} diff --git a/src/tests/Feature/Controller/Admin/SkusTest.php b/src/tests/Feature/Controller/Admin/SkusTest.php --- a/src/tests/Feature/Controller/Admin/SkusTest.php +++ b/src/tests/Feature/Controller/Admin/SkusTest.php @@ -82,7 +82,7 @@ $json = $response->json(); - $this->assertCount(11, $json); + $this->assertCount(13, $json); $this->assertSame(100, $json[0]['prio']); $this->assertSame($sku->id, $json[0]['id']); diff --git a/src/tests/Feature/Controller/Admin/UsersTest.php b/src/tests/Feature/Controller/Admin/UsersTest.php --- a/src/tests/Feature/Controller/Admin/UsersTest.php +++ b/src/tests/Feature/Controller/Admin/UsersTest.php @@ -194,6 +194,17 @@ $this->assertSame($user->id, $json['list'][0]['id']); $this->assertSame($user->email, $json['list'][0]['email']); + // Search by shared folder email + $response = $this->actingAs($admin)->get("api/v4/users?search=folder-event@kolab.org"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(1, $json['count']); + $this->assertCount(1, $json['list']); + $this->assertSame($user->id, $json['list'][0]['id']); + $this->assertSame($user->email, $json['list'][0]['email']); + // Deleted users/domains $domain = $this->getTestDomain('testsearch.com', ['type' => \App\Domain::TYPE_EXTERNAL]); $user = $this->getTestUser('test@testsearch.com'); diff --git a/src/tests/Feature/Controller/Reseller/SharedFoldersTest.php b/src/tests/Feature/Controller/Reseller/SharedFoldersTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Controller/Reseller/SharedFoldersTest.php @@ -0,0 +1,180 @@ +getTestUser('john@kolab.org'); + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); + $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local'); + $folder = $this->getTestSharedFolder('folder-event@kolab.org'); + + // Non-admin user + $response = $this->actingAs($user)->get("api/v4/shared-folders"); + $response->assertStatus(403); + + // Admin user + $response = $this->actingAs($admin)->get("api/v4/shared-folders"); + $response->assertStatus(403); + + // Search with no search criteria + $response = $this->actingAs($reseller1)->get("api/v4/shared-folders"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(0, $json['count']); + $this->assertSame([], $json['list']); + + // Search with no matches expected + $response = $this->actingAs($reseller1)->get("api/v4/shared-folders?search=john@kolab.org"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(0, $json['count']); + $this->assertSame([], $json['list']); + + // Search by email + $response = $this->actingAs($reseller1)->get("api/v4/shared-folders?search={$folder->email}"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(1, $json['count']); + $this->assertCount(1, $json['list']); + $this->assertSame($folder->email, $json['list'][0]['email']); + + // Search by owner + $response = $this->actingAs($reseller1)->get("api/v4/shared-folders?owner={$user->id}"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(2, $json['count']); + $this->assertCount(2, $json['list']); + $this->assertSame($folder->email, $json['list'][0]['email']); + $this->assertSame($folder->name, $json['list'][0]['name']); + + // Search by owner (Ned is a controller on John's wallets, + // here we expect only folders assigned to Ned's wallet(s)) + $ned = $this->getTestUser('ned@kolab.org'); + $response = $this->actingAs($reseller1)->get("api/v4/shared-folders?owner={$ned->id}"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(0, $json['count']); + $this->assertCount(0, $json['list']); + + $response = $this->actingAs($reseller2)->get("api/v4/shared-folders?search={$folder->email}"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(0, $json['count']); + $this->assertSame([], $json['list']); + + $response = $this->actingAs($reseller2)->get("api/v4/shared-folders?owner={$user->id}"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(0, $json['count']); + $this->assertSame([], $json['list']); + } + + /** + * Test fetching shared folder info (GET /api/v4/shared-folders/) + */ + public function testShow(): void + { + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $user = $this->getTestUser('john@kolab.org'); + $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); + $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local'); + $folder = $this->getTestSharedFolder('folder-event@kolab.org'); + + // Only resellers can access it + $response = $this->actingAs($user)->get("api/v4/shared-folders/{$folder->id}"); + $response->assertStatus(403); + + $response = $this->actingAs($admin)->get("api/v4/shared-folders/{$folder->id}"); + $response->assertStatus(403); + + $response = $this->actingAs($reseller2)->get("api/v4/shared-folders/{$folder->id}"); + $response->assertStatus(404); + + $response = $this->actingAs($reseller1)->get("api/v4/shared-folders/{$folder->id}"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertEquals($folder->id, $json['id']); + $this->assertEquals($folder->email, $json['email']); + $this->assertEquals($folder->name, $json['name']); + } + + /** + * Test fetching shared folder status (GET /api/v4/shared-folders//status) + */ + public function testStatus(): void + { + Queue::fake(); // disable jobs + + $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); + $folder = $this->getTestSharedFolder('folder-event@kolab.org'); + + // This end-point does not exist for folders + $response = $this->actingAs($reseller1)->get("/api/v4/shared-folders/{$folder->id}/status"); + $response->assertStatus(404); + } + + /** + * Test shared folder creating (POST /api/v4/shared-folders) + */ + public function testStore(): void + { + $user = $this->getTestUser('john@kolab.org'); + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); + + // Test unauthorized access to reseller API + $response = $this->actingAs($user)->post("/api/v4/shared-folders", []); + $response->assertStatus(403); + + // Reseller or admin can't create folders + $response = $this->actingAs($admin)->post("/api/v4/shared-folders", []); + $response->assertStatus(403); + + $response = $this->actingAs($reseller1)->post("/api/v4/shared-folders", []); + $response->assertStatus(404); + } +} diff --git a/src/tests/Feature/Controller/Reseller/SkusTest.php b/src/tests/Feature/Controller/Reseller/SkusTest.php --- a/src/tests/Feature/Controller/Reseller/SkusTest.php +++ b/src/tests/Feature/Controller/Reseller/SkusTest.php @@ -99,7 +99,7 @@ $json = $response->json(); - $this->assertCount(11, $json); + $this->assertCount(13, $json); $this->assertSame(100, $json[0]['prio']); $this->assertSame($sku->id, $json[0]['id']); diff --git a/src/tests/Feature/Controller/SharedFoldersTest.php b/src/tests/Feature/Controller/SharedFoldersTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Controller/SharedFoldersTest.php @@ -0,0 +1,488 @@ +deleteTestSharedFolder('folder-test@kolab.org'); + SharedFolder::where('name', 'Test Folder')->delete(); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestSharedFolder('folder-test@kolab.org'); + SharedFolder::where('name', 'Test Folder')->delete(); + + parent::tearDown(); + } + + /** + * Test resource deleting (DELETE /api/v4/resources/) + */ + public function testDestroy(): void + { + $john = $this->getTestUser('john@kolab.org'); + $jack = $this->getTestUser('jack@kolab.org'); + $folder = $this->getTestSharedFolder('folder-test@kolab.org'); + $folder->assignToWallet($john->wallets->first()); + + // Test unauth access + $response = $this->delete("api/v4/shared-folders/{$folder->id}"); + $response->assertStatus(401); + + // Test non-existing folder + $response = $this->actingAs($john)->delete("api/v4/shared-folders/abc"); + $response->assertStatus(404); + + // Test access to other user's folder + $response = $this->actingAs($jack)->delete("api/v4/shared-folders/{$folder->id}"); + $response->assertStatus(403); + + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertSame("Access denied", $json['message']); + $this->assertCount(2, $json); + + // Test removing a folder + $response = $this->actingAs($john)->delete("api/v4/shared-folders/{$folder->id}"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertEquals('success', $json['status']); + $this->assertEquals("Shared folder deleted successfully.", $json['message']); + } + + /** + * Test shared folders listing (GET /api/v4/shared-folders) + */ + public function testIndex(): void + { + $jack = $this->getTestUser('jack@kolab.org'); + $john = $this->getTestUser('john@kolab.org'); + $ned = $this->getTestUser('ned@kolab.org'); + + // Test unauth access + $response = $this->get("api/v4/shared-folders"); + $response->assertStatus(401); + + // Test a user with no shared folders + $response = $this->actingAs($jack)->get("/api/v4/shared-folders"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertCount(0, $json); + + // Test a user with two shared folders + $response = $this->actingAs($john)->get("/api/v4/shared-folders"); + $response->assertStatus(200); + + $json = $response->json(); + + $folder = SharedFolder::where('name', 'Calendar')->first(); + + $this->assertCount(2, $json); + $this->assertSame($folder->id, $json[0]['id']); + $this->assertSame($folder->email, $json[0]['email']); + $this->assertSame($folder->name, $json[0]['name']); + $this->assertSame($folder->type, $json[0]['type']); + $this->assertArrayHasKey('isDeleted', $json[0]); + $this->assertArrayHasKey('isActive', $json[0]); + $this->assertArrayHasKey('isLdapReady', $json[0]); + $this->assertArrayHasKey('isImapReady', $json[0]); + + // Test that another wallet controller has access to shared folders + $response = $this->actingAs($ned)->get("/api/v4/shared-folders"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertCount(2, $json); + $this->assertSame($folder->email, $json[0]['email']); + } + + /** + * Test shared folder config update (POST /api/v4/shared-folders//config) + */ + public function testSetConfig(): void + { + $john = $this->getTestUser('john@kolab.org'); + $jack = $this->getTestUser('jack@kolab.org'); + $folder = $this->getTestSharedFolder('folder-test@kolab.org'); + $folder->assignToWallet($john->wallets->first()); + + // Test unknown resource id + $post = ['acl' => ['john@kolab.org, full']]; + $response = $this->actingAs($john)->post("/api/v4/shared-folders/123/config", $post); + $json = $response->json(); + + $response->assertStatus(404); + + // Test access by user not being a wallet controller + $response = $this->actingAs($jack)->post("/api/v4/shared-folders/{$folder->id}/config", $post); + $json = $response->json(); + + $response->assertStatus(403); + + $this->assertSame('error', $json['status']); + $this->assertSame("Access denied", $json['message']); + $this->assertCount(2, $json); + + // Test some invalid data + $post = ['test' => 1]; + $response = $this->actingAs($john)->post("/api/v4/shared-folders/{$folder->id}/config", $post); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertCount(2, $json); + $this->assertCount(1, $json['errors']); + $this->assertSame('The requested configuration parameter is not supported.', $json['errors']['test']); + + $folder->refresh(); + + $this->assertNull($folder->getSetting('test')); + $this->assertNull($folder->getSetting('acl')); + + // Test some valid data + $post = ['acl' => ['john@kolab.org, full']]; + $response = $this->actingAs($john)->post("/api/v4/shared-folders/{$folder->id}/config", $post); + + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertCount(2, $json); + $this->assertSame('success', $json['status']); + $this->assertSame("Shared folder settings updated successfully.", $json['message']); + + $this->assertSame(['acl' => $post['acl']], $folder->fresh()->getConfig()); + + // Test input validation + $post = ['acl' => ['john@kolab.org, full', 'test, full']]; + $response = $this->actingAs($john)->post("/api/v4/shared-folders/{$folder->id}/config", $post); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertCount(2, $json); + $this->assertSame('error', $json['status']); + $this->assertCount(1, $json['errors']); + $this->assertCount(1, $json['errors']['acl']); + $this->assertSame( + "The specified email address is invalid.", + $json['errors']['acl'][1] + ); + + $this->assertSame(['acl' => ['john@kolab.org, full']], $folder->fresh()->getConfig()); + } + + /** + * Test fetching shared folder data/profile (GET /api/v4/shared-folders/) + */ + public function testShow(): void + { + $jack = $this->getTestUser('jack@kolab.org'); + $john = $this->getTestUser('john@kolab.org'); + $ned = $this->getTestUser('ned@kolab.org'); + + $folder = $this->getTestSharedFolder('folder-test@kolab.org'); + $folder->assignToWallet($john->wallets->first()); + $folder->setSetting('acl', '["anyone, full"]'); + + // Test unauthenticated access + $response = $this->get("/api/v4/shared-folders/{$folder->id}"); + $response->assertStatus(401); + + // Test unauthorized access to a shared folder of another user + $response = $this->actingAs($jack)->get("/api/v4/shared-folders/{$folder->id}"); + $response->assertStatus(403); + + // John: Account owner - non-existing folder + $response = $this->actingAs($john)->get("/api/v4/shared-folders/abc"); + $response->assertStatus(404); + + // John: Account owner + $response = $this->actingAs($john)->get("/api/v4/shared-folders/{$folder->id}"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame($folder->id, $json['id']); + $this->assertSame($folder->email, $json['email']); + $this->assertSame($folder->name, $json['name']); + $this->assertSame($folder->type, $json['type']); + $this->assertTrue(!empty($json['statusInfo'])); + $this->assertArrayHasKey('isDeleted', $json); + $this->assertArrayHasKey('isActive', $json); + $this->assertArrayHasKey('isLdapReady', $json); + $this->assertArrayHasKey('isImapReady', $json); + $this->assertSame(['acl' => ['anyone, full']], $json['config']); + } + + /** + * Test fetching a shared folder status (GET /api/v4/shared-folders//status) + * and forcing setup process update (?refresh=1) + */ + public function testStatus(): void + { + Queue::fake(); + + $john = $this->getTestUser('john@kolab.org'); + $jack = $this->getTestUser('jack@kolab.org'); + + $folder = $this->getTestSharedFolder('folder-test@kolab.org'); + $folder->assignToWallet($john->wallets->first()); + + // Test unauthorized access + $response = $this->get("/api/v4/shared-folders/abc/status"); + $response->assertStatus(401); + + // Test unauthorized access + $response = $this->actingAs($jack)->get("/api/v4/shared-folders/{$folder->id}/status"); + $response->assertStatus(403); + + $folder->status = SharedFolder::STATUS_NEW | SharedFolder::STATUS_ACTIVE; + $folder->save(); + + // Get resource status + $response = $this->actingAs($john)->get("/api/v4/shared-folders/{$folder->id}/status"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertFalse($json['isLdapReady']); + $this->assertFalse($json['isImapReady']); + $this->assertFalse($json['isReady']); + $this->assertFalse($json['isDeleted']); + $this->assertTrue($json['isActive']); + $this->assertCount(7, $json['process']); + $this->assertSame('shared-folder-new', $json['process'][0]['label']); + $this->assertSame(true, $json['process'][0]['state']); + $this->assertSame('shared-folder-ldap-ready', $json['process'][1]['label']); + $this->assertSame(false, $json['process'][1]['state']); + $this->assertTrue(empty($json['status'])); + $this->assertTrue(empty($json['message'])); + $this->assertSame('running', $json['processState']); + + // Make sure the domain is confirmed (other test might unset that status) + $domain = $this->getTestDomain('kolab.org'); + $domain->status |= \App\Domain::STATUS_CONFIRMED; + $domain->save(); + $folder->status |= SharedFolder::STATUS_IMAP_READY; + $folder->save(); + + // Now "reboot" the process and get the folder status + $response = $this->actingAs($john)->get("/api/v4/shared-folders/{$folder->id}/status?refresh=1"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertTrue($json['isLdapReady']); + $this->assertTrue($json['isImapReady']); + $this->assertTrue($json['isReady']); + $this->assertCount(7, $json['process']); + $this->assertSame('shared-folder-ldap-ready', $json['process'][1]['label']); + $this->assertSame(true, $json['process'][1]['state']); + $this->assertSame('shared-folder-imap-ready', $json['process'][2]['label']); + $this->assertSame(true, $json['process'][2]['state']); + $this->assertSame('success', $json['status']); + $this->assertSame('Setup process finished successfully.', $json['message']); + $this->assertSame('done', $json['processState']); + + // Test a case when a domain is not ready + $domain->status ^= \App\Domain::STATUS_CONFIRMED; + $domain->save(); + + $response = $this->actingAs($john)->get("/api/v4/shared-folders/{$folder->id}/status?refresh=1"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertTrue($json['isLdapReady']); + $this->assertTrue($json['isReady']); + $this->assertCount(7, $json['process']); + $this->assertSame('shared-folder-ldap-ready', $json['process'][1]['label']); + $this->assertSame(true, $json['process'][1]['state']); + $this->assertSame('success', $json['status']); + $this->assertSame('Setup process finished successfully.', $json['message']); + } + + /** + * Test SharedFoldersController::statusInfo() + */ + public function testStatusInfo(): void + { + $john = $this->getTestUser('john@kolab.org'); + $folder = $this->getTestSharedFolder('folder-test@kolab.org'); + $folder->assignToWallet($john->wallets->first()); + $folder->status = SharedFolder::STATUS_NEW | SharedFolder::STATUS_ACTIVE; + $folder->save(); + $domain = $this->getTestDomain('kolab.org'); + $domain->status |= \App\Domain::STATUS_CONFIRMED; + $domain->save(); + + $result = SharedFoldersController::statusInfo($folder); + + $this->assertFalse($result['isReady']); + $this->assertCount(7, $result['process']); + $this->assertSame('shared-folder-new', $result['process'][0]['label']); + $this->assertSame(true, $result['process'][0]['state']); + $this->assertSame('shared-folder-ldap-ready', $result['process'][1]['label']); + $this->assertSame(false, $result['process'][1]['state']); + $this->assertSame('running', $result['processState']); + + $folder->created_at = Carbon::now()->subSeconds(181); + $folder->save(); + + $result = SharedFoldersController::statusInfo($folder); + + $this->assertSame('failed', $result['processState']); + + $folder->status |= SharedFolder::STATUS_LDAP_READY | SharedFolder::STATUS_IMAP_READY; + $folder->save(); + + $result = SharedFoldersController::statusInfo($folder); + + $this->assertTrue($result['isReady']); + $this->assertCount(7, $result['process']); + $this->assertSame('shared-folder-new', $result['process'][0]['label']); + $this->assertSame(true, $result['process'][0]['state']); + $this->assertSame('shared-folder-ldap-ready', $result['process'][1]['label']); + $this->assertSame(true, $result['process'][1]['state']); + $this->assertSame('shared-folder-ldap-ready', $result['process'][1]['label']); + $this->assertSame(true, $result['process'][1]['state']); + $this->assertSame('done', $result['processState']); + } + + /** + * Test shared folder creation (POST /api/v4/shared-folders) + */ + public function testStore(): void + { + Queue::fake(); + + $jack = $this->getTestUser('jack@kolab.org'); + $john = $this->getTestUser('john@kolab.org'); + + // Test unauth request + $response = $this->post("/api/v4/shared-folders", []); + $response->assertStatus(401); + + // Test non-controller user + $response = $this->actingAs($jack)->post("/api/v4/shared-folders", []); + $response->assertStatus(403); + + // Test empty request + $response = $this->actingAs($john)->post("/api/v4/shared-folders", []); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertSame("The name field is required.", $json['errors']['name'][0]); + $this->assertSame("The type field is required.", $json['errors']['type'][0]); + $this->assertCount(2, $json); + $this->assertCount(2, $json['errors']); + + // Test too long name + $post = ['domain' => 'kolab.org', 'name' => str_repeat('A', 192), 'type' => 'unknown']; + $response = $this->actingAs($john)->post("/api/v4/shared-folders", $post); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertCount(2, $json); + $this->assertSame(["The name may not be greater than 191 characters."], $json['errors']['name']); + $this->assertSame(["The specified type is invalid."], $json['errors']['type']); + $this->assertCount(2, $json['errors']); + + // Test successful folder creation + $post['name'] = 'Test Folder'; + $post['type'] = 'event'; + $response = $this->actingAs($john)->post("/api/v4/shared-folders", $post); + $json = $response->json(); + + $response->assertStatus(200); + + $this->assertSame('success', $json['status']); + $this->assertSame("Shared folder created successfully.", $json['message']); + $this->assertCount(2, $json); + + $folder = SharedFolder::where('name', $post['name'])->first(); + $this->assertInstanceOf(SharedFolder::class, $folder); + $this->assertSame($post['type'], $folder->type); + $this->assertTrue($john->sharedFolders()->get()->contains($folder)); + + // Shared folder name must be unique within a domain + $post['type'] = 'mail'; + $response = $this->actingAs($john)->post("/api/v4/shared-folders", $post); + $response->assertStatus(422); + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertCount(2, $json); + $this->assertCount(1, $json['errors']); + $this->assertSame("The specified name is not available.", $json['errors']['name'][0]); + } + + /** + * Test shared folder update (PUT /api/v4/shared-folders/getTestUser('jack@kolab.org'); + $john = $this->getTestUser('john@kolab.org'); + $ned = $this->getTestUser('ned@kolab.org'); + + $folder = $this->getTestSharedFolder('folder-test@kolab.org'); + $folder->assignToWallet($john->wallets->first()); + + // Test unauthorized update + $response = $this->get("/api/v4/shared-folders/{$folder->id}", []); + $response->assertStatus(401); + + // Test unauthorized update + $response = $this->actingAs($jack)->get("/api/v4/shared-folders/{$folder->id}", []); + $response->assertStatus(403); + + // Name change + $post = [ + 'name' => 'Test Res', + ]; + + $response = $this->actingAs($john)->put("/api/v4/shared-folders/{$folder->id}", $post); + $json = $response->json(); + + $response->assertStatus(200); + + $this->assertSame('success', $json['status']); + $this->assertSame("Shared folder updated successfully.", $json['message']); + $this->assertCount(2, $json); + + $folder->refresh(); + $this->assertSame($post['name'], $folder->name); + } +} diff --git a/src/tests/Feature/Controller/SkusTest.php b/src/tests/Feature/Controller/SkusTest.php --- a/src/tests/Feature/Controller/SkusTest.php +++ b/src/tests/Feature/Controller/SkusTest.php @@ -105,7 +105,7 @@ $json = $response->json(); - $this->assertCount(11, $json); + $this->assertCount(13, $json); $this->assertSame(100, $json[0]['prio']); $this->assertSame($sku->id, $json[0]['id']); @@ -215,7 +215,7 @@ $json = $response->json(); - $this->assertCount(9, $json); + $this->assertCount(10, $json); $this->assertSkuElement('beta', $json[6], [ 'prio' => 10, @@ -234,7 +234,16 @@ 'required' => ['beta'], ]); - $this->assertSkuElement('distlist', $json[8], [ + $this->assertSkuElement('beta-shared-folders', $json[8], [ + 'prio' => 10, + 'type' => 'user', + 'handler' => 'sharedfolders', + 'enabled' => false, + 'readonly' => false, + 'required' => ['beta'], + ]); + + $this->assertSkuElement('distlist', $json[9], [ 'prio' => 10, 'type' => 'user', 'handler' => 'distlist', diff --git a/src/tests/Feature/DomainTest.php b/src/tests/Feature/DomainTest.php --- a/src/tests/Feature/DomainTest.php +++ b/src/tests/Feature/DomainTest.php @@ -239,6 +239,7 @@ $this->deleteTestUser('user@gmail.com'); $this->deleteTestGroup('group@gmail.com'); $this->deleteTestResource('resource@gmail.com'); + $this->deleteTestSharedFolder('folder@gmail.com'); // Empty domain $domain = $this->getTestDomain('gmail.com', [ @@ -259,6 +260,9 @@ $this->getTestResource('resource@gmail.com'); $this->assertFalse($domain->isEmpty()); $this->deleteTestResource('resource@gmail.com'); + $this->getTestSharedFolder('folder@gmail.com'); + $this->assertFalse($domain->isEmpty()); + $this->deleteTestSharedFolder('folder@gmail.com'); // TODO: Test with an existing alias, but not other objects in a domain diff --git a/src/tests/Feature/Jobs/DomainCreateTest.php b/src/tests/Feature/Jobs/Domain/CreateTest.php rename from src/tests/Feature/Jobs/DomainCreateTest.php rename to src/tests/Feature/Jobs/Domain/CreateTest.php --- a/src/tests/Feature/Jobs/DomainCreateTest.php +++ b/src/tests/Feature/Jobs/Domain/CreateTest.php @@ -1,12 +1,12 @@ deleteTestResource('resource-test@' . \config('app.domain')); + } + + public function tearDown(): void + { + $this->deleteTestResource('resource-test@' . \config('app.domain')); + + parent::tearDown(); + } + + /** + * Test job handle + * + * @group ldap + */ + public function testHandle(): void + { + Queue::fake(); + + // Test unknown resource + $job = new \App\Jobs\Resource\CreateJob(123); + $job->handle(); + + $this->assertTrue($job->isReleased()); + $this->assertFalse($job->hasFailed()); + + $resource = $this->getTestResource('resource-test@' . \config('app.domain')); + + $this->assertFalse($resource->isLdapReady()); + + // Test resource creation + $job = new \App\Jobs\Resource\CreateJob($resource->id); + $job->handle(); + + $this->assertTrue($resource->fresh()->isLdapReady()); + $this->assertFalse($job->hasFailed()); + + // Test job failures + $job = new \App\Jobs\Resource\CreateJob($resource->id); + $job->handle(); + + $this->assertTrue($job->hasFailed()); + $this->assertSame("Resource {$resource->id} is already marked as ldap-ready.", $job->failureMessage); + + $resource->status |= Resource::STATUS_DELETED; + $resource->save(); + + $job = new \App\Jobs\Resource\CreateJob($resource->id); + $job->handle(); + + $this->assertTrue($job->hasFailed()); + $this->assertSame("Resource {$resource->id} is marked as deleted.", $job->failureMessage); + + $resource->status ^= Resource::STATUS_DELETED; + $resource->save(); + $resource->delete(); + + $job = new \App\Jobs\Resource\CreateJob($resource->id); + $job->handle(); + + $this->assertTrue($job->hasFailed()); + $this->assertSame("Resource {$resource->id} is actually deleted.", $job->failureMessage); + + // TODO: Test failures on domain sanity checks + } +} diff --git a/src/tests/Feature/Jobs/Resource/DeleteTest.php b/src/tests/Feature/Jobs/Resource/DeleteTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Jobs/Resource/DeleteTest.php @@ -0,0 +1,76 @@ +deleteTestResource('resource-test@' . \config('app.domain')); + } + + public function tearDown(): void + { + $this->deleteTestResource('resource-test@' . \config('app.domain')); + + parent::tearDown(); + } + + /** + * Test job handle + * + * @group ldap + */ + public function testHandle(): void + { + Queue::fake(); + + // Test non-existing resource ID + $job = new \App\Jobs\Resource\DeleteJob(123); + $job->handle(); + + $this->assertTrue($job->hasFailed()); + $this->assertSame("Resource 123 could not be found in the database.", $job->failureMessage); + + $resource = $this->getTestResource('resource-test@' . \config('app.domain'), [ + 'status' => Resource::STATUS_NEW + ]); + + // create the resource first + $job = new \App\Jobs\Resource\CreateJob($resource->id); + $job->handle(); + + $resource->refresh(); + + $this->assertTrue($resource->isLdapReady()); + + // Test successful deletion + $resource->status |= Resource::STATUS_IMAP_READY; + $resource->save(); + + $job = new \App\Jobs\Resource\DeleteJob($resource->id); + $job->handle(); + + $resource->refresh(); + + $this->assertFalse($resource->isLdapReady()); + $this->assertFalse($resource->isImapReady()); + $this->assertTrue($resource->isDeleted()); + + // Test deleting already deleted resource + $job = new \App\Jobs\Resource\DeleteJob($resource->id); + $job->handle(); + + $this->assertTrue($job->hasFailed()); + $this->assertSame("Resource {$resource->id} is already marked as deleted.", $job->failureMessage); + } +} diff --git a/src/tests/Feature/Jobs/Resource/UpdateTest.php b/src/tests/Feature/Jobs/Resource/UpdateTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Jobs/Resource/UpdateTest.php @@ -0,0 +1,82 @@ +deleteTestResource('resource-test@' . \config('app.domain')); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestResource('resource-test@' . \config('app.domain')); + + parent::tearDown(); + } + + /** + * Test job handle + * + * @group ldap + */ + public function testHandle(): void + { + Queue::fake(); + + // Test non-existing resource ID + $job = new \App\Jobs\Resource\UpdateJob(123); + $job->handle(); + + $this->assertTrue($job->hasFailed()); + $this->assertSame("Resource 123 could not be found in the database.", $job->failureMessage); + + $resource = $this->getTestResource('resource-test@' . \config('app.domain')); + + // Create the resource in LDAP + $job = new \App\Jobs\Resource\CreateJob($resource->id); + $job->handle(); + + $resource->setConfig(['invitation_policy' => 'accept']); + + $job = new \App\Jobs\Resource\UpdateJob($resource->id); + $job->handle(); + + $ldap_resource = LDAP::getResource($resource->email); + + $this->assertSame('ACT_ACCEPT', $ldap_resource['kolabinvitationpolicy']); + + // Test that the job is being deleted if the resource is not ldap ready or is deleted + $resource->refresh(); + $resource->status = Resource::STATUS_NEW | Resource::STATUS_ACTIVE; + $resource->save(); + + $job = new \App\Jobs\Resource\UpdateJob($resource->id); + $job->handle(); + + $this->assertTrue($job->isDeleted()); + + $resource->status = Resource::STATUS_NEW | Resource::STATUS_ACTIVE + | Resource::STATUS_LDAP_READY | Resource::STATUS_DELETED; + $resource->save(); + + $job = new \App\Jobs\Resource\UpdateJob($resource->id); + $job->handle(); + + $this->assertTrue($job->isDeleted()); + } +} diff --git a/src/tests/Feature/Jobs/Resource/VerifyTest.php b/src/tests/Feature/Jobs/Resource/VerifyTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Jobs/Resource/VerifyTest.php @@ -0,0 +1,75 @@ +getTestResource('resource-test1@kolab.org'); + $resource->status |= Resource::STATUS_IMAP_READY; + $resource->save(); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $resource = $this->getTestResource('resource-test1@kolab.org'); + $resource->status |= Resource::STATUS_IMAP_READY; + $resource->save(); + + parent::tearDown(); + } + + /** + * Test job handle + * + * @group imap + */ + public function testHandle(): void + { + Queue::fake(); + + // Test non-existing resource ID + $job = new \App\Jobs\Resource\VerifyJob(123); + $job->handle(); + + $this->assertTrue($job->hasFailed()); + $this->assertSame("Resource 123 could not be found in the database.", $job->failureMessage); + + // Test existing resource + $resource = $this->getTestResource('resource-test1@kolab.org'); + + if ($resource->isImapReady()) { + $resource->status ^= Resource::STATUS_IMAP_READY; + $resource->save(); + } + + $this->assertFalse($resource->isImapReady()); + + for ($i = 0; $i < 10; $i++) { + $job = new \App\Jobs\Resource\VerifyJob($resource->id); + $job->handle(); + + if ($resource->fresh()->isImapReady()) { + $this->assertTrue(true); + return; + } + + sleep(1); + } + + $this->assertTrue(false, "Unable to verify the shared folder is set up in time"); + } +} diff --git a/src/tests/Feature/Jobs/SharedFolder/CreateTest.php b/src/tests/Feature/Jobs/SharedFolder/CreateTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Jobs/SharedFolder/CreateTest.php @@ -0,0 +1,83 @@ +deleteTestSharedFolder('folder-test@' . \config('app.domain')); + } + + public function tearDown(): void + { + $this->deleteTestSharedFolder('folder-test@' . \config('app.domain')); + + parent::tearDown(); + } + + /** + * Test job handle + * + * @group ldap + */ + public function testHandle(): void + { + Queue::fake(); + + // Test unknown folder + $job = new \App\Jobs\SharedFolder\CreateJob(123); + $job->handle(); + + $this->assertTrue($job->isReleased()); + $this->assertFalse($job->hasFailed()); + + $folder = $this->getTestSharedFolder('folder-test@' . \config('app.domain')); + + $this->assertFalse($folder->isLdapReady()); + + // Test shared folder creation + $job = new \App\Jobs\SharedFolder\CreateJob($folder->id); + $job->handle(); + + $this->assertTrue($folder->fresh()->isLdapReady()); + $this->assertFalse($job->hasFailed()); + + // Test job failures + $job = new \App\Jobs\SharedFolder\CreateJob($folder->id); + $job->handle(); + + $this->assertTrue($job->hasFailed()); + $this->assertSame("Shared folder {$folder->id} is already marked as ldap-ready.", $job->failureMessage); + + $folder->status |= SharedFolder::STATUS_DELETED; + $folder->save(); + + $job = new \App\Jobs\SharedFolder\CreateJob($folder->id); + $job->handle(); + + $this->assertTrue($job->hasFailed()); + $this->assertSame("Shared folder {$folder->id} is marked as deleted.", $job->failureMessage); + + $folder->status ^= SharedFolder::STATUS_DELETED; + $folder->save(); + $folder->delete(); + + $job = new \App\Jobs\SharedFolder\CreateJob($folder->id); + $job->handle(); + + $this->assertTrue($job->hasFailed()); + $this->assertSame("Shared folder {$folder->id} is actually deleted.", $job->failureMessage); + + // TODO: Test failures on domain sanity checks + } +} diff --git a/src/tests/Feature/Jobs/SharedFolder/DeleteTest.php b/src/tests/Feature/Jobs/SharedFolder/DeleteTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Jobs/SharedFolder/DeleteTest.php @@ -0,0 +1,76 @@ +deleteTestSharedFolder('folder-test@' . \config('app.domain')); + } + + public function tearDown(): void + { + $this->deleteTestSharedFolder('folder-test@' . \config('app.domain')); + + parent::tearDown(); + } + + /** + * Test job handle + * + * @group ldap + */ + public function testHandle(): void + { + Queue::fake(); + + // Test non-existing folder ID + $job = new \App\Jobs\SharedFolder\DeleteJob(123); + $job->handle(); + + $this->assertTrue($job->hasFailed()); + $this->assertSame("Shared folder 123 could not be found in the database.", $job->failureMessage); + + $folder = $this->getTestSharedFolder('folder-test@' . \config('app.domain'), [ + 'status' => SharedFolder::STATUS_NEW + ]); + + // create the shared folder first + $job = new \App\Jobs\SharedFolder\CreateJob($folder->id); + $job->handle(); + + $folder->refresh(); + + $this->assertTrue($folder->isLdapReady()); + + // Test successful deletion + $folder->status |= SharedFolder::STATUS_IMAP_READY; + $folder->save(); + + $job = new \App\Jobs\SharedFolder\DeleteJob($folder->id); + $job->handle(); + + $folder->refresh(); + + $this->assertFalse($folder->isLdapReady()); + $this->assertFalse($folder->isImapReady()); + $this->assertTrue($folder->isDeleted()); + + // Test deleting already deleted folder + $job = new \App\Jobs\SharedFolder\DeleteJob($folder->id); + $job->handle(); + + $this->assertTrue($job->hasFailed()); + $this->assertSame("Shared folder {$folder->id} is already marked as deleted.", $job->failureMessage); + } +} diff --git a/src/tests/Feature/Jobs/SharedFolder/UpdateTest.php b/src/tests/Feature/Jobs/SharedFolder/UpdateTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Jobs/SharedFolder/UpdateTest.php @@ -0,0 +1,78 @@ +deleteTestSharedFolder('folder-test@' . \config('app.domain')); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestSharedFolder('folder-test@' . \config('app.domain')); + + parent::tearDown(); + } + + /** + * Test job handle + * + * @group ldap + */ + public function testHandle(): void + { + Queue::fake(); + + // Test non-existing folder ID + $job = new \App\Jobs\SharedFolder\UpdateJob(123); + $job->handle(); + + $this->assertTrue($job->hasFailed()); + $this->assertSame("Shared folder 123 could not be found in the database.", $job->failureMessage); + + $folder = $this->getTestSharedFolder('folder-test@' . \config('app.domain')); + + // Create the folder in LDAP + $job = new \App\Jobs\SharedFolder\CreateJob($folder->id); + $job->handle(); + + $job = new \App\Jobs\SharedFolder\UpdateJob($folder->id); + $job->handle(); + + $this->assertTrue(is_array(LDAP::getSharedFolder($folder->email))); + + // Test that the job is being deleted if the folder is not ldap ready or is deleted + $folder->refresh(); + $folder->status = SharedFolder::STATUS_NEW | SharedFolder::STATUS_ACTIVE; + $folder->save(); + + $job = new \App\Jobs\SharedFolder\UpdateJob($folder->id); + $job->handle(); + + $this->assertTrue($job->isDeleted()); + + $folder->status = SharedFolder::STATUS_NEW | SharedFolder::STATUS_ACTIVE + | SharedFolder::STATUS_LDAP_READY | SharedFolder::STATUS_DELETED; + $folder->save(); + + $job = new \App\Jobs\SharedFolder\UpdateJob($folder->id); + $job->handle(); + + $this->assertTrue($job->isDeleted()); + } +} diff --git a/src/tests/Feature/Jobs/SharedFolder/VerifyTest.php b/src/tests/Feature/Jobs/SharedFolder/VerifyTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Jobs/SharedFolder/VerifyTest.php @@ -0,0 +1,75 @@ +getTestSharedFolder('folder-event@kolab.org'); + $folder->status |= SharedFolder::STATUS_IMAP_READY; + $folder->save(); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $folder = $this->getTestSharedFolder('folder-event@kolab.org'); + $folder->status |= SharedFolder::STATUS_IMAP_READY; + $folder->save(); + + parent::tearDown(); + } + + /** + * Test job handle + * + * @group imap + */ + public function testHandle(): void + { + Queue::fake(); + + // Test non-existing folder ID + $job = new \App\Jobs\SharedFolder\VerifyJob(123); + $job->handle(); + + $this->assertTrue($job->hasFailed()); + $this->assertSame("Shared folder 123 could not be found in the database.", $job->failureMessage); + + // Test existing folder + $folder = $this->getTestSharedFolder('folder-event@kolab.org'); + + if ($folder->isImapReady()) { + $folder->status ^= SharedFolder::STATUS_IMAP_READY; + $folder->save(); + } + + $this->assertFalse($folder->isImapReady()); + + for ($i = 0; $i < 10; $i++) { + $job = new \App\Jobs\SharedFolder\VerifyJob($folder->id); + $job->handle(); + + if ($folder->fresh()->isImapReady()) { + $this->assertTrue(true); + return; + } + + sleep(1); + } + + $this->assertTrue(false, "Unable to verify the shared folder is set up in time"); + } +} diff --git a/src/tests/Feature/Jobs/UserCreateTest.php b/src/tests/Feature/Jobs/User/CreateTest.php rename from src/tests/Feature/Jobs/UserCreateTest.php rename to src/tests/Feature/Jobs/User/CreateTest.php --- a/src/tests/Feature/Jobs/UserCreateTest.php +++ b/src/tests/Feature/Jobs/User/CreateTest.php @@ -1,11 +1,11 @@ deleteTestUser('user-test@kolabnow.com'); + SharedFolder::withTrashed()->where('email', 'like', '%@kolabnow.com')->each(function ($folder) { + $this->deleteTestSharedFolder($folder->email); + }); + } + + public function tearDown(): void + { + $this->deleteTestUser('user-test@kolabnow.com'); + SharedFolder::withTrashed()->where('email', 'like', '%@kolabnow.com')->each(function ($folder) { + $this->deleteTestSharedFolder($folder->email); + }); + + parent::tearDown(); + } + + /** + * Tests for SharedFolder::assignToWallet() + */ + public function testAssignToWallet(): void + { + $user = $this->getTestUser('user-test@kolabnow.com'); + $folder = $this->getTestSharedFolder('folder-test@kolabnow.com'); + + $result = $folder->assignToWallet($user->wallets->first()); + + $this->assertSame($folder, $result); + $this->assertSame(1, $folder->entitlements()->count()); + $this->assertSame('shared-folder', $folder->entitlements()->first()->sku->title); + + // Can't be done twice on the same folder + $this->expectException(\Exception::class); + $result->assignToWallet($user->wallets->first()); + } + + /** + * Test SharedFolder::getConfig() and setConfig() methods + */ + public function testConfigTrait(): void + { + Queue::fake(); + + $folder = new SharedFolder(); + $folder->email = 'folder-test@kolabnow.com'; + $folder->name = 'Test'; + $folder->save(); + $john = $this->getTestUser('john@kolab.org'); + $folder->assignToWallet($john->wallets->first()); + + $this->assertSame(['acl' => []], $folder->getConfig()); + + $result = $folder->setConfig(['acl' => ['anyone, read-only'], 'unknown' => false]); + + $this->assertSame(['acl' => ['anyone, read-only']], $folder->getConfig()); + $this->assertSame('["anyone, read-only"]', $folder->getSetting('acl')); + $this->assertSame(['unknown' => "The requested configuration parameter is not supported."], $result); + + $result = $folder->setConfig(['acl' => ['anyone, unknown']]); + + $this->assertSame(['acl' => ['anyone, read-only']], $folder->getConfig()); + $this->assertSame('["anyone, read-only"]', $folder->getSetting('acl')); + $this->assertSame(['acl' => ["The entry format is invalid. Expected an email address."]], $result); + + // Test valid user for ACL + $result = $folder->setConfig(['acl' => ['john@kolab.org, full']]); + + $this->assertSame(['acl' => ['john@kolab.org, full']], $folder->getConfig()); + $this->assertSame('["john@kolab.org, full"]', $folder->getSetting('acl')); + $this->assertSame([], $result); + + // Test invalid user for ACL + $result = $folder->setConfig(['acl' => ['john, full']]); + + $this->assertSame(['acl' => ['john@kolab.org, full']], $folder->getConfig()); + $this->assertSame('["john@kolab.org, full"]', $folder->getSetting('acl')); + $this->assertSame(['acl' => ["The specified email address is invalid."]], $result); + + // Other invalid entries + $acl = [ + // Test non-existing user for ACL + 'unknown@kolab.org, full', + // Test existing user from a different wallet + 'user@sample-tenant.dev-local, read-only', + // Valid entry + 'john@kolab.org, read-write', + ]; + + $result = $folder->setConfig(['acl' => $acl]); + $this->assertCount(2, $result['acl']); + $this->assertSame("The specified email address does not exist.", $result['acl'][0]); + $this->assertSame("The specified email address does not exist.", $result['acl'][1]); + $this->assertSame(['acl' => ['john@kolab.org, full']], $folder->getConfig()); + $this->assertSame('["john@kolab.org, full"]', $folder->getSetting('acl')); + } + + /** + * Test creating a shared folder + */ + public function testCreate(): void + { + Queue::fake(); + + $folder = new SharedFolder(); + $folder->name = 'Reśo'; + $folder->domain = 'kolabnow.com'; + $folder->save(); + + $this->assertMatchesRegularExpression('/^[0-9]{1,20}$/', $folder->id); + $this->assertMatchesRegularExpression('/^mail-[0-9]{1,20}@kolabnow\.com$/', $folder->email); + $this->assertSame('Reśo', $folder->name); + $this->assertTrue($folder->isNew()); + $this->assertTrue($folder->isActive()); + $this->assertFalse($folder->isDeleted()); + $this->assertFalse($folder->isLdapReady()); + $this->assertFalse($folder->isImapReady()); + + $settings = $folder->settings()->get(); + $this->assertCount(1, $settings); + $this->assertSame('folder', $settings[0]->key); + $this->assertSame('shared/Reśo@kolabnow.com', $settings[0]->value); + + Queue::assertPushed( + \App\Jobs\SharedFolder\CreateJob::class, + function ($job) use ($folder) { + $folderEmail = TestCase::getObjectProperty($job, 'folderEmail'); + $folderId = TestCase::getObjectProperty($job, 'folderId'); + + return $folderEmail === $folder->email + && $folderId === $folder->id; + } + ); + + Queue::assertPushedWithChain( + \App\Jobs\SharedFolder\CreateJob::class, + [ + \App\Jobs\SharedFolder\VerifyJob::class, + ] + ); + } + + /** + * Test a shared folder deletion and force-deletion + */ + public function testDelete(): void + { + Queue::fake(); + + $user = $this->getTestUser('user-test@kolabnow.com'); + $folder = $this->getTestSharedFolder('folder-test@kolabnow.com'); + $folder->assignToWallet($user->wallets->first()); + + $entitlements = \App\Entitlement::where('entitleable_id', $folder->id); + + $this->assertSame(1, $entitlements->count()); + + $folder->delete(); + + $this->assertTrue($folder->fresh()->trashed()); + $this->assertSame(0, $entitlements->count()); + $this->assertSame(1, $entitlements->withTrashed()->count()); + + $folder->forceDelete(); + + $this->assertSame(0, $entitlements->withTrashed()->count()); + $this->assertCount(0, SharedFolder::withTrashed()->where('id', $folder->id)->get()); + + Queue::assertPushed(\App\Jobs\SharedFolder\DeleteJob::class, 1); + Queue::assertPushed( + \App\Jobs\SharedFolder\DeleteJob::class, + function ($job) use ($folder) { + $folderEmail = TestCase::getObjectProperty($job, 'folderEmail'); + $folderId = TestCase::getObjectProperty($job, 'folderId'); + + return $folderEmail === $folder->email + && $folderId === $folder->id; + } + ); + } + + /** + * Tests for SharedFolder::emailExists() + */ + public function testEmailExists(): void + { + Queue::fake(); + + $folder = $this->getTestSharedFolder('folder-test@kolabnow.com'); + + $this->assertFalse(SharedFolder::emailExists('unknown@domain.tld')); + $this->assertTrue(SharedFolder::emailExists($folder->email)); + + $result = SharedFolder::emailExists($folder->email, true); + $this->assertSame($result->id, $folder->id); + + $folder->delete(); + + $this->assertTrue(SharedFolder::emailExists($folder->email)); + + $result = SharedFolder::emailExists($folder->email, true); + $this->assertSame($result->id, $folder->id); + } + + /** + * Tests for SettingsTrait functionality and SharedFolderSettingObserver + */ + public function testSettings(): void + { + Queue::fake(); + Queue::assertNothingPushed(); + + $folder = $this->getTestSharedFolder('folder-test@kolabnow.com'); + + Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 0); + + // Add a setting + $folder->setSetting('unknown', 'test'); + + Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 0); + + // Add a setting that is synced to LDAP + $folder->setSetting('acl', 'test'); + + Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 1); + + // Note: We test both current folder as well as fresh folder object + // to make sure cache works as expected + $this->assertSame('test', $folder->getSetting('unknown')); + $this->assertSame('test', $folder->fresh()->getSetting('acl')); + + Queue::fake(); + + // Update a setting + $folder->setSetting('unknown', 'test1'); + + Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 0); + + // Update a setting that is synced to LDAP + $folder->setSetting('acl', 'test1'); + + Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 1); + + $this->assertSame('test1', $folder->getSetting('unknown')); + $this->assertSame('test1', $folder->fresh()->getSetting('acl')); + + Queue::fake(); + + // Delete a setting (null) + $folder->setSetting('unknown', null); + + Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 0); + + // Delete a setting that is synced to LDAP + $folder->setSetting('acl', null); + + Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 1); + + $this->assertSame(null, $folder->getSetting('unknown')); + $this->assertSame(null, $folder->fresh()->getSetting('acl')); + } + + /** + * Test updating a shared folder + */ + public function testUpdate(): void + { + Queue::fake(); + + $folder = $this->getTestSharedFolder('folder-test@kolabnow.com'); + + $folder->name = 'New'; + $folder->save(); + + // Assert the imap folder changes on a folder name change + $settings = $folder->settings()->where('key', 'folder')->get(); + $this->assertCount(1, $settings); + $this->assertSame('shared/New@kolabnow.com', $settings[0]->value); + + Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 1); + Queue::assertPushed( + \App\Jobs\SharedFolder\UpdateJob::class, + function ($job) use ($folder) { + $folderEmail = TestCase::getObjectProperty($job, 'folderEmail'); + $folderId = TestCase::getObjectProperty($job, 'folderId'); + + return $folderEmail === $folder->email + && $folderId === $folder->id; + } + ); + } +} diff --git a/src/tests/Feature/UserTest.php b/src/tests/Feature/UserTest.php --- a/src/tests/Feature/UserTest.php +++ b/src/tests/Feature/UserTest.php @@ -20,6 +20,7 @@ $this->deleteTestUser('UserAccountC@UserAccount.com'); $this->deleteTestGroup('test-group@UserAccount.com'); $this->deleteTestResource('test-resource@UserAccount.com'); + $this->deleteTestSharedFolder('test-folder@UserAccount.com'); $this->deleteTestDomain('UserAccount.com'); $this->deleteTestDomain('UserAccountAdd.com'); } @@ -33,6 +34,7 @@ $this->deleteTestUser('UserAccountC@UserAccount.com'); $this->deleteTestGroup('test-group@UserAccount.com'); $this->deleteTestResource('test-resource@UserAccount.com'); + $this->deleteTestSharedFolder('test-folder@UserAccount.com'); $this->deleteTestDomain('UserAccount.com'); $this->deleteTestDomain('UserAccountAdd.com'); @@ -459,6 +461,8 @@ $group->assignToWallet($userA->wallets->first()); $resource = $this->getTestResource('test-resource@UserAccount.com', ['name' => 'test']); $resource->assignToWallet($userA->wallets->first()); + $folder = $this->getTestSharedFolder('test-folder@UserAccount.com', ['name' => 'test']); + $folder->assignToWallet($userA->wallets->first()); $entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id); $entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id); @@ -466,6 +470,7 @@ $entitlementsDomain = \App\Entitlement::where('entitleable_id', $domain->id); $entitlementsGroup = \App\Entitlement::where('entitleable_id', $group->id); $entitlementsResource = \App\Entitlement::where('entitleable_id', $resource->id); + $entitlementsFolder = \App\Entitlement::where('entitleable_id', $folder->id); $this->assertSame(7, $entitlementsA->count()); $this->assertSame(7, $entitlementsB->count()); @@ -473,6 +478,7 @@ $this->assertSame(1, $entitlementsDomain->count()); $this->assertSame(1, $entitlementsGroup->count()); $this->assertSame(1, $entitlementsResource->count()); + $this->assertSame(1, $entitlementsFolder->count()); // Delete non-controller user $userC->delete(); @@ -489,16 +495,19 @@ $this->assertSame(0, $entitlementsDomain->count()); $this->assertSame(0, $entitlementsGroup->count()); $this->assertSame(0, $entitlementsResource->count()); + $this->assertSame(0, $entitlementsFolder->count()); $this->assertTrue($userA->fresh()->trashed()); $this->assertTrue($userB->fresh()->trashed()); $this->assertTrue($domain->fresh()->trashed()); $this->assertTrue($group->fresh()->trashed()); $this->assertTrue($resource->fresh()->trashed()); + $this->assertTrue($folder->fresh()->trashed()); $this->assertFalse($userA->isDeleted()); $this->assertFalse($userB->isDeleted()); $this->assertFalse($domain->isDeleted()); $this->assertFalse($group->isDeleted()); $this->assertFalse($resource->isDeleted()); + $this->assertFalse($folder->isDeleted()); $userA->forceDelete(); @@ -511,6 +520,7 @@ $this->assertCount(0, Domain::withTrashed()->where('id', $domain->id)->get()); $this->assertCount(0, Group::withTrashed()->where('id', $group->id)->get()); $this->assertCount(0, \App\Resource::withTrashed()->where('id', $resource->id)->get()); + $this->assertCount(0, \App\SharedFolder::withTrashed()->where('id', $folder->id)->get()); } /** @@ -730,6 +740,32 @@ $this->assertSame(0, $resources->count()); } + /** + * Test sharedFolders() method + */ + public function testSharedFolders(): void + { + $john = $this->getTestUser('john@kolab.org'); + $ned = $this->getTestUser('ned@kolab.org'); + $jack = $this->getTestUser('jack@kolab.org'); + + $folders = $john->sharedFolders()->orderBy('email')->get(); + + $this->assertSame(2, $folders->count()); + $this->assertSame('folder-contact@kolab.org', $folders[0]->email); + $this->assertSame('folder-event@kolab.org', $folders[1]->email); + + $folders = $ned->sharedFolders()->orderBy('email')->get(); + + $this->assertSame(2, $folders->count()); + $this->assertSame('folder-contact@kolab.org', $folders[0]->email); + $this->assertSame('folder-event@kolab.org', $folders[1]->email); + + $folders = $jack->sharedFolders()->get(); + + $this->assertSame(0, $folders->count()); + } + /** * Test user restoring */ diff --git a/src/tests/TestCaseTrait.php b/src/tests/TestCaseTrait.php --- a/src/tests/TestCaseTrait.php +++ b/src/tests/TestCaseTrait.php @@ -2,9 +2,11 @@ namespace Tests; +use App\Backends\LDAP; use App\Domain; use App\Group; use App\Resource; +use App\SharedFolder; use App\Sku; use App\Transaction; use App\User; @@ -154,6 +156,7 @@ $beta_handlers = [ 'App\Handlers\Beta', 'App\Handlers\Beta\Resources', + 'App\Handlers\Beta\SharedFolders', 'App\Handlers\Distlist', ]; @@ -290,8 +293,7 @@ return; } - $job = new \App\Jobs\Group\DeleteJob($group->id); - $job->handle(); + LDAP::deleteGroup($group); $group->forceDelete(); } @@ -311,12 +313,31 @@ return; } - $job = new \App\Jobs\Resource\DeleteJob($resource->id); - $job->handle(); + LDAP::deleteResource($resource); $resource->forceDelete(); } + /** + * Delete a test shared folder whatever it takes. + * + * @coversNothing + */ + protected function deleteTestSharedFolder($email) + { + Queue::fake(); + + $folder = SharedFolder::withTrashed()->where('email', $email)->first(); + + if (!$folder) { + return; + } + + LDAP::deleteSharedFolder($folder); + + $folder->forceDelete(); + } + /** * Delete a test user whatever it takes. * @@ -332,8 +353,7 @@ return; } - $job = new \App\Jobs\User\DeleteJob($user->id); - $job->handle(); + LDAP::deleteUser($user); $user->forceDelete(); } @@ -375,7 +395,7 @@ } /** - * Get Resource object by name+domain, create it if needed. + * Get Resource object by email, create it if needed. * Skip LDAP jobs. */ protected function getTestResource($email, $attrib = []) @@ -406,6 +426,38 @@ return $resource; } + /** + * Get SharedFolder object by email, create it if needed. + * Skip LDAP jobs. + */ + protected function getTestSharedFolder($email, $attrib = []) + { + // Disable jobs (i.e. skip LDAP oprations) + Queue::fake(); + + $folder = SharedFolder::where('email', $email)->first(); + + if (!$folder) { + list($local, $domain) = explode('@', $email, 2); + + $folder = new SharedFolder(); + $folder->email = $email; + $folder->domain = $domain; + + if (!isset($attrib['name'])) { + $folder->name = $local; + } + } + + foreach ($attrib as $key => $val) { + $folder->{$key} = $val; + } + + $folder->save(); + + return $folder; + } + /** * Get User object by email, create it if needed. * Skip LDAP jobs. diff --git a/src/tests/Unit/Rules/ResourceNameTest.php b/src/tests/Unit/Rules/ResourceNameTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Unit/Rules/ResourceNameTest.php @@ -0,0 +1,47 @@ +getTestUser('john@kolab.org'); + $rules = ['name' => ['present', new ResourceName($user, 'kolab.org')]]; + + // Empty/invalid input + $v = Validator::make(['name' => null], $rules); + $this->assertSame(['name' => ["The specified name is invalid."]], $v->errors()->toArray()); + + $v = Validator::make(['name' => []], $rules); + $this->assertSame(['name' => ["The specified name is invalid."]], $v->errors()->toArray()); + + // Forbidden chars + $v = Validator::make(['name' => 'Test@'], $rules); + $this->assertSame(['name' => ["The specified name is invalid."]], $v->errors()->toArray()); + + // Length limit + $v = Validator::make(['name' => str_repeat('a', 192)], $rules); + $this->assertSame(['name' => ["The name may not be greater than 191 characters."]], $v->errors()->toArray()); + + // Existing resource + $v = Validator::make(['name' => 'Conference Room #1'], $rules); + $this->assertSame(['name' => ["The specified name is not available."]], $v->errors()->toArray()); + + // Valid name + $v = Validator::make(['name' => 'TestRule'], $rules); + $this->assertSame([], $v->errors()->toArray()); + + // Invalid domain + $rules = ['name' => ['present', new ResourceName($user, 'kolabnow.com')]]; + $v = Validator::make(['name' => 'TestRule'], $rules); + $this->assertSame(['name' => ["The specified domain is invalid."]], $v->errors()->toArray()); + } +} diff --git a/src/tests/Unit/Rules/SharedFolderNameTest.php b/src/tests/Unit/Rules/SharedFolderNameTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Unit/Rules/SharedFolderNameTest.php @@ -0,0 +1,47 @@ +getTestUser('john@kolab.org'); + $rules = ['name' => ['present', new SharedFolderName($user, 'kolab.org')]]; + + // Empty/invalid input + $v = Validator::make(['name' => null], $rules); + $this->assertSame(['name' => ["The specified name is invalid."]], $v->errors()->toArray()); + + $v = Validator::make(['name' => []], $rules); + $this->assertSame(['name' => ["The specified name is invalid."]], $v->errors()->toArray()); + + // Forbidden chars + $v = Validator::make(['name' => 'Test@'], $rules); + $this->assertSame(['name' => ["The specified name is invalid."]], $v->errors()->toArray()); + + // Length limit + $v = Validator::make(['name' => str_repeat('a', 192)], $rules); + $this->assertSame(['name' => ["The name may not be greater than 191 characters."]], $v->errors()->toArray()); + + // Existing resource + $v = Validator::make(['name' => 'Calendar'], $rules); + $this->assertSame(['name' => ["The specified name is not available."]], $v->errors()->toArray()); + + // Valid name + $v = Validator::make(['name' => 'TestRule'], $rules); + $this->assertSame([], $v->errors()->toArray()); + + // Invalid domain + $rules = ['name' => ['present', new SharedFolderName($user, 'kolabnow.com')]]; + $v = Validator::make(['name' => 'TestRule'], $rules); + $this->assertSame(['name' => ["The specified domain is invalid."]], $v->errors()->toArray()); + } +} diff --git a/src/tests/Unit/Rules/SharedFolderTypeTest.php b/src/tests/Unit/Rules/SharedFolderTypeTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Unit/Rules/SharedFolderTypeTest.php @@ -0,0 +1,34 @@ + ['present', new SharedFolderType()]]; + + // Empty/invalid input + $v = Validator::make(['type' => null], $rules); + $this->assertSame(['type' => ["The specified type is invalid."]], $v->errors()->toArray()); + + $v = Validator::make(['type' => []], $rules); + $this->assertSame(['type' => ["The specified type is invalid."]], $v->errors()->toArray()); + + $v = Validator::make(['type' => 'Test'], $rules); + $this->assertSame(['type' => ["The specified type is invalid."]], $v->errors()->toArray()); + + // Valid type + foreach (\App\SharedFolder::SUPPORTED_TYPES as $type) { + $v = Validator::make(['type' => $type], $rules); + $this->assertSame([], $v->errors()->toArray()); + } + } +} diff --git a/src/tests/Unit/SharedFolderTest.php b/src/tests/Unit/SharedFolderTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Unit/SharedFolderTest.php @@ -0,0 +1,85 @@ + 'test']); + + foreach ($folders as $folderStatuses) { + $folder->status = \array_sum($folderStatuses); + + $folderStatuses = []; + + foreach ($statuses as $status) { + if ($folder->status & $status) { + $folderStatuses[] = $status; + } + } + + $this->assertSame($folder->status, \array_sum($folderStatuses)); + + // either one is true, but not both + $this->assertSame( + $folder->isNew() === in_array(SharedFolder::STATUS_NEW, $folderStatuses), + $folder->isActive() === in_array(SharedFolder::STATUS_ACTIVE, $folderStatuses) + ); + + $this->assertTrue( + $folder->isNew() === in_array(SharedFolder::STATUS_NEW, $folderStatuses) + ); + + $this->assertTrue( + $folder->isActive() === in_array(SharedFolder::STATUS_ACTIVE, $folderStatuses) + ); + + $this->assertTrue( + $folder->isDeleted() === in_array(SharedFolder::STATUS_DELETED, $folderStatuses) + ); + + $this->assertTrue( + $folder->isLdapReady() === in_array(SharedFolder::STATUS_LDAP_READY, $folderStatuses) + ); + + $this->assertTrue( + $folder->isImapReady() === in_array(SharedFolder::STATUS_IMAP_READY, $folderStatuses) + ); + } + + $this->expectException(\Exception::class); + $folder->status = 111; + } + + /** + * Test basic SharedFolder funtionality + */ + public function testSharedFolderType(): void + { + $folder = new SharedFolder(['name' => 'test']); + + foreach (SharedFolder::SUPPORTED_TYPES as $type) { + $folder->type = $type; + } + + $this->expectException(\Exception::class); + $folder->type = 'unknown'; + } +}