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 @@ -3,31 +3,372 @@ namespace App\Backends; use App\Domain; +use App\Group; +use App\Resource; +use App\SharedFolder; use App\User; class IMAP { + /** @const array Maps Kolab permissions to IMAP permissions */ + private const ACL_MAP = [ + 'read-only' => 'lrs', + 'read-write' => 'lrswitedn', + 'full' => 'lrswipkxtecdn', + ]; + /** - * Check if an account is set up + * Delete a group. * - * @param string $username User login (email address) + * @param \App\Group $group Group * - * @return bool True if an account exists and is set up, False otherwise + * @return bool True if a group was deleted successfully, False otherwise + * @throws \Exception */ - public static function verifyAccount(string $username): bool + public static function deleteGroup(Group $group): bool + { + $domainName = explode('@', $group->email, 2)[1]; + + // Cleanup ACL + // FIXME: Since all groups in Kolab4 have email address, + // should we consider using it in ACL instead of the name? + // Also we need to decide what to do and configure IMAP appropriately, + // right now groups in ACL does not work for me at all. + \App\Jobs\IMAP\AclCleanupJob::dispatch($group->name, $domainName); + + return true; + } + + /** + * Create a mailbox. + * + * @param \App\User $user User + * + * @return bool True if a mailbox was created successfully, False otherwise + * @throws \Exception + */ + public static function createUser(User $user): bool { $config = self::getConfig(); - $imap = self::initIMAP($config, $username); + $imap = self::initIMAP($config); - $folders = $imap->listMailboxes('', '*'); + $mailbox = self::toUTF7('user/' . $user->email); + + // Mailbox already exists + if (self::folderExists($imap, $mailbox)) { + $imap->closeConnection(); + return true; + } + + // Create the mailbox + if (!$imap->createFolder($mailbox)) { + \Log::error("Failed to create mailbox {$mailbox}"); + $imap->closeConnection(); + return false; + } + + // Wait until it's propagated (for Cyrus Murder setup) + // FIXME: Do we still need this? + if (strpos($imap->conn->data['GREETING'] ?? '', 'Cyrus IMAP Murder') !== false) { + $tries = 30; + while ($tries-- > 0) { + $folders = $imap->listMailboxes('', $mailbox); + if (is_array($folders) && count($folders)) { + break; + } + sleep(1); + $imap->closeConnection(); + $imap = self::initIMAP($config); + } + } + + // Set quota + $quota = $user->countEntitlementsBySku('storage') * 1048576; + if ($quota) { + $imap->setQuota($mailbox, ['storage' => $quota]); + } $imap->closeConnection(); - if (!is_array($folders)) { - throw new \Exception("Failed to get IMAP folders"); + return true; + } + + /** + * Delete a mailbox. + * + * @param \App\User $user User + * + * @return bool True if a mailbox was deleted successfully, False otherwise + * @throws \Exception + */ + public static function deleteUser(User $user): bool + { + $config = self::getConfig(); + $imap = self::initIMAP($config); + + $mailbox = self::toUTF7('user/' . $user->email); + + // To delete the mailbox cyrus-admin needs extra permissions + $imap->setACL($mailbox, $config['user'], 'c'); + + // Delete the mailbox (no need to delete subfolders?) + $result = $imap->deleteFolder($mailbox); + + $imap->closeConnection(); + + // Cleanup ACL + \App\Jobs\IMAP\AclCleanupJob::dispatch($user->email); + + return $result; + } + + /** + * Update a mailbox (quota). + * + * @param \App\User $user User + * + * @return bool True if a mailbox was updated successfully, False otherwise + * @throws \Exception + */ + public static function updateUser(User $user): bool + { + $config = self::getConfig(); + $imap = self::initIMAP($config); + + $mailbox = self::toUTF7('user/' . $user->email); + $result = true; + + // Set quota + $quota = $user->countEntitlementsBySku('storage') * 1048576; + if ($quota) { + $result = $imap->setQuota($mailbox, ['storage' => $quota]); } - return count($folders) > 0; + $imap->closeConnection(); + + return $result; + } + + /** + * Create a resource. + * + * @param \App\Resource $resource Resource + * + * @return bool True if a resource was created successfully, False otherwise + * @throws \Exception + */ + public static function createResource(Resource $resource): bool + { + $config = self::getConfig(); + $imap = self::initIMAP($config); + + $settings = $resource->getSettings(['invitation_policy', 'folder']); + $mailbox = self::toUTF7($settings['folder']); + + // Mailbox already exists + if (self::folderExists($imap, $mailbox)) { + $imap->closeConnection(); + return true; + } + + // Create the shared folder + if (!$imap->createFolder($mailbox)) { + \Log::error("Failed to create mailbox {$mailbox}"); + $imap->closeConnection(); + return false; + } + + // Set folder type + $imap->setMetadata($mailbox, ['/shared/vendor/kolab/folder-type' => 'event']); + + // Set ACL + if (!empty($settings['invitation_policy'])) { + if (preg_match('/^manual:(\S+@\S+)$/', $settings['invitation_policy'], $m)) { + self::aclUpdate($imap, $mailbox, ["{$m[1]}, full"]); + } + } + + $imap->closeConnection(); + + return true; + } + + /** + * Update a resource. + * + * @param \App\Resource $resource Resource + * @param array $props Old resource properties + * + * @return bool True if a resource was updated successfully, False otherwise + * @throws \Exception + */ + public static function updateResource(Resource $resource, array $props = []): bool + { + $config = self::getConfig(); + $imap = self::initIMAP($config); + + $settings = $resource->getSettings(['invitation_policy', 'folder']); + $folder = $settings['folder']; + $mailbox = self::toUTF7($folder); + + // Rename the mailbox (only possible if we have the old folder) + if (!empty($props['folder']) && $props['folder'] != $folder) { + $oldMailbox = self::toUTF7($props['folder']); + + if (!$imap->renameFolder($oldMailbox, $mailbox)) { + \Log::error("Failed to rename mailbox {$oldMailbox} to {$mailbox}"); + $imap->closeConnection(); + return false; + } + } + + // ACL + $acl = []; + if (!empty($settings['invitation_policy'])) { + if (preg_match('/^manual:(\S+@\S+)$/', $settings['invitation_policy'], $m)) { + $acl = ["{$m[1]}, full"]; + } + } + self::aclUpdate($imap, $mailbox, $acl); + + $imap->closeConnection(); + + return true; + } + + /** + * Delete a resource. + * + * @param \App\Resource $resource Resource + * + * @return bool True if a resource was deleted successfully, False otherwise + * @throws \Exception + */ + public static function deleteResource(Resource $resource): bool + { + $config = self::getConfig(); + $imap = self::initIMAP($config); + + $settings = $resource->getSettings(['folder']); + $mailbox = self::toUTF7($settings['folder']); + + // To delete the mailbox cyrus-admin needs extra permissions + $imap->setACL($mailbox, $config['user'], 'c'); + + // Delete the mailbox (no need to delete subfolders?) + $result = $imap->deleteFolder($mailbox); + + $imap->closeConnection(); + + return $result; + } + + /** + * Create a shared folder. + * + * @param \App\SharedFolder $folder Shared folder + * + * @return bool True if a falder was created successfully, False otherwise + * @throws \Exception + */ + public static function createSharedFolder(SharedFolder $folder): bool + { + $config = self::getConfig(); + $imap = self::initIMAP($config); + + $settings = $folder->getSettings(['acl', 'folder']); + $acl = !empty($settings['acl']) ? json_decode($settings['acl'], true) : null; + $mailbox = self::toUTF7($settings['folder']); + + // Mailbox already exists + if (self::folderExists($imap, $mailbox)) { + $imap->closeConnection(); + return true; + } + + // Create the mailbox + if (!$imap->createFolder($mailbox)) { + \Log::error("Failed to create mailbox {$mailbox}"); + $imap->closeConnection(); + return false; + } + + // Set folder type + $imap->setMetadata($mailbox, ['/shared/vendor/kolab/folder-type' => $folder->type]); + + // Set ACL + self::aclUpdate($imap, $mailbox, $acl); + + $imap->closeConnection(); + + return true; + } + + /** + * Update a shared folder. + * + * @param \App\SharedFolder $folder Shared folder + * @param array $props Old folder properties + * + * @return bool True if a falder was updated successfully, False otherwise + * @throws \Exception + */ + public static function updateSharedFolder(SharedFolder $folder, array $props = []): bool + { + $config = self::getConfig(); + $imap = self::initIMAP($config); + + $settings = $folder->getSettings(['acl', 'folder']); + $acl = !empty($settings['acl']) ? json_decode($settings['acl'], true) : null; + $folder = $settings['folder']; + $mailbox = self::toUTF7($folder); + + // Rename the mailbox + if (!empty($props['folder']) && $props['folder'] != $folder) { + $oldMailbox = self::toUTF7($props['folder']); + + if (!$imap->renameFolder($oldMailbox, $mailbox)) { + \Log::error("Failed to rename mailbox {$oldMailbox} to {$mailbox}"); + $imap->closeConnection(); + return false; + } + } + + // Note: Shared folder type does not change + + // ACL + self::aclUpdate($imap, $mailbox, $acl); + + $imap->closeConnection(); + + return true; + } + + /** + * Delete a shared folder. + * + * @param \App\SharedFolder $folder Shared folder + * + * @return bool True if a falder was deleted successfully, False otherwise + * @throws \Exception + */ + public static function deleteSharedFolder(SharedFolder $folder): bool + { + $config = self::getConfig(); + $imap = self::initIMAP($config); + + $settings = $folder->getSettings(['folder']); + $mailbox = self::toUTF7($settings['folder']); + + // To delete the mailbox cyrus-admin needs extra permissions + $imap->setACL($mailbox, $config['user'], 'c'); + + // Delete the mailbox + $result = $imap->deleteFolder($mailbox); + + $imap->closeConnection(); + + return $result; } /** @@ -44,7 +385,7 @@ // Convert the folder from UTF8 to UTF7-IMAP if (\preg_match('#^(shared/|shared/Resources/)(.+)(@[^@]+)$#', $folder, $matches)) { - $folderName = \mb_convert_encoding($matches[2], 'UTF7-IMAP', 'UTF8'); + $folderName = self::toUTF7($matches[2]); $folder = $matches[1] . $folderName . $matches[3]; } @@ -67,6 +408,134 @@ return true; } + /** + * Convert UTF8 string to UTF7-IMAP encoding + */ + public static function toUTF7(string $string): string + { + return \mb_convert_encoding($string, 'UTF7-IMAP', 'UTF8'); + } + + /** + * Check if an account is set up + * + * @param string $username User login (email address) + * + * @return bool True if an account exists and is set up, False otherwise + */ + public static function verifyAccount(string $username): bool + { + $config = self::getConfig(); + $imap = self::initIMAP($config, $username); + + $folders = $imap->listMailboxes('', '*'); + + $imap->closeConnection(); + + if (!is_array($folders)) { + throw new \Exception("Failed to get IMAP folders"); + } + + return count($folders) > 0; + } + + /** + * Remove ACL for a specified user/group anywhere in the IMAP + * + * @param string $ident ACL identifier (user email or e.g. group name) + * @param string $domain ACL domain + */ + public static function aclCleanup(string $ident, string $domain = ''): void + { + $config = self::getConfig(); + $imap = self::initIMAP($config); + + if (strpos($ident, '@')) { + $domain = explode('@', $ident, 2)[1]; + } + + $callback = function ($folder) use ($imap, $ident) { + $acl = $imap->getACL($folder); + if (is_array($acl) && isset($acl[$ident])) { + $imap->deleteACL($folder, $ident); + } + }; + + $folders = $imap->listMailboxes('', "user/*@{$domain}"); + + if (!is_array($folders)) { + $imap->closeConnection(); + throw new \Exception("Failed to get IMAP folders"); + } + + array_walk($folders, $callback); + + $folders = $imap->listMailboxes('', "shared/*@{$domain}"); + + if (!is_array($folders)) { + $imap->closeConnection(); + throw new \Exception("Failed to get IMAP folders"); + } + + array_walk($folders, $callback); + + $imap->closeConnection(); + } + + /** + * Convert Kolab ACL into IMAP user->rights array + */ + private static function aclToImap($acl): array + { + if (empty($acl)) { + return []; + } + + return \collect($acl) + ->mapWithKeys(function ($item, $key) { + list($user, $rights) = explode(',', $item, 2); + return [trim($user) => self::ACL_MAP[trim($rights)]]; + }) + ->all(); + } + + /** + * Update folder ACL + */ + private static function aclUpdate($imap, $mailbox, $acl, bool $isNew = false) + { + $imapAcl = $isNew ? [] : $imap->getACL($mailbox); + + if (is_array($imapAcl)) { + foreach (self::aclToImap($acl) as $user => $rights) { + if (empty($imapAcl[$user]) || implode('', $imapAcl[$user]) !== $rights) { + $imap->setACL($mailbox, $user, $rights); + } + + unset($imapAcl[$user]); + } + + foreach ($imapAcl as $user => $rights) { + $imap->deleteACL($mailbox, $user); + } + } + } + + /** + * Check if an IMAP folder exists + */ + private static function folderExists($imap, string $folder): bool + { + $folders = $imap->listMailboxes('', $folder); + + if (!is_array($folders)) { + $imap->closeConnection(); + throw new \Exception("Failed to get IMAP folders"); + } + + return count($folders) > 0; + } + /** * Initialize connection to IMAP */ diff --git a/src/app/Console/Commands/Job/DomainCreate.php b/src/app/Console/Commands/Job/DomainCreate.php --- a/src/app/Console/Commands/Job/DomainCreate.php +++ b/src/app/Console/Commands/Job/DomainCreate.php @@ -3,7 +3,6 @@ namespace App\Console\Commands\Job; use App\Console\Command; -use App\Domain; class DomainCreate extends Command { @@ -19,7 +18,7 @@ * * @var string */ - protected $description = "Execute the DomainCreate job (again)."; + protected $description = "Execute the domain creation job (again)."; /** * Execute the console command. diff --git a/src/app/Console/Commands/Job/DomainUpdate.php b/src/app/Console/Commands/Job/DomainUpdate.php --- a/src/app/Console/Commands/Job/DomainUpdate.php +++ b/src/app/Console/Commands/Job/DomainUpdate.php @@ -3,7 +3,6 @@ namespace App\Console\Commands\Job; use App\Console\Command; -use App\Domain; class DomainUpdate extends Command { @@ -19,7 +18,7 @@ * * @var string */ - protected $description = "Execute the DomainUpdate job (again)."; + protected $description = "Execute the domain update job (again)."; /** * Execute the console command. diff --git a/src/app/Console/Commands/Job/UserUpdate.php b/src/app/Console/Commands/Job/ResourceCreate.php copy from src/app/Console/Commands/Job/UserUpdate.php copy to src/app/Console/Commands/Job/ResourceCreate.php --- a/src/app/Console/Commands/Job/UserUpdate.php +++ b/src/app/Console/Commands/Job/ResourceCreate.php @@ -3,23 +3,22 @@ namespace App\Console\Commands\Job; use App\Console\Command; -use App\User; -class UserUpdate extends Command +class ResourceCreate extends Command { /** * The name and signature of the console command. * * @var string */ - protected $signature = 'job:userupdate {user}'; + protected $signature = 'job:resourcecreate {resource}'; /** * The console command description. * * @var string */ - protected $description = "Execute the UserUpdate job (again)."; + protected $description = "Execute the resource creation job (again)."; /** * Execute the console command. @@ -28,13 +27,13 @@ */ public function handle() { - $user = $this->getUser($this->argument('user')); + $resource = $this->getResource($this->argument('resource')); - if (!$user) { + if (!$resource) { return 1; } - $job = new \App\Jobs\User\UpdateJob($user->id); + $job = new \App\Jobs\Resource\CreateJob($resource->id); $job->handle(); } } diff --git a/src/app/Console/Commands/Job/UserUpdate.php b/src/app/Console/Commands/Job/ResourceUpdate.php copy from src/app/Console/Commands/Job/UserUpdate.php copy to src/app/Console/Commands/Job/ResourceUpdate.php --- a/src/app/Console/Commands/Job/UserUpdate.php +++ b/src/app/Console/Commands/Job/ResourceUpdate.php @@ -3,23 +3,22 @@ namespace App\Console\Commands\Job; use App\Console\Command; -use App\User; -class UserUpdate extends Command +class ResourceUpdate extends Command { /** * The name and signature of the console command. * * @var string */ - protected $signature = 'job:userupdate {user}'; + protected $signature = 'job:resourceupdate {resource}'; /** * The console command description. * * @var string */ - protected $description = "Execute the UserUpdate job (again)."; + protected $description = "Execute the resource update job (again)."; /** * Execute the console command. @@ -28,13 +27,13 @@ */ public function handle() { - $user = $this->getUser($this->argument('user')); + $resource = $this->getResource($this->argument('resource')); - if (!$user) { + if (!$resource) { return 1; } - $job = new \App\Jobs\User\UpdateJob($user->id); + $job = new \App\Jobs\Resource\UpdateJob($resource->id); $job->handle(); } } diff --git a/src/app/Console/Commands/Job/UserUpdate.php b/src/app/Console/Commands/Job/SharedFolderCreate.php copy from src/app/Console/Commands/Job/UserUpdate.php copy to src/app/Console/Commands/Job/SharedFolderCreate.php --- a/src/app/Console/Commands/Job/UserUpdate.php +++ b/src/app/Console/Commands/Job/SharedFolderCreate.php @@ -3,23 +3,22 @@ namespace App\Console\Commands\Job; use App\Console\Command; -use App\User; -class UserUpdate extends Command +class SharedFolderCreate extends Command { /** * The name and signature of the console command. * * @var string */ - protected $signature = 'job:userupdate {user}'; + protected $signature = 'job:sharedfoldercreate {folder}'; /** * The console command description. * * @var string */ - protected $description = "Execute the UserUpdate job (again)."; + protected $description = "Execute the shared folder creation job (again)."; /** * Execute the console command. @@ -28,13 +27,13 @@ */ public function handle() { - $user = $this->getUser($this->argument('user')); + $folder = $this->getSharedFolder($this->argument('folder')); - if (!$user) { + if (!$folder) { return 1; } - $job = new \App\Jobs\User\UpdateJob($user->id); + $job = new \App\Jobs\SharedFolder\CreateJob($folder->id); $job->handle(); } } diff --git a/src/app/Console/Commands/Job/UserUpdate.php b/src/app/Console/Commands/Job/SharedFolderUpdate.php copy from src/app/Console/Commands/Job/UserUpdate.php copy to src/app/Console/Commands/Job/SharedFolderUpdate.php --- a/src/app/Console/Commands/Job/UserUpdate.php +++ b/src/app/Console/Commands/Job/SharedFolderUpdate.php @@ -3,23 +3,22 @@ namespace App\Console\Commands\Job; use App\Console\Command; -use App\User; -class UserUpdate extends Command +class SharedFolderUpdate extends Command { /** * The name and signature of the console command. * * @var string */ - protected $signature = 'job:userupdate {user}'; + protected $signature = 'job:sharedfolderupdate {folder}'; /** * The console command description. * * @var string */ - protected $description = "Execute the UserUpdate job (again)."; + protected $description = "Execute the shared folder update job (again)."; /** * Execute the console command. @@ -28,13 +27,13 @@ */ public function handle() { - $user = $this->getUser($this->argument('user')); + $folder = $this->getSharedFolder($this->argument('folder')); - if (!$user) { + if (!$folder) { return 1; } - $job = new \App\Jobs\User\UpdateJob($user->id); + $job = new \App\Jobs\SharedFolder\UpdateJob($folder->id); $job->handle(); } } diff --git a/src/app/Console/Commands/Job/UserCreate.php b/src/app/Console/Commands/Job/UserCreate.php --- a/src/app/Console/Commands/Job/UserCreate.php +++ b/src/app/Console/Commands/Job/UserCreate.php @@ -3,7 +3,6 @@ namespace App\Console\Commands\Job; use App\Console\Command; -use App\User; class UserCreate extends Command { @@ -19,7 +18,7 @@ * * @var string */ - protected $description = "Execute the UserCreate job (again)."; + protected $description = "Execute the user creation job (again)."; /** * Execute the console command. diff --git a/src/app/Console/Commands/Job/UserUpdate.php b/src/app/Console/Commands/Job/UserUpdate.php --- a/src/app/Console/Commands/Job/UserUpdate.php +++ b/src/app/Console/Commands/Job/UserUpdate.php @@ -3,7 +3,6 @@ namespace App\Console\Commands\Job; use App\Console\Command; -use App\User; class UserUpdate extends Command { @@ -19,7 +18,7 @@ * * @var string */ - protected $description = "Execute the UserUpdate job (again)."; + protected $description = "Execute the user update job (again)."; /** * Execute the console command. diff --git a/src/app/Http/Controllers/API/V4/DomainsController.php b/src/app/Http/Controllers/API/V4/DomainsController.php --- a/src/app/Http/Controllers/API/V4/DomainsController.php +++ b/src/app/Http/Controllers/API/V4/DomainsController.php @@ -299,20 +299,17 @@ * @param \App\Domain $domain Domain object * @param string $step Step identifier (as in self::statusInfo()) * - * @return bool True if the execution succeeded, False otherwise + * @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(Domain $domain, string $step): bool + public static function execProcessStep(Domain $domain, string $step): ?bool { try { switch ($step) { case 'domain-ldap-ready': - // Domain not in LDAP, create it - if (!$domain->isLdapReady()) { - LDAP::createDomain($domain); - $domain->status |= Domain::STATUS_LDAP_READY; - $domain->save(); - } - return $domain->isLdapReady(); + // Use worker to do the job + \App\Jobs\Domain\CreateJob::dispatch($domain->id); + return null; case 'domain-verified': // Domain existence not verified 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 @@ -160,30 +160,10 @@ switch ($step) { case 'resource-ldap-ready': - // Resource not in LDAP, create it - $job = new \App\Jobs\Resource\CreateJob($resource->id); - $job->handle(); - - $resource->refresh(); - - return $resource->isLdapReady(); - case 'resource-imap-ready': - // Resource 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\Resource\VerifyJob::dispatch($resource->id); - - return null; - } - - $job = new \App\Jobs\Resource\VerifyJob($resource->id); - $job->handle(); - - $resource->refresh(); - - return $resource->isImapReady(); + // Use worker to do the job, frontend might not have the IMAP admin credentials + \App\Jobs\Resource\CreateJob::dispatch($resource->id); + return null; } } catch (\Exception $e) { \Log::error($e); diff --git a/src/app/Http/Controllers/API/V4/SharedFoldersController.php b/src/app/Http/Controllers/API/V4/SharedFoldersController.php --- a/src/app/Http/Controllers/API/V4/SharedFoldersController.php +++ b/src/app/Http/Controllers/API/V4/SharedFoldersController.php @@ -156,30 +156,10 @@ 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(); + // Use worker to do the job, frontend might not have the IMAP admin credentials + \App\Jobs\SharedFolder\CreateJob::dispatch($folder->id); + return null; } } catch (\Exception $e) { \Log::error($e); 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 @@ -532,30 +532,10 @@ switch ($step) { case 'user-ldap-ready': - // User not in LDAP, create it - $job = new \App\Jobs\User\CreateJob($user->id); - $job->handle(); - - $user->refresh(); - - return $user->isLdapReady(); - case 'user-imap-ready': - // User 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\User\VerifyJob::dispatch($user->id); - - return null; - } - - $job = new \App\Jobs\User\VerifyJob($user->id); - $job->handle(); - - $user->refresh(); - - return $user->isImapReady(); + // Use worker to do the job, frontend might not have the IMAP admin credentials + \App\Jobs\User\CreateJob::dispatch($user->id); + return null; } } catch (\Exception $e) { \Log::error($e); diff --git a/src/app/Jobs/Group/CreateJob.php b/src/app/Jobs/Group/CreateJob.php --- a/src/app/Jobs/Group/CreateJob.php +++ b/src/app/Jobs/Group/CreateJob.php @@ -23,7 +23,9 @@ \App\Backends\LDAP::createGroup($group); $group->status |= \App\Group::STATUS_LDAP_READY; - $group->save(); } + + $group->status |= \App\Group::STATUS_ACTIVE; + $group->save(); } } diff --git a/src/app/Jobs/Group/DeleteJob.php b/src/app/Jobs/Group/DeleteJob.php --- a/src/app/Jobs/Group/DeleteJob.php +++ b/src/app/Jobs/Group/DeleteJob.php @@ -25,14 +25,17 @@ return; } - \App\Backends\LDAP::deleteGroup($group); - - $group->status |= \App\Group::STATUS_DELETED; - if ($group->isLdapReady()) { + \App\Backends\LDAP::deleteGroup($group); + $group->status ^= \App\Group::STATUS_LDAP_READY; } - +/* + if (!\App\Backends\IMAP::deleteGroup($group)) { + throw new \Exception("Failed to delete group {$this->groupId} from IMAP."); + } +*/ + $group->status |= \App\Group::STATUS_DELETED; $group->save(); } } diff --git a/src/app/Jobs/IMAP/AclCleanupJob.php b/src/app/Jobs/IMAP/AclCleanupJob.php new file mode 100644 --- /dev/null +++ b/src/app/Jobs/IMAP/AclCleanupJob.php @@ -0,0 +1,59 @@ +ident = $ident; + $this->domain = $domain; + } + + /** + * Execute the job. + * + * @return void + * + * @throws \Exception + */ + public function handle() + { + \App\Backends\IMAP::aclCleanup($this->ident, $this->domain); + } +} diff --git a/src/app/Jobs/Resource/CreateJob.php b/src/app/Jobs/Resource/CreateJob.php --- a/src/app/Jobs/Resource/CreateJob.php +++ b/src/app/Jobs/Resource/CreateJob.php @@ -30,11 +30,6 @@ return; } - if ($resource->isLdapReady()) { - $this->fail(new \Exception("Resource {$this->resourceId} is already marked as ldap-ready.")); - return; - } - // see if the domain is ready $domain = $resource->domain(); @@ -53,9 +48,22 @@ return; } - \App\Backends\LDAP::createResource($resource); + if (!$resource->isLdapReady()) { + \App\Backends\LDAP::createResource($resource); + + $resource->status |= \App\Resource::STATUS_LDAP_READY; + $resource->save(); + } + + if (!$resource->isImapReady()) { + if (!\App\Backends\IMAP::createResource($resource)) { + throw new \Exception("Failed to create mailbox for resource {$this->resourceId}."); + } + + $resource->status |= \App\Resource::STATUS_IMAP_READY; + } - $resource->status |= \App\Resource::STATUS_LDAP_READY; + $resource->status |= \App\Resource::STATUS_ACTIVE; $resource->save(); } } diff --git a/src/app/Jobs/Resource/DeleteJob.php b/src/app/Jobs/Resource/DeleteJob.php --- a/src/app/Jobs/Resource/DeleteJob.php +++ b/src/app/Jobs/Resource/DeleteJob.php @@ -25,18 +25,22 @@ return; } - \App\Backends\LDAP::deleteResource($resource); - - $resource->status |= \App\Resource::STATUS_DELETED; - if ($resource->isLdapReady()) { + \App\Backends\LDAP::deleteResource($resource); + $resource->status ^= \App\Resource::STATUS_LDAP_READY; + $resource->save(); } if ($resource->isImapReady()) { + if (!\App\Backends\IMAP::deleteResource($resource)) { + throw new \Exception("Failed to delete mailbox for resource {$this->resourceId}."); + } + $resource->status ^= \App\Resource::STATUS_IMAP_READY; } + $resource->status |= \App\Resource::STATUS_DELETED; $resource->save(); } } diff --git a/src/app/Jobs/Resource/UpdateJob.php b/src/app/Jobs/Resource/UpdateJob.php --- a/src/app/Jobs/Resource/UpdateJob.php +++ b/src/app/Jobs/Resource/UpdateJob.php @@ -19,12 +19,20 @@ return; } - // Cancel the update if the resource is deleted or not yet in LDAP - if (!$resource->isLdapReady() || $resource->isDeleted()) { + // Cancel the update if the resource is deleted + if ($resource->isDeleted()) { $this->delete(); return; } - \App\Backends\LDAP::updateResource($resource); + if ($resource->isLdapReady()) { + \App\Backends\LDAP::updateResource($resource); + } + + if ($resource->isImapReady()) { + if (!\App\Backends\IMAP::updateResource($resource, $this->properties)) { + throw new \Exception("Failed to update mailbox for resource {$this->resourceId}."); + } + } } } diff --git a/src/app/Jobs/ResourceJob.php b/src/app/Jobs/ResourceJob.php --- a/src/app/Jobs/ResourceJob.php +++ b/src/app/Jobs/ResourceJob.php @@ -13,6 +13,13 @@ */ abstract class ResourceJob extends CommonJob { + /** + * Old values of the resource properties on update (key -> value) + * + * @var array + */ + protected $properties = []; + /** * The ID for the \App\Resource. This is the shortest globally unique identifier and saves Redis space * compared to a serialized version of the complete \App\Resource object. @@ -31,13 +38,15 @@ /** * Create a new job instance. * - * @param int $resourceId The ID for the resource to process. + * @param int $resourceId The ID for the resource to process. + * @param array $properties Old values of the resource properties on update * * @return void */ - public function __construct(int $resourceId) + public function __construct(int $resourceId, array $properties = []) { $this->resourceId = $resourceId; + $this->properties = $properties; $resource = $this->getResource(); diff --git a/src/app/Jobs/SharedFolder/CreateJob.php b/src/app/Jobs/SharedFolder/CreateJob.php --- a/src/app/Jobs/SharedFolder/CreateJob.php +++ b/src/app/Jobs/SharedFolder/CreateJob.php @@ -30,11 +30,6 @@ 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(); @@ -53,9 +48,22 @@ return; } - \App\Backends\LDAP::createSharedFolder($folder); + if (!$folder->isLdapReady()) { + \App\Backends\LDAP::createSharedFolder($folder); + + $folder->status |= \App\SharedFolder::STATUS_LDAP_READY; + $folder->save(); + } + + if (!$folder->isImapReady()) { + if (!\App\Backends\IMAP::createSharedFolder($folder)) { + throw new \Exception("Failed to create mailbox for shared folder {$this->folderId}."); + } + + $folder->status |= \App\SharedFolder::STATUS_IMAP_READY; + } - $folder->status |= \App\SharedFolder::STATUS_LDAP_READY; + $folder->status |= \App\SharedFolder::STATUS_ACTIVE; $folder->save(); } } diff --git a/src/app/Jobs/SharedFolder/DeleteJob.php b/src/app/Jobs/SharedFolder/DeleteJob.php --- a/src/app/Jobs/SharedFolder/DeleteJob.php +++ b/src/app/Jobs/SharedFolder/DeleteJob.php @@ -25,18 +25,22 @@ return; } - \App\Backends\LDAP::deleteSharedFolder($folder); - - $folder->status |= \App\SharedFolder::STATUS_DELETED; - if ($folder->isLdapReady()) { + \App\Backends\LDAP::deleteSharedFolder($folder); + $folder->status ^= \App\SharedFolder::STATUS_LDAP_READY; + $folder->save(); } if ($folder->isImapReady()) { + if (!\App\Backends\IMAP::deleteSharedFolder($folder)) { + throw new \Exception("Failed to delete mailbox for shared folder {$this->folderId}."); + } + $folder->status ^= \App\SharedFolder::STATUS_IMAP_READY; } + $folder->status |= \App\SharedFolder::STATUS_DELETED; $folder->save(); } } diff --git a/src/app/Jobs/SharedFolder/UpdateJob.php b/src/app/Jobs/SharedFolder/UpdateJob.php --- a/src/app/Jobs/SharedFolder/UpdateJob.php +++ b/src/app/Jobs/SharedFolder/UpdateJob.php @@ -19,12 +19,20 @@ return; } - // Cancel the update if the folder is deleted or not yet in LDAP - if (!$folder->isLdapReady() || $folder->isDeleted()) { + // Cancel the update if the folder is deleted + if ($folder->isDeleted()) { $this->delete(); return; } - \App\Backends\LDAP::updateSharedFolder($folder); + if ($folder->isLdapReady()) { + \App\Backends\LDAP::updateSharedFolder($folder); + } + + if ($folder->isImapReady()) { + if (!\App\Backends\IMAP::updateSharedFolder($folder, $this->properties)) { + throw new \Exception("Failed to update mailbox for shared folder {$this->folderId}."); + } + } } } diff --git a/src/app/Jobs/SharedFolderJob.php b/src/app/Jobs/SharedFolderJob.php --- a/src/app/Jobs/SharedFolderJob.php +++ b/src/app/Jobs/SharedFolderJob.php @@ -27,16 +27,25 @@ */ protected $folderEmail; + /** + * Old values of the shared folder properties on update (key -> value) + * + * @var array + */ + protected $properties = []; + /** * Create a new job instance. * - * @param int $folderId The ID for the shared folder to process. + * @param int $folderId The ID for the shared folder to process + * @param array $properties Old values of the shared folder properties on update (key -> value) * * @return void */ - public function __construct(int $folderId) + public function __construct(int $folderId, array $properties = []) { $this->folderId = $folderId; + $this->properties = $properties; $folder = $this->getSharedFolder(); diff --git a/src/app/Jobs/User/CreateJob.php b/src/app/Jobs/User/CreateJob.php --- a/src/app/Jobs/User/CreateJob.php +++ b/src/app/Jobs/User/CreateJob.php @@ -42,16 +42,11 @@ return; } - if ($user->deleted_at) { + if ($user->trashed()) { $this->fail(new \Exception("User {$this->userId} is actually deleted.")); return; } - if ($user->isLdapReady()) { - $this->fail(new \Exception("User {$this->userId} is already marked as ldap-ready.")); - return; - } - // see if the domain is ready $domain = $user->domain(); @@ -70,9 +65,22 @@ return; } - \App\Backends\LDAP::createUser($user); + if (!$user->isLdapReady()) { + \App\Backends\LDAP::createUser($user); + + $user->status |= \App\User::STATUS_LDAP_READY; + $user->save(); + } + + if (!$user->isImapReady()) { + if (!\App\Backends\IMAP::createUser($user)) { + throw new \Exception("Failed to create mailbox for user {$this->userId}."); + } + + $user->status |= \App\User::STATUS_IMAP_READY; + } - $user->status |= \App\User::STATUS_LDAP_READY; + $user->status |= \App\User::STATUS_ACTIVE; $user->save(); } } diff --git a/src/app/Jobs/User/DeleteJob.php b/src/app/Jobs/User/DeleteJob.php --- a/src/app/Jobs/User/DeleteJob.php +++ b/src/app/Jobs/User/DeleteJob.php @@ -30,18 +30,22 @@ return; } - \App\Backends\LDAP::deleteUser($user); - - $user->status |= \App\User::STATUS_DELETED; - if ($user->isLdapReady()) { + \App\Backends\LDAP::deleteUser($user); + $user->status ^= \App\User::STATUS_LDAP_READY; + $user->save(); } if ($user->isImapReady()) { + if (!\App\Backends\IMAP::deleteUser($user)) { + throw new \Exception("Failed to delete mailbox for user {$this->userId}."); + } + $user->status ^= \App\User::STATUS_IMAP_READY; } + $user->status |= \App\User::STATUS_DELETED; $user->save(); } } diff --git a/src/app/Jobs/User/UpdateJob.php b/src/app/Jobs/User/UpdateJob.php --- a/src/app/Jobs/User/UpdateJob.php +++ b/src/app/Jobs/User/UpdateJob.php @@ -24,11 +24,14 @@ return; } - if (!$user->isLdapReady()) { - $this->delete(); - return; + if ($user->isLdapReady()) { + \App\Backends\LDAP::updateUser($user); } - \App\Backends\LDAP::updateUser($user); + if ($user->isImapReady()) { + if (!\App\Backends\IMAP::updateUser($user)) { + throw new \Exception("Failed to update mailbox for user {$this->userId}."); + } + } } } diff --git a/src/app/Observers/EntitlementObserver.php b/src/app/Observers/EntitlementObserver.php --- a/src/app/Observers/EntitlementObserver.php +++ b/src/app/Observers/EntitlementObserver.php @@ -61,6 +61,11 @@ $entitlement->entitleable->save(); $entitlement->createTransaction(\App\Transaction::ENTITLEMENT_CREATED); + + // Update the user IMAP mailbox quota + if ($entitlement->sku->title == 'storage') { + \App\Jobs\User\UpdateJob::dispatch($entitlement->entitleable_id); + } } /** @@ -72,6 +77,13 @@ */ public function deleted(Entitlement $entitlement) { + if (!$entitlement->entitleable->trashed()) { + $entitlement->entitleable->updated_at = Carbon::now(); + $entitlement->entitleable->save(); + + $entitlement->createTransaction(\App\Transaction::ENTITLEMENT_DELETED); + } + // Remove all configured 2FA methods from Roundcube database if ($entitlement->sku->title == '2fa') { // FIXME: Should that be an async job? @@ -79,11 +91,9 @@ $sf->removeFactors(); } - if (!$entitlement->entitleable->trashed()) { - $entitlement->entitleable->updated_at = Carbon::now(); - $entitlement->entitleable->save(); - - $entitlement->createTransaction(\App\Transaction::ENTITLEMENT_DELETED); + // Update the user IMAP mailbox quota + if ($entitlement->sku->title == 'storage') { + \App\Jobs\User\UpdateJob::dispatch($entitlement->entitleable_id); } } diff --git a/src/app/Observers/GroupObserver.php b/src/app/Observers/GroupObserver.php --- a/src/app/Observers/GroupObserver.php +++ b/src/app/Observers/GroupObserver.php @@ -16,7 +16,7 @@ */ public function creating(Group $group): void { - $group->status |= Group::STATUS_NEW | Group::STATUS_ACTIVE; + $group->status |= Group::STATUS_NEW; if (!isset($group->name) && isset($group->email)) { $group->name = explode('@', $group->email)[0]; diff --git a/src/app/Observers/ResourceObserver.php b/src/app/Observers/ResourceObserver.php --- a/src/app/Observers/ResourceObserver.php +++ b/src/app/Observers/ResourceObserver.php @@ -15,7 +15,7 @@ */ public function creating(Resource $resource): void { - $resource->status |= Resource::STATUS_NEW | Resource::STATUS_ACTIVE; + $resource->status |= Resource::STATUS_NEW; } /** @@ -45,12 +45,8 @@ // Note: This is a single multi-insert query $resource->settings()->insert(array_values($settings)); - // Create resource record in LDAP, then check if it is created in IMAP - $chain = [ - new \App\Jobs\Resource\VerifyJob($resource->id), - ]; - - \App\Jobs\Resource\CreateJob::withChain($chain)->dispatch($resource->id); + // Create the resource in the backend (LDAP and IMAP) + \App\Jobs\Resource\CreateJob::dispatch($resource->id); } /** diff --git a/src/app/Observers/ResourceSettingObserver.php b/src/app/Observers/ResourceSettingObserver.php --- a/src/app/Observers/ResourceSettingObserver.php +++ b/src/app/Observers/ResourceSettingObserver.php @@ -17,7 +17,8 @@ public function created(ResourceSetting $resourceSetting) { if (in_array($resourceSetting->key, LDAP::RESOURCE_SETTINGS)) { - \App\Jobs\Resource\UpdateJob::dispatch($resourceSetting->resource_id); + $props = [$resourceSetting->key => null]; + \App\Jobs\Resource\UpdateJob::dispatch($resourceSetting->resource_id, $props); } } @@ -31,7 +32,8 @@ public function updated(ResourceSetting $resourceSetting) { if (in_array($resourceSetting->key, LDAP::RESOURCE_SETTINGS)) { - \App\Jobs\Resource\UpdateJob::dispatch($resourceSetting->resource_id); + $props = [$resourceSetting->key => $resourceSetting->getOriginal('value')]; + \App\Jobs\Resource\UpdateJob::dispatch($resourceSetting->resource_id, $props); } } @@ -45,7 +47,8 @@ public function deleted(ResourceSetting $resourceSetting) { if (in_array($resourceSetting->key, LDAP::RESOURCE_SETTINGS)) { - \App\Jobs\Resource\UpdateJob::dispatch($resourceSetting->resource_id); + $props = [$resourceSetting->key => $resourceSetting->getOriginal('value')]; + \App\Jobs\Resource\UpdateJob::dispatch($resourceSetting->resource_id, $props); } } } diff --git a/src/app/Observers/SharedFolderObserver.php b/src/app/Observers/SharedFolderObserver.php --- a/src/app/Observers/SharedFolderObserver.php +++ b/src/app/Observers/SharedFolderObserver.php @@ -19,7 +19,7 @@ $folder->type = 'mail'; } - $folder->status |= SharedFolder::STATUS_NEW | SharedFolder::STATUS_ACTIVE; + $folder->status |= SharedFolder::STATUS_NEW; } /** @@ -49,12 +49,8 @@ // 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); + // Create the shared folder in the backend (LDAP and IMAP) + \App\Jobs\SharedFolder\CreateJob::dispatch($folder->id); } /** diff --git a/src/app/Observers/SharedFolderSettingObserver.php b/src/app/Observers/SharedFolderSettingObserver.php --- a/src/app/Observers/SharedFolderSettingObserver.php +++ b/src/app/Observers/SharedFolderSettingObserver.php @@ -17,7 +17,8 @@ public function created(SharedFolderSetting $folderSetting) { if (in_array($folderSetting->key, LDAP::SHARED_FOLDER_SETTINGS)) { - \App\Jobs\SharedFolder\UpdateJob::dispatch($folderSetting->shared_folder_id); + $props = [$folderSetting->key => null]; + \App\Jobs\SharedFolder\UpdateJob::dispatch($folderSetting->shared_folder_id, $props); } } @@ -31,7 +32,8 @@ public function updated(SharedFolderSetting $folderSetting) { if (in_array($folderSetting->key, LDAP::SHARED_FOLDER_SETTINGS)) { - \App\Jobs\SharedFolder\UpdateJob::dispatch($folderSetting->shared_folder_id); + $props = [$folderSetting->key => $folderSetting->getOriginal('value')]; + \App\Jobs\SharedFolder\UpdateJob::dispatch($folderSetting->shared_folder_id, $props); } } @@ -45,7 +47,8 @@ public function deleted(SharedFolderSetting $folderSetting) { if (in_array($folderSetting->key, LDAP::SHARED_FOLDER_SETTINGS)) { - \App\Jobs\SharedFolder\UpdateJob::dispatch($folderSetting->shared_folder_id); + $props = [$folderSetting->key => $folderSetting->getOriginal('value')]; + \App\Jobs\SharedFolder\UpdateJob::dispatch($folderSetting->shared_folder_id, $props); } } } 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 @@ -20,8 +20,7 @@ { $user->email = \strtolower($user->email); - // only users that are not imported get the benefit of the doubt. - $user->status |= User::STATUS_NEW | User::STATUS_ACTIVE; + $user->status |= User::STATUS_NEW; } /** @@ -64,12 +63,8 @@ $user->wallets()->create(); - // Create user record in LDAP, then check if the account is created in IMAP - $chain = [ - new \App\Jobs\User\VerifyJob($user->id), - ]; - - \App\Jobs\User\CreateJob::withChain($chain)->dispatch($user->id); + // Create user record in the backend (LDAP and IMAP) + \App\Jobs\User\CreateJob::dispatch($user->id); if (\App\Tenant::getConfig($user->tenant_id, 'pgp.enable')) { \App\Jobs\PGP\KeyCreateJob::dispatch($user->id, $user->email); @@ -183,12 +178,8 @@ // FIXME: Should we reset user aliases? or re-validate them in any way? - // Create user record in LDAP, then run the verification process - $chain = [ - new \App\Jobs\User\VerifyJob($user->id), - ]; - - \App\Jobs\User\CreateJob::withChain($chain)->dispatch($user->id); + // Create user record in the backend (LDAP and IMAP) + \App\Jobs\User\CreateJob::dispatch($user->id); } /** diff --git a/src/app/Traits/EntitleableTrait.php b/src/app/Traits/EntitleableTrait.php --- a/src/app/Traits/EntitleableTrait.php +++ b/src/app/Traits/EntitleableTrait.php @@ -156,6 +156,24 @@ }); } + /** + * Count entitlements for the specified SKU. + * + * @param string $title The SKU title + * + * @return int Numer of entitlements + */ + public function countEntitlementsBySku(string $title): int + { + $sku = $this->skuByTitle($title); + + if (!$sku) { + return 0; + } + + return $this->entitlements()->where('sku_id', $sku->id)->count(); + } + /** * Entitlements for this object. * @@ -176,13 +194,7 @@ */ public function hasSku(string $title): bool { - $sku = $this->skuByTitle($title); - - if (!$sku) { - return false; - } - - return $this->entitlements()->where('sku_id', $sku->id)->count() > 0; + return $this->countEntitlementsBySku($title) > 0; } /** diff --git a/src/include/rcube_imap_generic.php b/src/include/rcube_imap_generic.php --- a/src/include/rcube_imap_generic.php +++ b/src/include/rcube_imap_generic.php @@ -3180,6 +3180,28 @@ return $result; } + /** + * Send the SETQUOTA command (RFC9208) + * + * @param string $root Quota root + * @param array $quota Quota limits e.g. ['storage' => 1024000'] + * + * @return boolean True on success, False on failure + */ + public function setQuota($root, $quota) + { + $fn = function ($key, $value) { + return strtoupper($key) . ' ' . $value; + }; + + $quota = implode(' ', array_map($fn, array_keys($quota), $quota)); + + $result = $this->execute('SETQUOTA', [$this->escape($root), "({$quota})"], + self::COMMAND_NORESPONSE); + + return ($result == self::ERROR_OK); + } + /** * Send the SETACL command (RFC4314) * diff --git a/src/tests/Browser/StatusTest.php b/src/tests/Browser/StatusTest.php --- a/src/tests/Browser/StatusTest.php +++ b/src/tests/Browser/StatusTest.php @@ -112,7 +112,7 @@ $browser->waitUntilMissing('@status', 10); }); - // Test the Refresh button + // Test the Refresh button if ($domain->isConfirmed()) { $domain->status ^= Domain::STATUS_CONFIRMED; $domain->save(); @@ -134,17 +134,15 @@ ->assertVisible('@refresh-button') ->assertVisible('@refresh-text'); - if ($john->refresh()->isImapReady()) { - $john->status ^= User::STATUS_IMAP_READY; - $john->save(); - } + $browser->click('@refresh-button') + ->assertToast(Toast::TYPE_SUCCESS, 'Setup process has been pushed. Please wait.'); + + $john->status |= User::STATUS_IMAP_READY; + $john->save(); $domain->status |= Domain::STATUS_CONFIRMED; $domain->save(); - - $browser->click('@refresh-button') - ->assertToast(Toast::TYPE_SUCCESS, 'Setup process finished successfully.'); }) - ->assertMissing('@status'); + ->waitUntilMissing('@status', 10); }); } 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 @@ -3,10 +3,225 @@ namespace Tests\Feature\Backends; use App\Backends\IMAP; +use App\Backends\LDAP; use Tests\TestCase; class IMAPTest extends TestCase { + private $imap; + private $user; + private $group; + private $resource; + private $folder; + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + if ($this->imap) { + $this->imap->closeConnection(); + $this->imap = null; + } + + if ($this->user) { + $this->deleteTestUser($this->user->email); + } + if ($this->group) { + $this->deleteTestGroup($this->group->email); + } + if ($this->resource) { + $this->deleteTestResource($this->resource->email); + } + if ($this->folder) { + $this->deleteTestSharedFolder($this->folder->email); + } + + parent::tearDown(); + } + + /** + * Test aclCleanup() + * + * @group imap + * @group ldap + */ + public function testAclCleanup(): void + { + $this->user = $user = $this->getTestUser('test-' . time() . '@kolab.org'); + $this->group = $group = $this->getTestGroup('test-group-' . time() . '@kolab.org'); + + // SETACL requires that the user/group exists in LDAP + LDAP::createUser($user); + // LDAP::createGroup($group); + + // First, set some ACLs that we'll expect to be removed later + $imap = $this->getImap(); + + $this->assertTrue($imap->setACL('user/john@kolab.org', $user->email, 'lrs')); + $this->assertTrue($imap->setACL('shared/Resources/Conference Room #1@kolab.org', $user->email, 'lrs')); +/* + $this->assertTrue($imap->setACL('user/john@kolab.org', $group->name, 'lrs')); + $this->assertTrue($imap->setACL('shared/Resources/Conference Room #1@kolab.org', $group->name, 'lrs')); +*/ + // Cleanup ACL of a user + IMAP::aclCleanup($user->email); + + $acl = $imap->getACL('user/john@kolab.org'); + $this->assertTrue(is_array($acl) && !isset($acl[$user->email])); + $acl = $imap->getACL('shared/Resources/Conference Room #1@kolab.org'); + $this->assertTrue(is_array($acl) && !isset($acl[$user->email])); + +/* + // Cleanup ACL of a group + IMAP::aclCleanup($group->name, 'kolab.org'); + + $acl = $imap->getACL('user/john@kolab.org'); + $this->assertTrue(is_array($acl) && !isset($acl[$user->email])); + $acl = $imap->getACL('shared/Resources/Conference Room #1@kolab.org'); + $this->assertTrue(is_array($acl) && !isset($acl[$user->email])); +*/ + } + + /** + * Test creating/updating/deleting an IMAP account + * + * @group imap + */ + public function testUsers(): void + { + $this->user = $user = $this->getTestUser('test-' . time() . '@' . \config('app.domain')); + $storage = \App\Sku::withEnvTenantContext()->where('title', 'storage')->first(); + $user->assignSku($storage, 1, $user->wallets->first()); + + $expectedQuota = [ + 'user/' . $user->email => [ + 'storage' => [ + 'used' => 0, + 'total' => 1048576 + ] + ] + ]; + + // Create the mailbox + $result = IMAP::createUser($user); + $this->assertTrue($result); + // $this->assertTrue(IMAP::verifyAccount($user->email)); + + $imap = $this->getImap(); + $quota = $imap->getQuota('user/' . $user->email); + $this->assertSame($expectedQuota, $quota['all']); + + // Update the mailbox (increase quota) + $user->assignSku($storage, 1, $user->wallets->first()); + $expectedQuota['user/' . $user->email]['storage']['total'] = 1048576 * 2; + + $result = IMAP::updateUser($user); + $this->assertTrue($result); + + $quota = $imap->getQuota('user/' . $user->email); + $this->assertSame($expectedQuota, $quota['all']); + + // Delete the mailbox + $result = IMAP::deleteUser($user); + $this->assertTrue($result); + + $this->expectException(\Exception::class); + IMAP::verifyAccount($user->email); + } + + /** + * Test creating/updating/deleting a resource + * + * @group imap + */ + public function testResources(): void + { + $this->resource = $resource = $this->getTestResource( + 'test-resource-' . time() . '@kolab.org', + ['name' => 'Resource ©' . time()] + ); + + $resource->setSetting('invitation_policy', 'manual:john@kolab.org'); + + // Create the resource + $this->assertTrue(IMAP::createResource($resource)); + $this->assertTrue(IMAP::verifySharedFolder($imapFolder = $resource->getSetting('folder'))); + + $imap = $this->getImap(); + $expectedAcl = ['john@kolab.org' => str_split('lrswipkxtecdn')]; + $this->assertSame($expectedAcl, $imap->getACL(IMAP::toUTF7($imapFolder))); + + // Update the resource (rename) + $resource->name = 'Resource1 ©' . time(); + $resource->save(); + $newImapFolder = $resource->getSetting('folder'); + + $this->assertTrue(IMAP::updateResource($resource, ['folder' => $imapFolder])); + $this->assertTrue($imapFolder != $newImapFolder); + $this->assertTrue(IMAP::verifySharedFolder($newImapFolder)); + $this->assertSame($expectedAcl, $imap->getACL(IMAP::toUTF7($newImapFolder))); + + // Update the resource (acl change) + $resource->setSetting('invitation_policy', 'accept'); + $this->assertTrue(IMAP::updateResource($resource)); + $this->assertSame([], $imap->getACL(IMAP::toUTF7($newImapFolder))); + + // Delete the resource + $this->assertTrue(IMAP::deleteResource($resource)); + $this->assertFalse(IMAP::verifySharedFolder($newImapFolder)); + } + + /** + * Test creating/updating/deleting a shared folder + * + * @group imap + */ + public function testSharedFolders(): void + { + $this->folder = $folder = $this->getTestSharedFolder( + 'test-folder-' . time() . '@kolab.org', + ['name' => 'SharedFolder ©' . time()] + ); + + $folder->setSetting('acl', json_encode(['john@kolab.org, full', 'jack@kolab.org, read-only'])); + + // Create the shared folder + $this->assertTrue(IMAP::createSharedFolder($folder)); + $this->assertTrue(IMAP::verifySharedFolder($imapFolder = $folder->getSetting('folder'))); + + $imap = $this->getImap(); + $expectedAcl = [ + 'john@kolab.org' => str_split('lrswipkxtecdn'), + 'jack@kolab.org' => str_split('lrs') + ]; + + $this->assertSame($expectedAcl, $imap->getACL(IMAP::toUTF7($imapFolder))); + + // Update shared folder (acl) + $folder->setSetting('acl', json_encode(['jack@kolab.org, read-only'])); + + $this->assertTrue(IMAP::updateSharedFolder($folder)); + + $expectedAcl = ['jack@kolab.org' => str_split('lrs')]; + + $this->assertSame($expectedAcl, $imap->getACL(IMAP::toUTF7($imapFolder))); + + // Update the shared folder (rename) + $folder->name = 'SharedFolder1 ©' . time(); + $folder->save(); + $newImapFolder = $folder->getSetting('folder'); + + $this->assertTrue(IMAP::updateSharedFolder($folder, ['folder' => $imapFolder])); + $this->assertTrue($imapFolder != $newImapFolder); + $this->assertTrue(IMAP::verifySharedFolder($newImapFolder)); + $this->assertSame($expectedAcl, $imap->getACL(IMAP::toUTF7($newImapFolder))); + + // Delete the shared folder + $this->assertTrue(IMAP::deleteSharedFolder($folder)); + $this->assertFalse(IMAP::verifySharedFolder($newImapFolder)); + } + /** * Test verifying IMAP account existence (existing account) * @@ -38,4 +253,24 @@ $result = IMAP::verifySharedFolder('shared/Calendar@kolab.org'); $this->assertTrue($result); } + + /** + * Get configured/initialized rcube_imap_generic instance + */ + private function getImap() + { + if ($this->imap) { + return $this->imap; + } + + $class = new \ReflectionClass(IMAP::class); + $init = $class->getMethod('initIMAP'); + $config = $class->getMethod('getConfig'); + $init->setAccessible(true); + $config->setAccessible(true); + + $config = $config->invoke(null); + + return $this->imap = $init->invokeArgs(null, [$config]); + } } diff --git a/src/tests/Feature/Console/User/StatusTest.php b/src/tests/Feature/Console/User/StatusTest.php --- a/src/tests/Feature/Console/User/StatusTest.php +++ b/src/tests/Feature/Console/User/StatusTest.php @@ -2,6 +2,7 @@ namespace Tests\Feature\Console\User; +use App\User; use Illuminate\Support\Facades\Queue; use Tests\TestCase; @@ -41,14 +42,20 @@ $this->assertSame(1, $code); $this->assertSame("User not found.", $output); + $user = $this->getTestUser( + 'user@force-delete.com', + ['status' => User::STATUS_NEW | User::STATUS_ACTIVE | User::STATUS_IMAP_READY | User::STATUS_LDAP_READY] + ); + // Existing user - $code = \Artisan::call("user:status john@kolab.org"); + $code = \Artisan::call("user:status {$user->email}"); $output = trim(\Artisan::output()); $this->assertSame(0, $code); $this->assertSame("Status (51): active (2), ldapReady (16), imapReady (32)", $output); - $user = $this->getTestUser('user@force-delete.com'); + $user->status = User::STATUS_ACTIVE; + $user->save(); $user->delete(); // Deleted user @@ -56,6 +63,6 @@ $output = trim(\Artisan::output()); $this->assertSame(0, $code); - $this->assertSame("Status (3): active (2), deleted (8)", $output); + $this->assertSame("Status (2): active (2), deleted (8)", $output); } } diff --git a/src/tests/Feature/Controller/AuthTest.php b/src/tests/Feature/Controller/AuthTest.php --- a/src/tests/Feature/Controller/AuthTest.php +++ b/src/tests/Feature/Controller/AuthTest.php @@ -69,7 +69,7 @@ */ public function testInfo(): void { - $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); + $user = $this->getTestUser('UsersControllerTest1@userscontroller.com', ['status' => User::STATUS_NEW]); $domain = $this->getTestDomain('userscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_PUBLIC, @@ -85,7 +85,7 @@ $this->assertEquals($user->id, $json['id']); $this->assertEquals($user->email, $json['email']); - $this->assertEquals(User::STATUS_NEW | User::STATUS_ACTIVE, $json['status']); + $this->assertEquals(User::STATUS_NEW, $json['status']); $this->assertTrue(is_array($json['statusInfo'])); $this->assertTrue(is_array($json['settings'])); $this->assertTrue(!isset($json['access_token'])); diff --git a/src/tests/Feature/Controller/ResourcesTest.php b/src/tests/Feature/Controller/ResourcesTest.php --- a/src/tests/Feature/Controller/ResourcesTest.php +++ b/src/tests/Feature/Controller/ResourcesTest.php @@ -339,25 +339,29 @@ $resource->status |= Resource::STATUS_IMAP_READY; $resource->save(); - // Now "reboot" the process and get the resource status + // Now "reboot" the process + Queue::fake(); $response = $this->actingAs($john)->get("/api/v4/resources/{$resource->id}/status?refresh=1"); $response->assertStatus(200); $json = $response->json(); - $this->assertTrue($json['isLdapReady']); + $this->assertFalse($json['isLdapReady']); $this->assertTrue($json['isImapReady']); - $this->assertTrue($json['isReady']); + $this->assertFalse($json['isReady']); $this->assertCount(7, $json['process']); $this->assertSame('resource-ldap-ready', $json['process'][1]['label']); - $this->assertSame(true, $json['process'][1]['state']); + $this->assertSame(false, $json['process'][1]['state']); $this->assertSame('resource-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']); + $this->assertSame('Setup process has been pushed. Please wait.', $json['message']); + $this->assertSame('waiting', $json['processState']); + + Queue::assertPushed(\App\Jobs\Resource\CreateJob::class, 1); // Test a case when a domain is not ready + Queue::fake(); $domain->status ^= \App\Domain::STATUS_CONFIRMED; $domain->save(); @@ -366,13 +370,16 @@ $json = $response->json(); - $this->assertTrue($json['isLdapReady']); - $this->assertTrue($json['isReady']); + $this->assertFalse($json['isLdapReady']); + $this->assertFalse($json['isReady']); $this->assertCount(7, $json['process']); $this->assertSame('resource-ldap-ready', $json['process'][1]['label']); - $this->assertSame(true, $json['process'][1]['state']); + $this->assertSame(false, $json['process'][1]['state']); $this->assertSame('success', $json['status']); - $this->assertSame('Setup process finished successfully.', $json['message']); + $this->assertSame('Setup process has been pushed. Please wait.', $json['message']); + $this->assertSame('waiting', $json['processState']); + + Queue::assertPushed(\App\Jobs\Resource\CreateJob::class, 1); } /** diff --git a/src/tests/Feature/Controller/SharedFoldersTest.php b/src/tests/Feature/Controller/SharedFoldersTest.php --- a/src/tests/Feature/Controller/SharedFoldersTest.php +++ b/src/tests/Feature/Controller/SharedFoldersTest.php @@ -346,25 +346,29 @@ $folder->status |= SharedFolder::STATUS_IMAP_READY; $folder->save(); - // Now "reboot" the process and get the folder status + // Now "reboot" the process + Queue::fake(); $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->assertFalse($json['isLdapReady']); $this->assertTrue($json['isImapReady']); - $this->assertTrue($json['isReady']); + $this->assertFalse($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(false, $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']); + $this->assertSame('Setup process has been pushed. Please wait.', $json['message']); + $this->assertSame('waiting', $json['processState']); + + Queue::assertPushed(\App\Jobs\SharedFolder\CreateJob::class, 1); // Test a case when a domain is not ready + Queue::fake(); $domain->status ^= \App\Domain::STATUS_CONFIRMED; $domain->save(); @@ -373,13 +377,16 @@ $json = $response->json(); - $this->assertTrue($json['isLdapReady']); - $this->assertTrue($json['isReady']); + $this->assertFalse($json['isLdapReady']); + $this->assertFalse($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(false, $json['process'][1]['state']); $this->assertSame('success', $json['status']); - $this->assertSame('Setup process finished successfully.', $json['message']); + $this->assertSame('Setup process has been pushed. Please wait.', $json['message']); + $this->assertSame('waiting', $json['processState']); + + Queue::assertPushed(\App\Jobs\SharedFolder\CreateJob::class, 1); } /** diff --git a/src/tests/Feature/Controller/UsersTest.php b/src/tests/Feature/Controller/UsersTest.php --- a/src/tests/Feature/Controller/UsersTest.php +++ b/src/tests/Feature/Controller/UsersTest.php @@ -495,29 +495,8 @@ $domain->status |= Domain::STATUS_CONFIRMED; $domain->save(); - // Now "reboot" the process and verify the user in imap synchronously - $response = $this->actingAs($john)->get("/api/v4/users/{$john->id}/status?refresh=1"); - $response->assertStatus(200); - - $json = $response->json(); - - $this->assertTrue($json['isImapReady']); - $this->assertTrue($json['isReady']); - $this->assertCount(7, $json['process']); - $this->assertSame('user-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']); - - Queue::size(1); - - // Test case for when the verify job is dispatched to the worker - $john->refresh(); - $john->status ^= User::STATUS_IMAP_READY; - $john->save(); - - \config(['imap.admin_password' => null]); - + // Now "reboot" the process + Queue::fake(); $response = $this->actingAs($john)->get("/api/v4/users/{$john->id}/status?refresh=1"); $response->assertStatus(200); @@ -525,11 +504,13 @@ $this->assertFalse($json['isImapReady']); $this->assertFalse($json['isReady']); + $this->assertCount(7, $json['process']); + $this->assertSame('user-imap-ready', $json['process'][2]['label']); + $this->assertSame(false, $json['process'][2]['state']); $this->assertSame('success', $json['status']); - $this->assertSame('waiting', $json['processState']); $this->assertSame('Setup process has been pushed. Please wait.', $json['message']); - Queue::assertPushed(\App\Jobs\User\VerifyJob::class, 1); + Queue::assertPushed(\App\Jobs\User\CreateJob::class, 1); } /** diff --git a/src/tests/Feature/EntitlementTest.php b/src/tests/Feature/EntitlementTest.php --- a/src/tests/Feature/EntitlementTest.php +++ b/src/tests/Feature/EntitlementTest.php @@ -39,6 +39,48 @@ parent::tearDown(); } + /** + * Tests for EntitlementObserver + */ + public function testEntitlementObserver(): void + { + $skuStorage = Sku::withEnvTenantContext()->where('title', 'storage')->first(); + $skuMailbox = Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); + $user = $this->getTestUser('entitlement-test@kolabnow.com'); + $wallet = $user->wallets->first(); + + // Test dispatching update jobs for the user, on quota update + Queue::fake(); + $user->assignSku($skuMailbox, 1, $wallet); + Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 0); + + Queue::fake(); + $user->assignSku($skuStorage, 1, $wallet); + Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1); + Queue::assertPushed( + \App\Jobs\User\UpdateJob::class, + function ($job) use ($user) { + return $user->id === TestCase::getObjectProperty($job, 'userId'); + } + ); + + Queue::fake(); + $user->entitlements()->where('sku_id', $skuMailbox->id)->first()->delete(); + Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 0); + + Queue::fake(); + $user->entitlements()->where('sku_id', $skuStorage->id)->first()->delete(); + Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1); + Queue::assertPushed( + \App\Jobs\User\UpdateJob::class, + function ($job) use ($user) { + return $user->id === TestCase::getObjectProperty($job, 'userId'); + } + ); + + // TODO: Test all events in the observer in more detail + } + /** * Tests for entitlements * @todo This really should be in User or Wallet tests file diff --git a/src/tests/Feature/GroupTest.php b/src/tests/Feature/GroupTest.php --- a/src/tests/Feature/GroupTest.php +++ b/src/tests/Feature/GroupTest.php @@ -79,7 +79,7 @@ $this->assertMatchesRegularExpression('/^[0-9]{1,20}$/', $group->id); $this->assertSame([], $group->members); $this->assertTrue($group->isNew()); - $this->assertTrue($group->isActive()); + $this->assertFalse($group->isActive()); Queue::assertPushed( \App\Jobs\Group\CreateJob::class, diff --git a/src/tests/Feature/Jobs/Group/DeleteTest.php b/src/tests/Feature/Jobs/Group/DeleteTest.php --- a/src/tests/Feature/Jobs/Group/DeleteTest.php +++ b/src/tests/Feature/Jobs/Group/DeleteTest.php @@ -3,6 +3,7 @@ namespace Tests\Feature\Jobs\Group; use App\Group; +use Illuminate\Support\Facades\Queue; use Tests\TestCase; class DeleteTest extends TestCase @@ -42,6 +43,8 @@ $this->assertTrue($group->fresh()->isLdapReady()); + Queue::fake(); + $job = new \App\Jobs\Group\DeleteJob($group->id); $job->handle(); @@ -49,7 +52,17 @@ $this->assertFalse($group->isLdapReady()); $this->assertTrue($group->isDeleted()); - +/* + Queue::assertPushed(\App\Jobs\IMAP\AclCleanupJob::class, 1); + Queue::assertPushed( + \App\Jobs\IMAP\AclCleanupJob::class, + function ($job) { + $ident = TestCase::getObjectProperty($job, 'ident'); + $domain = TestCase::getObjectProperty($job, 'domain'); + return $ident == 'group' && $domain === 'kolab.org'; + } + ); +*/ // Test non-existing group ID $job = new \App\Jobs\Group\DeleteJob(123); $job->handle(); diff --git a/src/tests/Feature/Jobs/Resource/CreateTest.php b/src/tests/Feature/Jobs/Resource/CreateTest.php --- a/src/tests/Feature/Jobs/Resource/CreateTest.php +++ b/src/tests/Feature/Jobs/Resource/CreateTest.php @@ -29,6 +29,7 @@ * Test job handle * * @group ldap + * @group imap */ public function testHandle(): void { @@ -42,24 +43,27 @@ $this->assertTrue($job->isReleased()); $this->assertFalse($job->hasFailed()); - $resource = $this->getTestResource('resource-test@' . \config('app.domain')); + $resource = $this->getTestResource( + 'resource-test@' . \config('app.domain'), + ['status' => Resource::STATUS_NEW] + ); $this->assertFalse($resource->isLdapReady()); + $this->assertFalse($resource->isImapReady()); + $this->assertFalse($resource->isActive()); // Test resource creation $job = new \App\Jobs\Resource\CreateJob($resource->id); $job->handle(); - $this->assertTrue($resource->fresh()->isLdapReady()); + $resource->refresh(); + $this->assertFalse($job->hasFailed()); + $this->assertTrue($resource->isLdapReady()); + $this->assertTrue($resource->isImapReady()); + $this->assertTrue($resource->isActive()); // 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(); @@ -80,5 +84,6 @@ $this->assertSame("Resource {$resource->id} is actually deleted.", $job->failureMessage); // TODO: Test failures on domain sanity checks + // TODO: Test partial execution, i.e. only IMAP or only LDAP } } diff --git a/src/tests/Feature/Jobs/Resource/DeleteTest.php b/src/tests/Feature/Jobs/Resource/DeleteTest.php --- a/src/tests/Feature/Jobs/Resource/DeleteTest.php +++ b/src/tests/Feature/Jobs/Resource/DeleteTest.php @@ -29,6 +29,7 @@ * Test job handle * * @group ldap + * @group imap */ public function testHandle(): void { @@ -52,11 +53,10 @@ $resource->refresh(); $this->assertTrue($resource->isLdapReady()); + $this->assertTrue($resource->isImapReady()); + $this->assertFalse($resource->isDeleted()); // Test successful deletion - $resource->status |= Resource::STATUS_IMAP_READY; - $resource->save(); - $job = new \App\Jobs\Resource\DeleteJob($resource->id); $job->handle(); diff --git a/src/tests/Feature/Jobs/Resource/UpdateTest.php b/src/tests/Feature/Jobs/Resource/UpdateTest.php --- a/src/tests/Feature/Jobs/Resource/UpdateTest.php +++ b/src/tests/Feature/Jobs/Resource/UpdateTest.php @@ -33,6 +33,7 @@ * Test job handle * * @group ldap + * @group imap */ public function testHandle(): void { @@ -45,12 +46,16 @@ $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')); + $resource = $this->getTestResource( + 'resource-test@' . \config('app.domain'), + ['status' => Resource::STATUS_NEW] + ); // Create the resource in LDAP $job = new \App\Jobs\Resource\CreateJob($resource->id); $job->handle(); + // Run the update with some new config $resource->setConfig(['invitation_policy' => 'accept']); $job = new \App\Jobs\Resource\UpdateJob($resource->id); @@ -60,18 +65,11 @@ $this->assertSame('ACT_ACCEPT', $ldap_resource['kolabinvitationpolicy']); + // TODO: Assert IMAP change worked + // 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->status |= Resource::STATUS_DELETED; $resource->save(); $job = new \App\Jobs\Resource\UpdateJob($resource->id); diff --git a/src/tests/Feature/Jobs/SharedFolder/CreateTest.php b/src/tests/Feature/Jobs/SharedFolder/CreateTest.php --- a/src/tests/Feature/Jobs/SharedFolder/CreateTest.php +++ b/src/tests/Feature/Jobs/SharedFolder/CreateTest.php @@ -29,6 +29,7 @@ * Test job handle * * @group ldap + * @group imap */ public function testHandle(): void { @@ -42,24 +43,27 @@ $this->assertTrue($job->isReleased()); $this->assertFalse($job->hasFailed()); - $folder = $this->getTestSharedFolder('folder-test@' . \config('app.domain')); + $folder = $this->getTestSharedFolder( + 'folder-test@' . \config('app.domain'), + ['status' => SharedFolder::STATUS_NEW] + ); $this->assertFalse($folder->isLdapReady()); + $this->assertFalse($folder->isImapReady()); + $this->assertFalse($folder->isActive()); // Test shared folder creation $job = new \App\Jobs\SharedFolder\CreateJob($folder->id); $job->handle(); - $this->assertTrue($folder->fresh()->isLdapReady()); + $folder->refresh(); + $this->assertFalse($job->hasFailed()); + $this->assertTrue($folder->isLdapReady()); + $this->assertTrue($folder->isImapReady()); + $this->assertTrue($folder->isActive()); // 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(); @@ -80,5 +84,6 @@ $this->assertSame("Shared folder {$folder->id} is actually deleted.", $job->failureMessage); // TODO: Test failures on domain sanity checks + // TODO: Test partial execution, i.e. only IMAP or only LDAP } } diff --git a/src/tests/Feature/Jobs/SharedFolder/DeleteTest.php b/src/tests/Feature/Jobs/SharedFolder/DeleteTest.php --- a/src/tests/Feature/Jobs/SharedFolder/DeleteTest.php +++ b/src/tests/Feature/Jobs/SharedFolder/DeleteTest.php @@ -29,6 +29,7 @@ * Test job handle * * @group ldap + * @group imap */ public function testHandle(): void { @@ -52,11 +53,10 @@ $folder->refresh(); $this->assertTrue($folder->isLdapReady()); + $this->assertTrue($folder->isImapReady()); + $this->assertFalse($folder->isDeleted()); // Test successful deletion - $folder->status |= SharedFolder::STATUS_IMAP_READY; - $folder->save(); - $job = new \App\Jobs\SharedFolder\DeleteJob($folder->id); $job->handle(); diff --git a/src/tests/Feature/Jobs/SharedFolder/UpdateTest.php b/src/tests/Feature/Jobs/SharedFolder/UpdateTest.php --- a/src/tests/Feature/Jobs/SharedFolder/UpdateTest.php +++ b/src/tests/Feature/Jobs/SharedFolder/UpdateTest.php @@ -33,6 +33,7 @@ * Test job handle * * @group ldap + * @group imap */ public function testHandle(): void { @@ -45,29 +46,28 @@ $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')); + $folder = $this->getTestSharedFolder( + 'folder-test@' . \config('app.domain'), + ['status' => SharedFolder::STATUS_NEW] + ); // 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(); + $this->assertTrue($folder->isLdapReady()); + $this->assertTrue($folder->isImapReady()); + + // Run the update job $job = new \App\Jobs\SharedFolder\UpdateJob($folder->id); $job->handle(); - $this->assertTrue($job->isDeleted()); + // TODO: Assert that it worked on both LDAP and IMAP side - $folder->status = SharedFolder::STATUS_NEW | SharedFolder::STATUS_ACTIVE - | SharedFolder::STATUS_LDAP_READY | SharedFolder::STATUS_DELETED; + // Test handling deleted folder + $folder->status |= SharedFolder::STATUS_DELETED; $folder->save(); $job = new \App\Jobs\SharedFolder\UpdateJob($folder->id); diff --git a/src/tests/Feature/Jobs/User/CreateTest.php b/src/tests/Feature/Jobs/User/CreateTest.php --- a/src/tests/Feature/Jobs/User/CreateTest.php +++ b/src/tests/Feature/Jobs/User/CreateTest.php @@ -3,6 +3,7 @@ namespace Tests\Feature\Jobs\User; use App\User; +use Illuminate\Support\Facades\Queue; use Tests\TestCase; class CreateTest extends TestCase @@ -28,26 +29,28 @@ * Test job handle * * @group ldap + * @group imap */ public function testHandle(): void { - $user = $this->getTestUser('new-job-user@' . \config('app.domain')); + Queue::fake(); + $user = $this->getTestUser('new-job-user@' . \config('app.domain'), ['status' => User::STATUS_NEW]); $this->assertFalse($user->isLdapReady()); + $this->assertFalse($user->isImapReady()); + $this->assertFalse($user->isActive()); $job = new \App\Jobs\User\CreateJob($user->id); $job->handle(); - $this->assertTrue($user->fresh()->isLdapReady()); - $this->assertFalse($job->hasFailed()); + $user->refresh(); - // Test job failures - $job = new \App\Jobs\User\CreateJob($user->id); - $job->handle(); - - $this->assertTrue($job->hasFailed()); - $this->assertSame("User {$user->id} is already marked as ldap-ready.", $job->failureMessage); + $this->assertTrue($user->isLdapReady()); + $this->assertTrue($user->isImapReady()); + $this->assertTrue($user->isActive()); + $this->assertFalse($job->hasFailed()); + // Test job failure (user deleted) $user->status |= User::STATUS_DELETED; $user->save(); @@ -57,6 +60,7 @@ $this->assertTrue($job->hasFailed()); $this->assertSame("User {$user->id} is marked as deleted.", $job->failureMessage); + // Test job failure (user removed) $user->status ^= User::STATUS_DELETED; $user->save(); $user->delete(); @@ -67,13 +71,14 @@ $this->assertTrue($job->hasFailed()); $this->assertSame("User {$user->id} is actually deleted.", $job->failureMessage); - // TODO: Test failures on domain sanity checks - - $this->expectException(\Exception::class); + // Test job failure (user unknown) $job = new \App\Jobs\User\CreateJob(123); $job->handle(); $this->assertTrue($job->isReleased()); $this->assertFalse($job->hasFailed()); + + // TODO: Test failures on domain sanity checks + // TODO: Test partial execution, i.e. only IMAP or only LDAP } } diff --git a/src/tests/Feature/Jobs/User/DeleteTest.php b/src/tests/Feature/Jobs/User/DeleteTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Jobs/User/DeleteTest.php @@ -0,0 +1,91 @@ +deleteTestUser('new-job-user@' . \config('app.domain')); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestUser('new-job-user@' . \config('app.domain')); + + parent::tearDown(); + } + + /** + * Test job handle + * + * @group ldap + * @group imap + */ + public function testHandle(): void + { + Queue::fake(); + + $user = $this->getTestUser('new-job-user@' . \config('app.domain')); + + // Create the user in LDAP+IMAP + $job = new \App\Jobs\User\CreateJob($user->id); + $job->handle(); + + $user->refresh(); + + $this->assertTrue($user->isLdapReady()); + $this->assertTrue($user->isImapReady()); + $this->assertFalse($user->isDeleted()); + + // Test job failure (user already deleted) + $user->status |= User::STATUS_DELETED; + $user->save(); + + $job = new \App\Jobs\User\DeleteJob($user->id); + $job->handle(); + + $this->assertTrue($job->hasFailed()); + $this->assertSame("User {$user->id} is already marked as deleted.", $job->failureMessage); + + // Test success delete from LDAP and IMAP + $user->status ^= User::STATUS_DELETED; + $user->save(); + + $this->assertFalse($user->isDeleted()); + + $job = new \App\Jobs\User\DeleteJob($user->id); + $job->handle(); + + $user->refresh(); + + $this->assertFalse($job->hasFailed()); + $this->assertFalse($user->isLdapReady()); + $this->assertFalse($user->isImapReady()); + $this->assertTrue($user->isDeleted()); + + Queue::assertPushed(\App\Jobs\IMAP\AclCleanupJob::class, 1); + Queue::assertPushed( + \App\Jobs\IMAP\AclCleanupJob::class, + function ($job) use ($user) { + $ident = TestCase::getObjectProperty($job, 'ident'); + $domain = TestCase::getObjectProperty($job, 'domain'); + return $ident == $user->email && $domain === ''; + } + ); + + // TODO: Test partial execution, i.e. only IMAP or only LDAP + } +} diff --git a/src/tests/Feature/Jobs/User/UpdateTest.php b/src/tests/Feature/Jobs/User/UpdateTest.php --- a/src/tests/Feature/Jobs/User/UpdateTest.php +++ b/src/tests/Feature/Jobs/User/UpdateTest.php @@ -32,6 +32,7 @@ * Test job handle * * @group ldap + * @group imap */ public function testHandle(): void { @@ -90,5 +91,7 @@ $this->assertTrue($job->hasFailed()); $this->assertSame("User 123 could not be found in the database.", $job->failureMessage); + + // TODO: Test IMAP, e.g. quota change } } diff --git a/src/tests/Feature/ResourceTest.php b/src/tests/Feature/ResourceTest.php --- a/src/tests/Feature/ResourceTest.php +++ b/src/tests/Feature/ResourceTest.php @@ -113,7 +113,7 @@ $this->assertMatchesRegularExpression('/^resource-[0-9]{1,20}@kolabnow\.com$/', $resource->email); $this->assertSame('Reśo', $resource->name); $this->assertTrue($resource->isNew()); - $this->assertTrue($resource->isActive()); + $this->assertFalse($resource->isActive()); $this->assertFalse($resource->isDeleted()); $this->assertFalse($resource->isLdapReady()); $this->assertFalse($resource->isImapReady()); @@ -133,13 +133,6 @@ && $resourceId === $resource->id; } ); - - Queue::assertPushedWithChain( - \App\Jobs\Resource\CreateJob::class, - [ - \App\Jobs\Resource\VerifyJob::class, - ] - ); } /** @@ -225,6 +218,13 @@ $resource->setSetting('invitation_policy', 'accept'); Queue::assertPushed(\App\Jobs\Resource\UpdateJob::class, 1); + Queue::assertPushed( + \App\Jobs\Resource\UpdateJob::class, + function ($job) use ($resource) { + return $resource->id === TestCase::getObjectProperty($job, 'resourceId') + && ['invitation_policy' => null] === TestCase::getObjectProperty($job, 'properties'); + } + ); // Note: We test both current resource as well as fresh resource object // to make sure cache works as expected @@ -242,6 +242,13 @@ $resource->setSetting('invitation_policy', 'reject'); Queue::assertPushed(\App\Jobs\Resource\UpdateJob::class, 1); + Queue::assertPushed( + \App\Jobs\Resource\UpdateJob::class, + function ($job) use ($resource) { + return $resource->id === TestCase::getObjectProperty($job, 'resourceId') + && ['invitation_policy' => 'accept'] === TestCase::getObjectProperty($job, 'properties'); + } + ); $this->assertSame('test1', $resource->getSetting('unknown')); $this->assertSame('reject', $resource->fresh()->getSetting('invitation_policy')); @@ -257,6 +264,13 @@ $resource->setSetting('invitation_policy', null); Queue::assertPushed(\App\Jobs\Resource\UpdateJob::class, 1); + Queue::assertPushed( + \App\Jobs\Resource\UpdateJob::class, + function ($job) use ($resource) { + return $resource->id === TestCase::getObjectProperty($job, 'resourceId') + && ['invitation_policy' => 'reject'] === TestCase::getObjectProperty($job, 'properties'); + } + ); $this->assertSame(null, $resource->getSetting('unknown')); $this->assertSame(null, $resource->fresh()->getSetting('invitation_policy')); diff --git a/src/tests/Feature/SharedFolderTest.php b/src/tests/Feature/SharedFolderTest.php --- a/src/tests/Feature/SharedFolderTest.php +++ b/src/tests/Feature/SharedFolderTest.php @@ -177,7 +177,7 @@ $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->isActive()); $this->assertFalse($folder->isDeleted()); $this->assertFalse($folder->isLdapReady()); $this->assertFalse($folder->isImapReady()); @@ -197,13 +197,6 @@ && $folderId === $folder->id; } ); - - Queue::assertPushedWithChain( - \App\Jobs\SharedFolder\CreateJob::class, - [ - \App\Jobs\SharedFolder\VerifyJob::class, - ] - ); } /** @@ -289,6 +282,13 @@ $folder->setSetting('acl', 'test'); Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 1); + Queue::assertPushed( + \App\Jobs\SharedFolder\UpdateJob::class, + function ($job) use ($folder) { + return $folder->id === TestCase::getObjectProperty($job, 'folderId') + && ['acl' => null] === TestCase::getObjectProperty($job, 'properties'); + } + ); // Note: We test both current folder as well as fresh folder object // to make sure cache works as expected @@ -306,6 +306,13 @@ $folder->setSetting('acl', 'test1'); Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 1); + Queue::assertPushed( + \App\Jobs\SharedFolder\UpdateJob::class, + function ($job) use ($folder) { + return $folder->id === TestCase::getObjectProperty($job, 'folderId') + && ['acl' => 'test'] === TestCase::getObjectProperty($job, 'properties'); + } + ); $this->assertSame('test1', $folder->getSetting('unknown')); $this->assertSame('test1', $folder->fresh()->getSetting('acl')); @@ -321,6 +328,13 @@ $folder->setSetting('acl', null); Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 1); + Queue::assertPushed( + \App\Jobs\SharedFolder\UpdateJob::class, + function ($job) use ($folder) { + return $folder->id === TestCase::getObjectProperty($job, 'folderId') + && ['acl' => 'test1'] === TestCase::getObjectProperty($job, 'properties'); + } + ); $this->assertSame(null, $folder->getSetting('unknown')); $this->assertSame(null, $folder->fresh()->getSetting('acl')); 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 @@ -321,7 +321,7 @@ $this->assertSame("user-test@$domain", $result->email); $this->assertSame($user->id, $result->id); - $this->assertSame(User::STATUS_NEW | User::STATUS_ACTIVE, $result->status); + $this->assertSame(User::STATUS_NEW, $result->status); $this->assertSame(0, $user->passwords()->count()); Queue::assertPushed(\App\Jobs\User\CreateJob::class, 1); @@ -338,28 +338,6 @@ } ); - Queue::assertPushedWithChain( - \App\Jobs\User\CreateJob::class, - [ - \App\Jobs\User\VerifyJob::class, - ] - ); -/* - FIXME: Looks like we can't really do detailed assertions on chained jobs - Another thing to consider is if we maybe should run these jobs - independently (not chained) and make sure there's no race-condition - in status update - - Queue::assertPushed(\App\Jobs\User\VerifyJob::class, 1); - Queue::assertPushed(\App\Jobs\User\VerifyJob::class, function ($job) use ($user) { - $userEmail = TestCase::getObjectProperty($job, 'userEmail'); - $userId = TestCase::getObjectProperty($job, 'userId'); - - return $userEmail === $user->email - && $userId === $user->id; - }); -*/ - // Test invoking KeyCreateJob $this->deleteTestUser("user-test@$domain"); @@ -973,7 +951,7 @@ } /** - * Test User::hasSku() method + * Test User::hasSku() and countEntitlementsBySku() methods */ public function testHasSku(): void { @@ -983,6 +961,11 @@ $this->assertTrue($john->hasSku('storage')); $this->assertFalse($john->hasSku('beta')); $this->assertFalse($john->hasSku('unknown')); + + $this->assertSame(0, $john->countEntitlementsBySku('unknown')); + $this->assertSame(0, $john->countEntitlementsBySku('2fa')); + $this->assertSame(1, $john->countEntitlementsBySku('mailbox')); + $this->assertSame(5, $john->countEntitlementsBySku('storage')); } /** @@ -1165,12 +1148,6 @@ return $userA->id === TestCase::getObjectProperty($job, 'userId'); } ); - Queue::assertPushedWithChain( - \App\Jobs\User\CreateJob::class, - [ - \App\Jobs\User\VerifyJob::class, - ] - ); } /**