diff --git a/src/.env.example b/src/.env.example index 41c85959..1a094e0d 100644 --- a/src/.env.example +++ b/src/.env.example @@ -1,186 +1,188 @@ APP_NAME=Kolab APP_ENV=local APP_KEY= APP_DEBUG=true APP_URL=http://127.0.0.1:8000 #APP_PASSPHRASE= APP_PUBLIC_URL= APP_DOMAIN=kolabnow.com APP_WEBSITE_DOMAIN=kolabnow.com APP_THEME=default APP_TENANT_ID=5 APP_LOCALE=en APP_LOCALES= APP_WITH_ADMIN=1 APP_WITH_RESELLER=1 APP_WITH_SERVICES=1 APP_WITH_FILES=1 +APP_LDAP=1 + APP_HEADER_CSP="connect-src 'self'; child-src 'self'; font-src 'self'; form-action 'self' data:; frame-ancestors 'self'; img-src blob: data: 'self' *; media-src 'self'; object-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-eval' 'unsafe-inline'; default-src 'self';" APP_HEADER_XFO=sameorigin SIGNUP_LIMIT_EMAIL=0 SIGNUP_LIMIT_IP=0 ASSET_URL=http://127.0.0.1:8000 WEBMAIL_URL=/apps SUPPORT_URL=/support SUPPORT_EMAIL= LOG_CHANNEL=stack LOG_SLOW_REQUESTS=5 LOG_DEPRECATIONS_CHANNEL=null LOG_LEVEL=debug DB_CONNECTION=mysql DB_DATABASE=kolabdev DB_HOST=127.0.0.1 DB_PASSWORD=kolab DB_PORT=3306 DB_USERNAME=kolabdev BROADCAST_DRIVER=redis CACHE_DRIVER=redis QUEUE_CONNECTION=redis SESSION_DRIVER=file SESSION_LIFETIME=120 OPENEXCHANGERATES_API_KEY="from openexchangerates.org" MFA_DSN=mysql://roundcube:Welcome2KolabSystems@127.0.0.1/roundcube MFA_TOTP_DIGITS=6 MFA_TOTP_INTERVAL=30 MFA_TOTP_DIGEST=sha1 IMAP_URI=ssl://127.0.0.1:11993 IMAP_ADMIN_LOGIN=cyrus-admin IMAP_ADMIN_PASSWORD=Welcome2KolabSystems IMAP_VERIFY_HOST=false IMAP_VERIFY_PEER=false LDAP_BASE_DN="dc=mgmt,dc=com" LDAP_DOMAIN_BASE_DN="ou=Domains,dc=mgmt,dc=com" LDAP_HOSTS=127.0.0.1 LDAP_PORT=389 LDAP_SERVICE_BIND_DN="uid=kolab-service,ou=Special Users,dc=mgmt,dc=com" LDAP_SERVICE_BIND_PW="Welcome2KolabSystems" LDAP_USE_SSL=false LDAP_USE_TLS=false # Administrative LDAP_ADMIN_BIND_DN="cn=Directory Manager" LDAP_ADMIN_BIND_PW="Welcome2KolabSystems" LDAP_ADMIN_ROOT_DN="dc=mgmt,dc=com" # Hosted (public registration) LDAP_HOSTED_BIND_DN="uid=hosted-kolab-service,ou=Special Users,dc=mgmt,dc=com" LDAP_HOSTED_BIND_PW="Welcome2KolabSystems" LDAP_HOSTED_ROOT_DN="dc=hosted,dc=com" COTURN_PUBLIC_IP=127.0.0.1 COTURN_STATIC_SECRET="Welcome2KolabSystems" MEET_WEBHOOK_TOKEN=Welcome2KolabSystems MEET_SERVER_TOKEN=Welcome2KolabSystems MEET_SERVER_URLS=https://localhost:12443/meetmedia/api/ MEET_SERVER_VERIFY_TLS=true MEET_WEBRTC_LISTEN_IP= MEET_PUBLIC_DOMAIN=127.0.0.1:12443 MEET_TURN_SERVER='turn:127.0.0.1:3478?transport=tcp' PGP_ENABLED= PGP_BINARY= PGP_AGENT= PGP_GPGCONF= PGP_LENGTH= # Set these to IP addresses you serve WOAT with. # Have the domain owner point _woat. NS RRs refer to ns0{1,2}. WOAT_NS1=ns01.domain.tld WOAT_NS2=ns02.domain.tld REDIS_HOST=127.0.0.1 REDIS_PASSWORD=null REDIS_PORT=6379 OCTANE_HTTP_HOST=127.0.0.1 SWOOLE_PACKAGE_MAX_LENGTH=10485760 PAYMENT_PROVIDER= MOLLIE_KEY= STRIPE_KEY= STRIPE_PUBLIC_KEY= STRIPE_WEBHOOK_SECRET= MAIL_MAILER=smtp MAIL_HOST=smtp.mailtrap.io MAIL_PORT=2525 MAIL_USERNAME=null MAIL_PASSWORD=null MAIL_ENCRYPTION=null MAIL_FROM_ADDRESS="noreply@example.com" MAIL_FROM_NAME="Example.com" MAIL_REPLYTO_ADDRESS="replyto@example.com" MAIL_REPLYTO_NAME=null DNS_TTL=3600 DNS_SPF="v=spf1 mx -all" DNS_STATIC="%s. MX 10 ext-mx01.mykolab.com." DNS_COPY_FROM=null AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= AWS_DEFAULT_REGION=us-east-1 AWS_BUCKET= AWS_USE_PATH_STYLE_ENDPOINT=false PUSHER_APP_ID= PUSHER_APP_KEY= PUSHER_APP_SECRET= PUSHER_APP_CLUSTER=mt1 MIX_ASSET_PATH='/' MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}" MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}" # Generate with ./artisan passport:client --password #PASSPORT_PROXY_OAUTH_CLIENT_ID= #PASSPORT_PROXY_OAUTH_CLIENT_SECRET= # Generate with ./artisan passport:client --password #PASSPORT_COMPANIONAPP_OAUTH_CLIENT_ID= #PASSPORT_COMPANIONAPP_OAUTH_CLIENT_SECRET= PASSPORT_PRIVATE_KEY= PASSPORT_PUBLIC_KEY= PASSWORD_POLICY= COMPANY_NAME= COMPANY_ADDRESS= COMPANY_DETAILS= COMPANY_EMAIL= COMPANY_LOGO= COMPANY_FOOTER= VAT_COUNTRIES=CH,LI VAT_RATE=7.7 KB_ACCOUNT_DELETE= KB_ACCOUNT_SUSPENDED= KB_PAYMENT_SYSTEM= KOLAB_SSL_CERTIFICATE=/etc/pki/tls/certs/kolab.hosted.com.cert KOLAB_SSL_CERTIFICATE_FULLCHAIN=/etc/pki/tls/certs/kolab.hosted.com.chain.pem KOLAB_SSL_CERTIFICATE_KEY=/etc/pki/tls/certs/kolab.hosted.com.key PROXY_SSL_CERTIFICATE=/etc/pki/tls/certs/imap.hosted.com.cert PROXY_SSL_CERTIFICATE_KEY=/etc/pki/tls/certs/imap.hosted.com.key NGINX_SSL_CERTIFICATE=/etc/pki/tls/certs/imap.hosted.com.cert NGINX_SSL_CERTIFICATE_KEY=/etc/pki/tls/certs/imap.hosted.com.key diff --git a/src/app/Backends/IMAP.php b/src/app/Backends/IMAP.php index 1fc1ef04..a6667a67 100644 --- a/src/app/Backends/IMAP.php +++ b/src/app/Backends/IMAP.php @@ -1,148 +1,636 @@ '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; } /** * Check if a shared folder is set up. * * @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 */ public static function verifySharedFolder(string $folder): bool { $config = self::getConfig(); $imap = self::initIMAP($config); // 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]; } // FIXME: just listMailboxes() does not return shared folders at all $metadata = $imap->getMetadata($folder, ['/shared/vendor/kolab/folder-type']); $imap->closeConnection(); // Note: We have to use error code to distinguish an error from "no mailbox" response if ($imap->errornum === \rcube_imap_generic::ERROR_NO) { return false; } if ($imap->errornum !== \rcube_imap_generic::ERROR_OK) { throw new \Exception("Failed to get folder metadata from IMAP"); } 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 */ private static function initIMAP(array $config, string $login_as = null) { $imap = new \rcube_imap_generic(); if (\config('app.debug')) { $imap->setDebug(true, 'App\Backends\IMAP::logDebug'); } if ($login_as) { $config['options']['auth_cid'] = $config['user']; $config['options']['auth_pw'] = $config['password']; $config['options']['auth_type'] = 'PLAIN'; $config['user'] = $login_as; } $imap->connect($config['host'], $config['user'], $config['password'], $config['options']); if (!$imap->connected()) { $message = sprintf("Login failed for %s against %s. %s", $config['user'], $config['host'], $imap->error); \Log::error($message); throw new \Exception("Connection to IMAP failed"); } return $imap; } /** * Get LDAP configuration for specified access level */ private static function getConfig() { $uri = \parse_url(\config('imap.uri')); $default_port = 143; $ssl_mode = null; if (isset($uri['scheme'])) { if (preg_match('/^(ssl|imaps)/', $uri['scheme'])) { $default_port = 993; $ssl_mode = 'ssl'; } elseif ($uri['scheme'] === 'tls') { $ssl_mode = 'tls'; } } $config = [ 'host' => $uri['host'], 'user' => \config('imap.admin_login'), 'password' => \config('imap.admin_password'), 'options' => [ 'port' => !empty($uri['port']) ? $uri['port'] : $default_port, 'ssl_mode' => $ssl_mode, 'socket_options' => [ 'ssl' => [ 'verify_peer' => \config('imap.verify_peer'), 'verify_peer_name' => \config('imap.verify_peer'), 'verify_host' => \config('imap.verify_host') ], ], ], ]; return $config; } /** * Debug logging callback */ public static function logDebug($conn, $msg): void { $msg = '[IMAP] ' . $msg; \Log::debug($msg); } } diff --git a/src/app/Backends/Roundcube.php b/src/app/Backends/Roundcube.php index 7d88048d..94b1464f 100644 --- a/src/app/Backends/Roundcube.php +++ b/src/app/Backends/Roundcube.php @@ -1,269 +1,281 @@ table(self::FILESTORE_TABLE) ->where('user_id', self::userId($email)) ->where('context', 'enigma') ->delete(); } /** * List all files from the Enigma filestore. * * @param string $email User email address * * @return array List of Enigma filestore records */ public static function enigmaList(string $email): array { return self::dbh()->table(self::FILESTORE_TABLE) ->where('user_id', self::userId($email)) ->where('context', 'enigma') ->orderBy('filename') ->get() ->all(); } /** * Synchronize Enigma filestore from/to specified directory * * @param string $email User email address * @param string $homedir Directory location */ public static function enigmaSync(string $email, string $homedir): void { $db = self::dbh(); $debug = \config('app.debug'); $user_id = self::userId($email); $root = \config('filesystems.disks.pgp.root'); $fs = Storage::disk('pgp'); $files = []; $result = $db->table(self::FILESTORE_TABLE)->select('file_id', 'filename', 'mtime') ->where('user_id', $user_id) ->where('context', 'enigma') ->get(); foreach ($result as $record) { $file = $homedir . '/' . $record->filename; $mtime = $fs->exists($file) ? $fs->lastModified($file) : 0; $files[] = $record->filename; if ($mtime < $record->mtime) { $record = $db->table(self::FILESTORE_TABLE)->select('file_id', 'data', 'mtime') ->where('file_id', $record->file_id) ->first(); $data = $record ? base64_decode($record->data) : false; if ($data === false) { \Log::error("Failed to sync $file ({$record->file_id}). Decode error."); continue; } if ($fs->put($file, $data, true)) { // Note: Laravel Filesystem API does not provide touch method touch("$root/$file", $record->mtime); if ($debug) { \Log::debug("[SYNC] Fetched file: $file"); } } } } // Remove files not in database foreach (array_diff(self::enigmaFilesList($homedir), $files) as $file) { $file = $homedir . '/' . $file; if ($fs->delete($file)) { if ($debug) { \Log::debug("[SYNC] Removed file: $file"); } } } // No records found, do initial sync if already have the keyring if (empty($file)) { self::enigmaSave(true, $homedir); } } /** * Save the keys database * * @param string $email User email address * @param string $homedir Directory location * @param bool $is_empty Set to Tre if it is a initial save */ public static function enigmaSave(string $email, string $homedir, bool $is_empty = false): void { $db = self::dbh(); $debug = \config('app.debug'); $user_id = self::userId($email); $fs = Storage::disk('pgp'); $records = []; if (!$is_empty) { $records = $db->table(self::FILESTORE_TABLE)->select('file_id', 'filename', 'mtime') ->where('user_id', $user_id) ->where('context', 'enigma') ->get() ->keyBy('filename') ->all(); } foreach (self::enigmaFilesList($homedir) as $filename) { $file = $homedir . '/' . $filename; $mtime = $fs->exists($file) ? $fs->lastModified($file) : 0; $existing = !empty($records[$filename]) ? $records[$filename] : null; unset($records[$filename]); if ($mtime && (empty($existing) || $mtime > $existing->mtime)) { $data = base64_encode($fs->get($file)); /* if (empty($maxsize)) { $maxsize = min($db->get_variable('max_allowed_packet', 1048500), 4*1024*1024) - 2000; } if (strlen($data) > $maxsize) { \Log::error("Failed to save $file. Size exceeds max_allowed_packet."); continue; } */ $result = $db->table(self::FILESTORE_TABLE)->updateOrInsert( ['user_id' => $user_id, 'context' => 'enigma', 'filename' => $filename], ['mtime' => $mtime, 'data' => $data] ); if ($debug) { \Log::debug("[SYNC] Pushed file: $file"); } } } // Delete removed files from database foreach (array_keys($records) as $filename) { $file = $homedir . '/' . $filename; $result = $db->table(self::FILESTORE_TABLE) ->where('user_id', $user_id) ->where('context', 'enigma') ->where('filename', $filename) ->delete(); if ($debug) { \Log::debug("[SYNC] Removed file: $file"); } } } + /** + * Delete a Roundcube user. + * + * @param string $email User email address + */ + public static function deleteUser(string $email): void + { + $db = self::dbh(); + + $db->table(self::USERS_TABLE)->where('username', \strtolower($email))->delete(); + } + /** * Find the Roundcube user identifier for the specified user. * * @param string $email User email address * @param bool $create Make sure the user record exists * * @returns ?int Roundcube user identifier */ public static function userId(string $email, bool $create = true): ?int { $db = self::dbh(); $user = $db->table(self::USERS_TABLE)->select('user_id') ->where('username', \strtolower($email)) ->first(); // Create a user record, without it we can't use the Roundcube storage if (empty($user)) { if (!$create) { return null; } $uri = \parse_url(\config('imap.uri')); $user_id = (int) $db->table(self::USERS_TABLE)->insertGetId( [ 'username' => $email, 'mail_host' => $uri['host'], 'created' => now()->toDateTimeString(), ], 'user_id' ); $username = \App\User::where('email', $email)->first()->name(); $db->table(self::IDENTITIES_TABLE)->insert([ 'user_id' => $user_id, 'email' => $email, 'name' => $username, 'changed' => now()->toDateTimeString(), 'standard' => 1, ]); return $user_id; } return (int) $user->user_id; } /** * Returns list of Enigma user homedir files to backup/sync */ private static function enigmaFilesList(string $homedir) { $files = []; $fs = Storage::disk('pgp'); foreach (self::$enigma_files as $file) { if ($fs->exists($homedir . '/' . $file)) { $files[] = $file; } } foreach ($fs->files($homedir . '/private-keys-v1.d') as $file) { if (preg_match('/\.key$/', $file)) { $files[] = substr($file, strlen($homedir . '/')); } } return $files; } } diff --git a/src/app/Console/Commands/Job/DomainCreate.php b/src/app/Console/Commands/Job/DomainCreate.php index 9c6f5716..e03805e3 100644 --- a/src/app/Console/Commands/Job/DomainCreate.php +++ b/src/app/Console/Commands/Job/DomainCreate.php @@ -1,40 +1,39 @@ getDomain($this->argument('domain')); if (!$domain) { return 1; } $job = new \App\Jobs\Domain\CreateJob($domain->id); $job->handle(); } } diff --git a/src/app/Console/Commands/Job/DomainUpdate.php b/src/app/Console/Commands/Job/DomainUpdate.php index 4b207e42..1477725b 100644 --- a/src/app/Console/Commands/Job/DomainUpdate.php +++ b/src/app/Console/Commands/Job/DomainUpdate.php @@ -1,40 +1,39 @@ getDomain($this->argument('domain')); if (!$domain) { return 1; } $job = new \App\Jobs\Domain\UpdateJob($domain->id); $job->handle(); } } diff --git a/src/app/Console/Commands/Job/DomainUpdate.php b/src/app/Console/Commands/Job/ResourceCreate.php similarity index 56% copy from src/app/Console/Commands/Job/DomainUpdate.php copy to src/app/Console/Commands/Job/ResourceCreate.php index 4b207e42..fe57b5f2 100644 --- a/src/app/Console/Commands/Job/DomainUpdate.php +++ b/src/app/Console/Commands/Job/ResourceCreate.php @@ -1,40 +1,39 @@ getDomain($this->argument('domain')); + $resource = $this->getResource($this->argument('resource')); - if (!$domain) { + if (!$resource) { return 1; } - $job = new \App\Jobs\Domain\UpdateJob($domain->id); + $job = new \App\Jobs\Resource\CreateJob($resource->id); $job->handle(); } } diff --git a/src/app/Console/Commands/Job/DomainUpdate.php b/src/app/Console/Commands/Job/ResourceUpdate.php similarity index 56% copy from src/app/Console/Commands/Job/DomainUpdate.php copy to src/app/Console/Commands/Job/ResourceUpdate.php index 4b207e42..15a0ffac 100644 --- a/src/app/Console/Commands/Job/DomainUpdate.php +++ b/src/app/Console/Commands/Job/ResourceUpdate.php @@ -1,40 +1,39 @@ getDomain($this->argument('domain')); + $resource = $this->getResource($this->argument('resource')); - if (!$domain) { + if (!$resource) { return 1; } - $job = new \App\Jobs\Domain\UpdateJob($domain->id); + $job = new \App\Jobs\Resource\UpdateJob($resource->id); $job->handle(); } } diff --git a/src/app/Console/Commands/Job/DomainUpdate.php b/src/app/Console/Commands/Job/SharedFolderCreate.php similarity index 55% copy from src/app/Console/Commands/Job/DomainUpdate.php copy to src/app/Console/Commands/Job/SharedFolderCreate.php index 4b207e42..79b63595 100644 --- a/src/app/Console/Commands/Job/DomainUpdate.php +++ b/src/app/Console/Commands/Job/SharedFolderCreate.php @@ -1,40 +1,39 @@ getDomain($this->argument('domain')); + $folder = $this->getSharedFolder($this->argument('folder')); - if (!$domain) { + if (!$folder) { return 1; } - $job = new \App\Jobs\Domain\UpdateJob($domain->id); + $job = new \App\Jobs\SharedFolder\CreateJob($folder->id); $job->handle(); } } diff --git a/src/app/Console/Commands/Job/DomainUpdate.php b/src/app/Console/Commands/Job/SharedFolderUpdate.php similarity index 56% copy from src/app/Console/Commands/Job/DomainUpdate.php copy to src/app/Console/Commands/Job/SharedFolderUpdate.php index 4b207e42..3b238557 100644 --- a/src/app/Console/Commands/Job/DomainUpdate.php +++ b/src/app/Console/Commands/Job/SharedFolderUpdate.php @@ -1,40 +1,39 @@ getDomain($this->argument('domain')); + $folder = $this->getSharedFolder($this->argument('folder')); - if (!$domain) { + if (!$folder) { return 1; } - $job = new \App\Jobs\Domain\UpdateJob($domain->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 index d6c4f750..7cd979ec 100644 --- a/src/app/Console/Commands/Job/UserCreate.php +++ b/src/app/Console/Commands/Job/UserCreate.php @@ -1,40 +1,39 @@ getUser($this->argument('user')); if (!$user) { return 1; } $job = new \App\Jobs\User\CreateJob($user->id); $job->handle(); } } diff --git a/src/app/Console/Commands/Job/UserUpdate.php b/src/app/Console/Commands/Job/UserUpdate.php index 2b8fe851..58367804 100644 --- a/src/app/Console/Commands/Job/UserUpdate.php +++ b/src/app/Console/Commands/Job/UserUpdate.php @@ -1,40 +1,39 @@ getUser($this->argument('user')); if (!$user) { return 1; } $job = new \App\Jobs\User\UpdateJob($user->id); $job->handle(); } } diff --git a/src/app/Http/Controllers/API/V4/DomainsController.php b/src/app/Http/Controllers/API/V4/DomainsController.php index 718a03d8..1d3e7c31 100644 --- a/src/app/Http/Controllers/API/V4/DomainsController.php +++ b/src/app/Http/Controllers/API/V4/DomainsController.php @@ -1,333 +1,329 @@ checkTenant($domain)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canRead($domain)) { return $this->errorResponse(403); } if (!$domain->confirm()) { return response()->json([ 'status' => 'error', 'message' => \trans('app.domain-verify-error'), ]); } return response()->json([ 'status' => 'success', 'statusInfo' => self::statusInfo($domain), 'message' => \trans('app.domain-verify-success'), ]); } /** * Remove the specified domain. * * @param string $id Domain identifier * * @return \Illuminate\Http\JsonResponse */ public function destroy($id) { $domain = Domain::withEnvTenantContext()->find($id); if (empty($domain)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canDelete($domain)) { return $this->errorResponse(403); } // It is possible to delete domain only if there are no users/aliases/groups using it. if (!$domain->isEmpty()) { $response = ['status' => 'error', 'message' => \trans('app.domain-notempty-error')]; return response()->json($response, 422); } $domain->delete(); return response()->json([ 'status' => 'success', 'message' => \trans('app.domain-delete-success'), ]); } /** * Create a domain. * * @param \Illuminate\Http\Request $request * * @return \Illuminate\Http\JsonResponse */ 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); } // Validate the input $v = Validator::make( $request->all(), [ 'namespace' => ['required', 'string', new UserEmailDomain()] ] ); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } $namespace = \strtolower(request()->input('namespace')); // Domain already exists if ($domain = Domain::withTrashed()->where('namespace', $namespace)->first()) { // Check if the domain is soft-deleted and belongs to the same user $deleteBeforeCreate = $domain->trashed() && ($wallet = $domain->wallet()) && $wallet->owner && $wallet->owner->id == $owner->id; if (!$deleteBeforeCreate) { $errors = ['namespace' => \trans('validation.domainnotavailable')]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } } if (empty($request->package) || !($package = \App\Package::withEnvTenantContext()->find($request->package))) { $errors = ['package' => \trans('validation.packagerequired')]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } if (!$package->isDomain()) { $errors = ['package' => \trans('validation.packageinvalid')]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } DB::beginTransaction(); // Force-delete the existing domain if it is soft-deleted and belongs to the same user if (!empty($deleteBeforeCreate)) { $domain->forceDelete(); } // Create the domain $domain = Domain::create([ 'namespace' => $namespace, 'type' => \App\Domain::TYPE_EXTERNAL, ]); $domain->assignPackage($package, $owner); DB::commit(); return response()->json([ 'status' => 'success', 'message' => \trans('app.domain-create-success'), ]); } /** * Get the information about the specified domain. * * @param string $id Domain identifier * * @return \Illuminate\Http\JsonResponse|void */ public function show($id) { $domain = Domain::find($id); if (!$this->checkTenant($domain)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canRead($domain)) { return $this->errorResponse(403); } $response = $this->objectToClient($domain, true); // Add hash information to the response $response['hash_text'] = $domain->hash(Domain::HASH_TEXT); $response['hash_cname'] = $domain->hash(Domain::HASH_CNAME); $response['hash_code'] = $domain->hash(Domain::HASH_CODE); // Add DNS/MX configuration for the domain $response['dns'] = self::getDNSConfig($domain); $response['mx'] = self::getMXConfig($domain->namespace); // Domain configuration, e.g. spf whitelist $response['config'] = $domain->getConfig(); // Status info $response['statusInfo'] = self::statusInfo($domain); // Entitlements/Wallet info SkusController::objectEntitlements($domain, $response); return response()->json($response); } /** * Provide DNS MX information to configure specified domain for */ protected static function getMXConfig(string $namespace): array { $entries = []; // copy MX entries from an existing domain if ($master = \config('dns.copyfrom')) { // TODO: cache this lookup foreach ((array) dns_get_record($master, DNS_MX) as $entry) { $entries[] = sprintf( "@\t%s\t%s\tMX\t%d %s.", \config('dns.ttl', $entry['ttl']), $entry['class'], $entry['pri'], $entry['target'] ); } } elseif ($static = \config('dns.static')) { $entries[] = strtr($static, array('\n' => "\n", '%s' => $namespace)); } // display SPF settings if ($spf = \config('dns.spf')) { $entries[] = ';'; foreach (['TXT', 'SPF'] as $type) { $entries[] = sprintf( "@\t%s\tIN\t%s\t\"%s\"", \config('dns.ttl'), $type, $spf ); } } return $entries; } /** * Provide sample DNS config for domain confirmation */ protected static function getDNSConfig(Domain $domain): array { $serial = date('Ymd01'); $hash_txt = $domain->hash(Domain::HASH_TEXT); $hash_cname = $domain->hash(Domain::HASH_CNAME); $hash = $domain->hash(Domain::HASH_CODE); return [ "@ IN SOA ns1.dnsservice.com. hostmaster.{$domain->namespace}. (", " {$serial} 10800 3600 604800 86400 )", ";", "@ IN A ", "www IN A ", ";", "{$hash_cname}.{$domain->namespace}. IN CNAME {$hash}.{$domain->namespace}.", "@ 3600 TXT \"{$hash_txt}\"", ]; } /** * Domain status (extended) information. * * @param \App\Domain $domain Domain object * * @return array Status information */ public static function statusInfo($domain): array { // If that is not a public domain, add domain specific steps return self::processStateInfo( $domain, [ 'domain-new' => true, 'domain-ldap-ready' => $domain->isLdapReady(), 'domain-verified' => $domain->isVerified(), 'domain-confirmed' => [$domain->isConfirmed(), "/domain/{$domain->id}"], ] ); } /** * Execute (synchronously) specified step in a domain setup process. * * @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 $domain->verify(); return $domain->isVerified(); case 'domain-confirmed': // Domain ownership confirmation $domain->confirm(); return $domain->isConfirmed(); } } catch (\Exception $e) { \Log::error($e); } return false; } } diff --git a/src/app/Http/Controllers/API/V4/ResourcesController.php b/src/app/Http/Controllers/API/V4/ResourcesController.php index 743eace2..9eaf3f36 100644 --- a/src/app/Http/Controllers/API/V4/ResourcesController.php +++ b/src/app/Http/Controllers/API/V4/ResourcesController.php @@ -1,194 +1,174 @@ true, 'resource-ldap-ready' => $resource->isLdapReady(), 'resource-imap-ready' => $resource->isImapReady(), ] ); } /** * Create a new resource 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 ResourceName($owner, $domain)]]; $v = Validator::make($request->all(), $rules); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } DB::beginTransaction(); // Create the resource $resource = new Resource(); $resource->name = request()->input('name'); $resource->domainName = $domain; $resource->save(); $resource->assignToWallet($owner->wallets->first()); DB::commit(); return response()->json([ 'status' => 'success', 'message' => \trans('app.resource-create-success'), ]); } /** * Update a resource. * * @param \Illuminate\Http\Request $request The API request. * @param string $id Resource identifier * * @return \Illuminate\Http\JsonResponse The response */ public function update(Request $request, $id) { $resource = Resource::find($id); if (!$this->checkTenant($resource)) { return $this->errorResponse(404); } $current_user = $this->guard()->user(); if (!$current_user->canUpdate($resource)) { return $this->errorResponse(403); } $owner = $resource->wallet()->owner; $name = $request->input('name'); $errors = []; // Validate the resource name if ($name !== null && $name != $resource->name) { $domainName = explode('@', $resource->email, 2)[1]; $rules = ['name' => ['required', 'string', new ResourceName($owner, $domainName)]]; $v = Validator::make($request->all(), $rules); if ($v->fails()) { $errors = $v->errors()->toArray(); } else { $resource->name = $name; } } if (!empty($errors)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } // SkusController::updateEntitlements($resource, $request->skus); $resource->save(); return response()->json([ 'status' => 'success', 'message' => \trans('app.resource-update-success'), ]); } /** * Execute (synchronously) specified step in a resource setup process. * * @param \App\Resource $resource Resource 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(Resource $resource, string $step): ?bool { try { if (strpos($step, 'domain-') === 0) { return DomainsController::execProcessStep($resource->domain(), $step); } 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); } return false; } } diff --git a/src/app/Http/Controllers/API/V4/SharedFoldersController.php b/src/app/Http/Controllers/API/V4/SharedFoldersController.php index b0da67c3..43926d19 100644 --- a/src/app/Http/Controllers/API/V4/SharedFoldersController.php +++ b/src/app/Http/Controllers/API/V4/SharedFoldersController.php @@ -1,287 +1,267 @@ 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->walletOwner(); if (empty($owner) || $owner->id != $current_user->id) { return $this->errorResponse(403); } if ($error_response = $this->validateFolderRequest($request, null, $owner)) { return $error_response; } DB::beginTransaction(); // Create the shared folder $folder = new SharedFolder(); $folder->name = $request->input('name'); $folder->type = $request->input('type'); $folder->domainName = $request->input('domain'); $folder->save(); if (!empty($request->aliases) && $folder->type === 'mail') { $folder->setAliases($request->aliases); } $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); } if ($error_response = $this->validateFolderRequest($request, $folder, $folder->walletOwner())) { return $error_response; } $name = $request->input('name'); DB::beginTransaction(); // SkusController::updateEntitlements($folder, $request->skus); if ($name && $name != $folder->name) { $folder->name = $name; } $folder->save(); if (isset($request->aliases) && $folder->type === 'mail') { $folder->setAliases($request->aliases); } DB::commit(); 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(); + // 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); } return false; } /** * Validate shared folder input * * @param \Illuminate\Http\Request $request The API request. * @param \App\SharedFolder|null $folder Shared folder * @param \App\User|null $owner Account owner * * @return \Illuminate\Http\JsonResponse|null The error response on error */ protected function validateFolderRequest(Request $request, $folder, $owner) { $errors = []; if (empty($folder)) { $name = $request->input('name'); $domain = $request->input('domain'); $rules = [ 'name' => ['required', 'string', new SharedFolderName($owner, $domain)], 'type' => ['required', 'string', new SharedFolderType()], ]; } else { // On update validate the folder name (if changed) $name = $request->input('name'); $domain = explode('@', $folder->email, 2)[1]; if ($name !== null && $name != $folder->name) { $rules = ['name' => ['required', 'string', new SharedFolderName($owner, $domain)]]; } } if (!empty($rules)) { $v = Validator::make($request->all(), $rules); if ($v->fails()) { $errors = $v->errors()->toArray(); } } // Validate aliases input if (isset($request->aliases)) { $aliases = []; $existing_aliases = $owner->aliases()->get()->pluck('alias')->toArray(); foreach ($request->aliases as $idx => $alias) { if (is_string($alias) && !empty($alias)) { // Alias cannot be the same as the email address if (!empty($folder) && Str::lower($alias) == Str::lower($folder->email)) { continue; } // validate new aliases if ( !in_array($alias, $existing_aliases) && ($error = self::validateAlias($alias, $owner, $name, $domain)) ) { if (!isset($errors['aliases'])) { $errors['aliases'] = []; } $errors['aliases'][$idx] = $error; continue; } $aliases[] = $alias; } } $request->aliases = $aliases; } if (!empty($errors)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } return null; } /** * Email address validation for use as a shared folder alias. * * @param string $alias Email address * @param \App\User $owner The account owner * @param string $folderName Folder name * @param string $domain Folder domain * * @return ?string Error message on validation error */ public static function validateAlias(string $alias, \App\User $owner, string $folderName, string $domain): ?string { $lmtp_alias = "shared+shared/{$folderName}@{$domain}"; if ($alias === $lmtp_alias) { return null; } return UsersController::validateAlias($alias, $owner); } } diff --git a/src/app/Http/Controllers/API/V4/UsersController.php b/src/app/Http/Controllers/API/V4/UsersController.php index 8d7ca29a..ca253cbd 100644 --- a/src/app/Http/Controllers/API/V4/UsersController.php +++ b/src/app/Http/Controllers/API/V4/UsersController.php @@ -1,722 +1,702 @@ guard()->user(); $search = trim(request()->input('search')); $page = intval(request()->input('page')) ?: 1; $pageSize = 20; $hasMore = false; $result = $user->users(); // Search by user email, alias or name if (strlen($search) > 0) { // thanks to cloning we skip some extra queries in $user->users() $allUsers1 = clone $result; $allUsers2 = clone $result; $result->whereLike('email', $search) ->union( $allUsers1->join('user_aliases', 'users.id', '=', 'user_aliases.user_id') ->whereLike('alias', $search) ) ->union( $allUsers2->join('user_settings', 'users.id', '=', 'user_settings.user_id') ->whereLike('value', $search) ->whereIn('key', ['first_name', 'last_name']) ); } $result = $result->orderBy('email') ->limit($pageSize + 1) ->offset($pageSize * ($page - 1)) ->get(); if (count($result) > $pageSize) { $result->pop(); $hasMore = true; } // Process the result $result = $result->map( function ($user) { return $this->objectToClient($user); } ); $result = [ 'list' => $result, 'count' => count($result), 'hasMore' => $hasMore, ]; return response()->json($result); } /** * Display information on the user account specified by $id. * * @param string $id The account to show information for. * * @return \Illuminate\Http\JsonResponse */ public function show($id) { $user = User::find($id); if (!$this->checkTenant($user)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canRead($user)) { return $this->errorResponse(403); } $response = $this->userResponse($user); $response['skus'] = \App\Entitlement::objectEntitlementsSummary($user); $response['config'] = $user->getConfig(); $response['aliases'] = $user->aliases()->pluck('alias')->all(); $code = $user->verificationcodes()->where('active', true) ->where('expires_at', '>', \Carbon\Carbon::now()) ->first(); if ($code) { $response['passwordLinkCode'] = $code->short_code . '-' . $code->code; } return response()->json($response); } /** * User status (extended) information * * @param \App\User $user User object * * @return array Status information */ public static function statusInfo($user): array { $process = self::processStateInfo( $user, [ 'user-new' => true, 'user-ldap-ready' => $user->isLdapReady(), 'user-imap-ready' => $user->isImapReady(), ] ); // Check if the user is a controller of his wallet $isController = $user->canDelete($user); $isDegraded = $user->isDegraded(); $hasMeet = !$isDegraded && Sku::withObjectTenantContext($user)->where('title', 'room')->exists(); $hasCustomDomain = $user->wallet()->entitlements() ->where('entitleable_type', Domain::class) ->count() > 0; // Get user's entitlements titles $skus = $user->entitlements()->select('skus.title') ->join('skus', 'skus.id', '=', 'entitlements.sku_id') ->get() ->pluck('title') ->sort() ->unique() ->values() ->all(); $hasBeta = in_array('beta', $skus); $result = [ 'skus' => $skus, 'enableBeta' => in_array('beta', $skus), // TODO: This will change when we enable all users to create domains 'enableDomains' => $isController && $hasCustomDomain, // TODO: Make 'enableDistlists' working for wallet controllers that aren't account owners 'enableDistlists' => $isController && $hasCustomDomain && $hasBeta, 'enableFiles' => !$isDegraded && $hasBeta && \config('app.with_files'), // TODO: Make 'enableFolders' working for wallet controllers that aren't account owners 'enableFolders' => $isController && $hasCustomDomain && $hasBeta, // TODO: Make 'enableResources' working for wallet controllers that aren't account owners 'enableResources' => $isController && $hasCustomDomain && $hasBeta, 'enableRooms' => $hasMeet, 'enableSettings' => $isController, 'enableUsers' => $isController, 'enableWallets' => $isController, 'enableCompanionapps' => $hasBeta, ]; return array_merge($process, $result); } /** * Create a new user 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->walletOwner(); if ($owner->id != $current_user->id) { return $this->errorResponse(403); } $this->deleteBeforeCreate = null; if ($error_response = $this->validateUserRequest($request, null, $settings)) { return $error_response; } if (empty($request->package) || !($package = \App\Package::withEnvTenantContext()->find($request->package))) { $errors = ['package' => \trans('validation.packagerequired')]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } if ($package->isDomain()) { $errors = ['package' => \trans('validation.packageinvalid')]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } DB::beginTransaction(); // @phpstan-ignore-next-line if ($this->deleteBeforeCreate) { $this->deleteBeforeCreate->forceDelete(); } // Create user record $user = User::create([ 'email' => $request->email, 'password' => $request->password, ]); $this->activatePassCode($user); $owner->assignPackage($package, $user); if (!empty($settings)) { $user->setSettings($settings); } if (!empty($request->aliases)) { $user->setAliases($request->aliases); } DB::commit(); return response()->json([ 'status' => 'success', 'message' => \trans('app.user-create-success'), ]); } /** * Update user data. * * @param \Illuminate\Http\Request $request The API request. * @param string $id User identifier * * @return \Illuminate\Http\JsonResponse The response */ public function update(Request $request, $id) { $user = User::withEnvTenantContext()->find($id); if (empty($user)) { return $this->errorResponse(404); } $current_user = $this->guard()->user(); // TODO: Decide what attributes a user can change on his own profile if (!$current_user->canUpdate($user)) { return $this->errorResponse(403); } if ($error_response = $this->validateUserRequest($request, $user, $settings)) { return $error_response; } // Entitlements, only controller can do that if ($request->skus !== null && !$current_user->canDelete($user)) { return $this->errorResponse(422, "You have no permission to change entitlements"); } DB::beginTransaction(); SkusController::updateEntitlements($user, $request->skus); if (!empty($settings)) { $user->setSettings($settings); } if (!empty($request->password)) { $user->password = $request->password; $user->save(); } $this->activatePassCode($user); if (isset($request->aliases)) { $user->setAliases($request->aliases); } DB::commit(); $response = [ 'status' => 'success', 'message' => \trans('app.user-update-success'), ]; // For self-update refresh the statusInfo in the UI if ($user->id == $current_user->id) { $response['statusInfo'] = self::statusInfo($user); } return response()->json($response); } /** * Create a response data array for specified user. * * @param \App\User $user User object * * @return array Response data */ public static function userResponse(User $user): array { $response = array_merge($user->toArray(), self::objectState($user)); // Settings $response['settings'] = []; foreach ($user->settings()->whereIn('key', self::USER_SETTINGS)->get() as $item) { $response['settings'][$item->key] = $item->value; } // Status info $response['statusInfo'] = self::statusInfo($user); // Add more info to the wallet object output $map_func = function ($wallet) use ($user) { $result = $wallet->toArray(); if ($wallet->discount) { $result['discount'] = $wallet->discount->discount; $result['discount_description'] = $wallet->discount->description; } if ($wallet->user_id != $user->id) { $result['user_email'] = $wallet->owner->email; } $provider = \App\Providers\PaymentProvider::factory($wallet); $result['provider'] = $provider->name(); return $result; }; // Information about wallets and accounts for access checks $response['wallets'] = $user->wallets->map($map_func)->toArray(); $response['accounts'] = $user->accounts->map($map_func)->toArray(); $response['wallet'] = $map_func($user->wallet()); return $response; } /** * Prepare user statuses for the UI * * @param \App\User $user User object * * @return array Statuses array */ protected static function objectState($user): array { $state = parent::objectState($user); $state['isAccountDegraded'] = $user->isDegraded(true); return $state; } /** * Validate user input * * @param \Illuminate\Http\Request $request The API request. * @param \App\User|null $user User identifier * @param array $settings User settings (from the request) * * @return \Illuminate\Http\JsonResponse|null The error response on error */ protected function validateUserRequest(Request $request, $user, &$settings = []) { $rules = [ 'external_email' => 'nullable|email', 'phone' => 'string|nullable|max:64|regex:/^[0-9+() -]+$/', 'first_name' => 'string|nullable|max:128', 'last_name' => 'string|nullable|max:128', 'organization' => 'string|nullable|max:512', 'billing_address' => 'string|nullable|max:1024', 'country' => 'string|nullable|alpha|size:2', 'currency' => 'string|nullable|alpha|size:3', 'aliases' => 'array|nullable', ]; $controller = ($user ?: $this->guard()->user())->walletOwner(); // Handle generated password reset code if ($code = $request->input('passwordLinkCode')) { // Accept - input if (strpos($code, '-')) { $code = explode('-', $code)[1]; } $this->passCode = $this->guard()->user()->verificationcodes() ->where('code', $code)->where('active', false)->first(); // Generate a password for a new user with password reset link // FIXME: Should/can we have a user with no password set? if ($this->passCode && empty($user)) { $request->password = $request->password_confirmation = Str::random(16); $ignorePassword = true; } } if (empty($user) || !empty($request->password) || !empty($request->password_confirmation)) { if (empty($ignorePassword)) { $rules['password'] = ['required', 'confirmed', new Password($controller)]; } } $errors = []; // Validate input $v = Validator::make($request->all(), $rules); if ($v->fails()) { $errors = $v->errors()->toArray(); } // For new user validate email address if (empty($user)) { $email = $request->email; if (empty($email)) { $errors['email'] = \trans('validation.required', ['attribute' => 'email']); } elseif ($error = self::validateEmail($email, $controller, $this->deleteBeforeCreate)) { $errors['email'] = $error; } } // Validate aliases input if (isset($request->aliases)) { $aliases = []; $existing_aliases = $user ? $user->aliases()->get()->pluck('alias')->toArray() : []; foreach ($request->aliases as $idx => $alias) { if (is_string($alias) && !empty($alias)) { // Alias cannot be the same as the email address (new user) if (!empty($email) && Str::lower($alias) == Str::lower($email)) { continue; } // validate new aliases if ( !in_array($alias, $existing_aliases) && ($error = self::validateAlias($alias, $controller)) ) { if (!isset($errors['aliases'])) { $errors['aliases'] = []; } $errors['aliases'][$idx] = $error; continue; } $aliases[] = $alias; } } $request->aliases = $aliases; } if (!empty($errors)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } // Update user settings $settings = $request->only(array_keys($rules)); unset($settings['password'], $settings['aliases'], $settings['email']); return null; } /** * Execute (synchronously) specified step in a user setup process. * * @param \App\User $user User 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(User $user, string $step): ?bool { try { if (strpos($step, 'domain-') === 0) { list ($local, $domain) = explode('@', $user->email); $domain = Domain::where('namespace', $domain)->first(); return DomainsController::execProcessStep($domain, $step); } 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); } return false; } /** * Email address validation for use as a user mailbox (login). * * @param string $email Email address * @param \App\User $user The account owner * @param null|\App\User|\App\Group $deleted Filled with an instance of a deleted user or group * with the specified email address, if exists * * @return ?string Error message on validation error */ public static function validateEmail(string $email, \App\User $user, &$deleted = null): ?string { $deleted = null; if (strpos($email, '@') === false) { return \trans('validation.entryinvalid', ['attribute' => 'email']); } list($login, $domain) = explode('@', Str::lower($email)); if (strlen($login) === 0 || strlen($domain) === 0) { return \trans('validation.entryinvalid', ['attribute' => 'email']); } // Check if domain exists $domain = Domain::withObjectTenantContext($user)->where('namespace', $domain)->first(); if (empty($domain)) { return \trans('validation.domaininvalid'); } // Validate login part alone $v = Validator::make( ['email' => $login], ['email' => ['required', new UserEmailLocal(!$domain->isPublic())]] ); if ($v->fails()) { return $v->errors()->toArray()['email'][0]; } // Check if it is one of domains available to the user if (!$domain->isPublic() && $user->id != $domain->walletOwner()->id) { return \trans('validation.entryexists', ['attribute' => 'domain']); } // Check if a user/group/resource/shared folder with specified address already exists if ( ($existing = User::emailExists($email, true)) || ($existing = \App\Group::emailExists($email, true)) || ($existing = \App\Resource::emailExists($email, true)) || ($existing = \App\SharedFolder::emailExists($email, true)) ) { // If this is a deleted user/group/resource/folder in the same custom domain // we'll force delete it before creating the target user if (!$domain->isPublic() && $existing->trashed()) { $deleted = $existing; } else { return \trans('validation.entryexists', ['attribute' => 'email']); } } // Check if an alias with specified address already exists. if (User::aliasExists($email) || \App\SharedFolder::aliasExists($email)) { return \trans('validation.entryexists', ['attribute' => 'email']); } return null; } /** * Email address validation for use as an alias. * * @param string $email Email address * @param \App\User $user The account owner * * @return ?string Error message on validation error */ public static function validateAlias(string $email, \App\User $user): ?string { if (strpos($email, '@') === false) { return \trans('validation.entryinvalid', ['attribute' => 'alias']); } list($login, $domain) = explode('@', Str::lower($email)); if (strlen($login) === 0 || strlen($domain) === 0) { return \trans('validation.entryinvalid', ['attribute' => 'alias']); } // Check if domain exists $domain = Domain::withObjectTenantContext($user)->where('namespace', $domain)->first(); if (empty($domain)) { return \trans('validation.domaininvalid'); } // Validate login part alone $v = Validator::make( ['alias' => $login], ['alias' => ['required', new UserEmailLocal(!$domain->isPublic())]] ); if ($v->fails()) { return $v->errors()->toArray()['alias'][0]; } // Check if it is one of domains available to the user if (!$domain->isPublic() && $user->id != $domain->walletOwner()->id) { return \trans('validation.entryexists', ['attribute' => 'domain']); } // Check if a user with specified address already exists if ($existing_user = User::emailExists($email, true)) { // Allow an alias in a custom domain to an address that was a user before if ($domain->isPublic() || !$existing_user->trashed()) { return \trans('validation.entryexists', ['attribute' => 'alias']); } } // Check if a group/resource/shared folder with specified address already exists if ( \App\Group::emailExists($email) || \App\Resource::emailExists($email) || \App\SharedFolder::emailExists($email) ) { return \trans('validation.entryexists', ['attribute' => 'alias']); } // Check if an alias with specified address already exists if (User::aliasExists($email) || \App\SharedFolder::aliasExists($email)) { // Allow assigning the same alias to a user in the same group account, // but only for non-public domains if ($domain->isPublic()) { return \trans('validation.entryexists', ['attribute' => 'alias']); } } return null; } /** * Activate password reset code (if set), and assign it to a user. * * @param \App\User $user The user */ protected function activatePassCode(User $user): void { // Activate the password reset code if ($this->passCode) { $this->passCode->user_id = $user->id; $this->passCode->active = true; $this->passCode->save(); } } } diff --git a/src/app/Http/Controllers/RelationController.php b/src/app/Http/Controllers/RelationController.php index f9c2625f..ec07d7c7 100644 --- a/src/app/Http/Controllers/RelationController.php +++ b/src/app/Http/Controllers/RelationController.php @@ -1,392 +1,398 @@ model::find($id); if (!$this->checkTenant($resource)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canDelete($resource)) { return $this->errorResponse(403); } $resource->delete(); return response()->json([ 'status' => 'success', 'message' => \trans("app.{$this->label}-delete-success"), ]); } /** * Listing of resources belonging to the authenticated user. * * The resource entitlements billed to the current user wallet(s) * * @return \Illuminate\Http\JsonResponse */ public function index() { $user = $this->guard()->user(); $method = Str::plural(\lcfirst(\class_basename($this->model))); $query = call_user_func_array([$user, $method], $this->relationArgs); if (!empty($this->order)) { foreach ($this->order as $col) { $query->orderBy($col); } } // TODO: Search and paging $result = $query->get() ->map(function ($resource) { return $this->objectToClient($resource); }); $result = [ 'list' => $result, 'count' => count($result), 'hasMore' => false, 'message' => \trans("app.search-foundx{$this->label}s", ['x' => count($result)]), ]; return response()->json($result); } /** * Prepare resource statuses for the UI * * @param object $resource Resource object * * @return array Statuses array */ protected static function objectState($resource): array { $state = []; $reflect = new \ReflectionClass(get_class($resource)); foreach (array_keys($reflect->getConstants()) as $const) { if (strpos($const, 'STATUS_') === 0 && $const != 'STATUS_NEW') { $method = Str::camel('is_' . strtolower(substr($const, 7))); $state[$method] = $resource->{$method}(); } } if (empty($state['isDeleted']) && method_exists($resource, 'trashed')) { $state['isDeleted'] = $resource->trashed(); } return $state; } /** * Prepare a resource object for the UI. * * @param object $object An object * @param bool $full Include all object properties * * @return array Object information */ protected function objectToClient($object, bool $full = false): array { if ($full) { $result = $object->toArray(); unset($result['tenant_id']); } else { $result = ['id' => $object->id]; foreach ($this->objectProps as $prop) { $result[$prop] = $object->{$prop}; } } $result = array_merge($result, $this->objectState($object)); return $result; } /** * Object status' process information. * * @param object $object The object to process * @param array $steps The steps definition * * @return array Process state information */ protected static function processStateInfo($object, array $steps): array { $process = []; + $withLdap = \config('app.with_ldap'); // Create a process check list foreach ($steps as $step_name => $state) { + // Remove LDAP related steps if the backend is disabled + if (!$withLdap && strpos($step_name, '-ldap-')) { + continue; + } + $step = [ 'label' => $step_name, 'title' => \trans("app.process-{$step_name}"), ]; if (is_array($state)) { $step['link'] = $state[1]; $state = $state[0]; } $step['state'] = $state; $process[] = $step; } // Add domain specific steps if (method_exists($object, 'domain')) { $domain = $object->domain(); // If that is not a public domain if ($domain && !$domain->isPublic()) { $domain_status = API\V4\DomainsController::statusInfo($domain); $process = array_merge($process, $domain_status['process']); } } $all = count($process); $checked = count(array_filter($process, function ($v) { return $v['state']; })); $state = $all === $checked ? 'done' : 'running'; // After 180 seconds assume the process is in failed state, // this should unlock the Refresh button in the UI if ($all !== $checked && $object->created_at->diffInSeconds(\Carbon\Carbon::now()) > 180) { $state = 'failed'; } return [ 'process' => $process, 'processState' => $state, 'isReady' => $all === $checked, ]; } /** * Object status' process information update. * * @param object $object The object to process * * @return array Process state information */ protected function processStateUpdate($object): array { $response = $this->statusInfo($object); if (!empty(request()->input('refresh'))) { $updated = false; $async = false; $last_step = 'none'; foreach ($response['process'] as $idx => $step) { $last_step = $step['label']; if (!$step['state']) { $exec = $this->execProcessStep($object, $step['label']); // @phpstan-ignore-line if (!$exec) { if ($exec === null) { $async = true; } break; } $updated = true; } } if ($updated) { $response = $this->statusInfo($object); } $success = $response['isReady']; $suffix = $success ? 'success' : 'error-' . $last_step; $response['status'] = $success ? 'success' : 'error'; $response['message'] = \trans('app.process-' . $suffix); if ($async && !$success) { $response['processState'] = 'waiting'; $response['status'] = 'success'; $response['message'] = \trans('app.process-async'); } } return $response; } /** * Set the resource configuration. * * @param int $id Resource identifier * * @return \Illuminate\Http\JsonResponse|void */ public function setConfig($id) { $resource = $this->model::find($id); if (!method_exists($this->model, 'setConfig')) { return $this->errorResponse(404); } if (!$this->checkTenant($resource)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canUpdate($resource)) { return $this->errorResponse(403); } $errors = $resource->setConfig(request()->input()); if (!empty($errors)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } return response()->json([ 'status' => 'success', 'message' => \trans("app.{$this->label}-setconfig-success"), ]); } /** * Display information of a resource specified by $id. * * @param string $id The resource to show information for. * * @return \Illuminate\Http\JsonResponse */ public function show($id) { $resource = $this->model::find($id); if (!$this->checkTenant($resource)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canRead($resource)) { return $this->errorResponse(403); } $response = $this->objectToClient($resource, true); if (!empty($statusInfo = $this->statusInfo($resource))) { $response['statusInfo'] = $statusInfo; } // Resource configuration, e.g. sender_policy, invitation_policy, acl if (method_exists($resource, 'getConfig')) { $response['config'] = $resource->getConfig(); } if (method_exists($resource, 'aliases')) { $response['aliases'] = $resource->aliases()->pluck('alias')->all(); } // Entitlements/Wallet info if (method_exists($resource, 'wallet')) { API\V4\SkusController::objectEntitlements($resource, $response); } return response()->json($response); } /** * Get a list of SKUs available to the resource. * * @param int $id Resource identifier * * @return \Illuminate\Http\JsonResponse */ public function skus($id) { $resource = $this->model::find($id); if (!$this->checkTenant($resource)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canRead($resource)) { return $this->errorResponse(403); } return API\V4\SkusController::objectSkus($resource); } /** * Fetch resource status (and reload setup process) * * @param int $id Resource identifier * * @return \Illuminate\Http\JsonResponse */ public function status($id) { $resource = $this->model::find($id); if (!$this->checkTenant($resource)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canRead($resource)) { return $this->errorResponse(403); } $response = $this->processStateUpdate($resource); $response = array_merge($response, $this->objectState($resource)); return response()->json($response); } /** * Resource status (extended) information * * @param object $resource Resource object * * @return array Status information */ public static function statusInfo($resource): array { return []; } } diff --git a/src/app/Jobs/Domain/CreateJob.php b/src/app/Jobs/Domain/CreateJob.php index cbe90e97..71386735 100644 --- a/src/app/Jobs/Domain/CreateJob.php +++ b/src/app/Jobs/Domain/CreateJob.php @@ -1,31 +1,31 @@ getDomain(); if (!$domain) { return; } - if (!$domain->isLdapReady()) { + if (\config('app.with_ldap') && !$domain->isLdapReady()) { \App\Backends\LDAP::createDomain($domain); $domain->status |= \App\Domain::STATUS_LDAP_READY; $domain->save(); - - \App\Jobs\Domain\VerifyJob::dispatch($domain->id); } + + \App\Jobs\Domain\VerifyJob::dispatch($domain->id); } } diff --git a/src/app/Jobs/Domain/DeleteJob.php b/src/app/Jobs/Domain/DeleteJob.php index 5e070223..3de9a3e3 100644 --- a/src/app/Jobs/Domain/DeleteJob.php +++ b/src/app/Jobs/Domain/DeleteJob.php @@ -1,38 +1,37 @@ getDomain(); if (!$domain) { return; } // sanity checks if ($domain->isDeleted()) { $this->fail(new \Exception("Domain {$this->domainId} is already marked as deleted.")); return; } - \App\Backends\LDAP::deleteDomain($domain); + if (\config('app.with_ldap') && $domain->isLdapReady()) { + \App\Backends\LDAP::deleteDomain($domain); - $domain->status |= \App\Domain::STATUS_DELETED; - - if ($domain->isLdapReady()) { $domain->status ^= \App\Domain::STATUS_LDAP_READY; } + $domain->status |= \App\Domain::STATUS_DELETED; $domain->save(); } } diff --git a/src/app/Jobs/Domain/UpdateJob.php b/src/app/Jobs/Domain/UpdateJob.php index de84fe08..95d00b75 100644 --- a/src/app/Jobs/Domain/UpdateJob.php +++ b/src/app/Jobs/Domain/UpdateJob.php @@ -1,29 +1,29 @@ getDomain(); if (!$domain) { return; } - if (!$domain->isLdapReady()) { + if (!\config('app.with_ldap') || !$domain->isLdapReady()) { $this->delete(); return; } \App\Backends\LDAP::updateDomain($domain); } } diff --git a/src/app/Jobs/Group/CreateJob.php b/src/app/Jobs/Group/CreateJob.php index 4437a4a2..71abc690 100644 --- a/src/app/Jobs/Group/CreateJob.php +++ b/src/app/Jobs/Group/CreateJob.php @@ -1,29 +1,31 @@ getGroup(); if (!$group) { return; } - if (!$group->isLdapReady()) { + if (\config('app.with_ldap') && !$group->isLdapReady()) { \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 index 2d550693..d9ac320b 100644 --- a/src/app/Jobs/Group/DeleteJob.php +++ b/src/app/Jobs/Group/DeleteJob.php @@ -1,38 +1,41 @@ getGroup(); if (!$group) { return; } // sanity checks if ($group->isDeleted()) { $this->fail(new \Exception("Group {$this->groupId} is already marked as deleted.")); return; } - \App\Backends\LDAP::deleteGroup($group); + if (\config('app.with_ldap') && $group->isLdapReady()) { + \App\Backends\LDAP::deleteGroup($group); - $group->status |= \App\Group::STATUS_DELETED; - - if ($group->isLdapReady()) { $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/Group/UpdateJob.php b/src/app/Jobs/Group/UpdateJob.php index f90cd63f..fea4e925 100644 --- a/src/app/Jobs/Group/UpdateJob.php +++ b/src/app/Jobs/Group/UpdateJob.php @@ -1,48 +1,48 @@ getGroup(); if (!$group) { return; } // Cancel the update if the group is deleted or not yet in LDAP - if (!$group->isLdapReady() || $group->isDeleted()) { + if (!\config('app.with_ldap') || !$group->isLdapReady() || $group->isDeleted()) { $this->delete(); return; } LDAP::connect(); // Groups does not have an attribute for the status, therefore // we remove suspended groups from LDAP. // We do not remove STATUS_LDAP_READY flag because it is part of the // setup process. $inLdap = !empty(LDAP::getGroup($group->email)); if ($group->isSuspended() && $inLdap) { LDAP::deleteGroup($group); } elseif (!$group->isSuspended() && !$inLdap) { LDAP::createGroup($group); } else { LDAP::updateGroup($group); } LDAP::disconnect(); } } diff --git a/src/app/Jobs/IMAP/AclCleanupJob.php b/src/app/Jobs/IMAP/AclCleanupJob.php new file mode 100644 index 00000000..cbd6606c --- /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 index 5cd2cc7e..f56b8c70 100644 --- a/src/app/Jobs/Resource/CreateJob.php +++ b/src/app/Jobs/Resource/CreateJob.php @@ -1,61 +1,71 @@ getResource(); if (!$resource) { return; } // sanity checks if ($resource->isDeleted()) { $this->fail(new \Exception("Resource {$this->resourceId} is marked as deleted.")); return; } if ($resource->trashed()) { $this->fail(new \Exception("Resource {$this->resourceId} is actually deleted.")); return; } - if ($resource->isLdapReady()) { - $this->fail(new \Exception("Resource {$this->resourceId} is already marked as ldap-ready.")); - return; - } + $withLdap = \config('app.with_ldap'); // see if the domain is ready $domain = $resource->domain(); if (!$domain) { $this->fail(new \Exception("The domain for resource {$this->resourceId} does not exist.")); return; } if ($domain->isDeleted()) { $this->fail(new \Exception("The domain for resource {$this->resourceId} is marked as deleted.")); return; } - if (!$domain->isLdapReady()) { + if ($withLdap && !$domain->isLdapReady()) { $this->release(60); return; } - \App\Backends\LDAP::createResource($resource); + if ($withLdap && !$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 index c109e2ac..afe6c6b8 100644 --- a/src/app/Jobs/Resource/DeleteJob.php +++ b/src/app/Jobs/Resource/DeleteJob.php @@ -1,42 +1,46 @@ getResource(); if (!$resource) { return; } // sanity checks if ($resource->isDeleted()) { $this->fail(new \Exception("Resource {$this->resourceId} is already marked as deleted.")); return; } - \App\Backends\LDAP::deleteResource($resource); + if (\config('app.with_ldap') && $resource->isLdapReady()) { + \App\Backends\LDAP::deleteResource($resource); - $resource->status |= \App\Resource::STATUS_DELETED; - - if ($resource->isLdapReady()) { $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 index 86b7d201..b2038fed 100644 --- a/src/app/Jobs/Resource/UpdateJob.php +++ b/src/app/Jobs/Resource/UpdateJob.php @@ -1,30 +1,38 @@ getResource(); if (!$resource) { 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 (\config('app.with_ldap') && $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 index 74cc2a3d..219e937f 100644 --- a/src/app/Jobs/ResourceJob.php +++ b/src/app/Jobs/ResourceJob.php @@ -1,73 +1,82 @@ handle(); * ``` */ 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. * * @var int */ protected $resourceId; /** * The \App\Resource email property, for legibility in the queue management. * * @var string */ protected $resourceEmail; /** * 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(); if ($resource) { $this->resourceEmail = $resource->email; } } /** * Get the \App\Resource entry associated with this job. * * @return \App\Resource|null * * @throws \Exception */ protected function getResource() { $resource = \App\Resource::withTrashed()->find($this->resourceId); if (!$resource) { // 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 Resource\CreateJob) { $this->release(5); return null; } $this->fail(new \Exception("Resource {$this->resourceId} could not be found in the database.")); } return $resource; } } diff --git a/src/app/Jobs/SharedFolder/CreateJob.php b/src/app/Jobs/SharedFolder/CreateJob.php index 7e2cf586..fc049a9a 100644 --- a/src/app/Jobs/SharedFolder/CreateJob.php +++ b/src/app/Jobs/SharedFolder/CreateJob.php @@ -1,61 +1,71 @@ 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; - } + $withLdap = \config('app.with_ldap'); // 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()) { + if ($withLdap && !$domain->isLdapReady()) { $this->release(60); return; } - \App\Backends\LDAP::createSharedFolder($folder); + if ($withLdap && !$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 index 361d25dc..e199475e 100644 --- a/src/app/Jobs/SharedFolder/DeleteJob.php +++ b/src/app/Jobs/SharedFolder/DeleteJob.php @@ -1,42 +1,46 @@ 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); + if (\config('app.with_ldap') && $folder->isLdapReady()) { + \App\Backends\LDAP::deleteSharedFolder($folder); - $folder->status |= \App\SharedFolder::STATUS_DELETED; - - if ($folder->isLdapReady()) { $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 index 7cd6e420..780a0adc 100644 --- a/src/app/Jobs/SharedFolder/UpdateJob.php +++ b/src/app/Jobs/SharedFolder/UpdateJob.php @@ -1,30 +1,38 @@ getSharedFolder(); if (!$folder) { 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 (\config('app.with_ldap') && $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 index 304b3dd7..d90904a2 100644 --- a/src/app/Jobs/SharedFolderJob.php +++ b/src/app/Jobs/SharedFolderJob.php @@ -1,72 +1,81 @@ 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; + /** + * 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(); 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/Jobs/User/CreateJob.php b/src/app/Jobs/User/CreateJob.php index 3871c10c..4a084e49 100644 --- a/src/app/Jobs/User/CreateJob.php +++ b/src/app/Jobs/User/CreateJob.php @@ -1,78 +1,88 @@ isDeleted()`), or * * the user is actually deleted (`$user->deleted_at`), or * * the user is already marked as ready in LDAP (`$user->isLdapReady()`). * */ class CreateJob extends UserJob { /** * Execute the job. * * @return void * * @throws \Exception */ public function handle() { $user = $this->getUser(); if (!$user) { return; } if ($user->role) { // Admins/resellers don't reside in LDAP (for now) return; } // sanity checks if ($user->isDeleted()) { $this->fail(new \Exception("User {$this->userId} is marked as deleted.")); 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; - } + $withLdap = \config('app.with_ldap'); // see if the domain is ready $domain = $user->domain(); if (!$domain) { $this->fail(new \Exception("The domain for {$this->userId} does not exist.")); return; } if ($domain->isDeleted()) { $this->fail(new \Exception("The domain for {$this->userId} is marked as deleted.")); return; } - if (!$domain->isLdapReady()) { + if ($withLdap && !$domain->isLdapReady()) { $this->release(60); return; } - \App\Backends\LDAP::createUser($user); + if ($withLdap && !$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 index 3569bb10..12c12cab 100644 --- a/src/app/Jobs/User/DeleteJob.php +++ b/src/app/Jobs/User/DeleteJob.php @@ -1,47 +1,55 @@ getUser(); if (!$user) { return; } if ($user->role) { // Admins/resellers don't reside in LDAP (for now) return; } // sanity checks if ($user->isDeleted()) { $this->fail(new \Exception("User {$this->userId} is already marked as deleted.")); return; } - \App\Backends\LDAP::deleteUser($user); + if (\config('app.with_ldap') && $user->isLdapReady()) { + \App\Backends\LDAP::deleteUser($user); - $user->status |= \App\User::STATUS_DELETED; - - if ($user->isLdapReady()) { $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; } + if (\config('database.connections.roundcube')) { + \App\Backends\Roundcube::deleteUser($user->email); + } + + $user->status |= \App\User::STATUS_DELETED; $user->save(); } } diff --git a/src/app/Jobs/User/UpdateJob.php b/src/app/Jobs/User/UpdateJob.php index 40214986..01b57d24 100644 --- a/src/app/Jobs/User/UpdateJob.php +++ b/src/app/Jobs/User/UpdateJob.php @@ -1,34 +1,37 @@ getUser(); if (!$user) { return; } if ($user->role) { // Admins/resellers don't reside in LDAP (for now) return; } - if (!$user->isLdapReady()) { - $this->delete(); - return; + if (\config('app.with_ldap') && $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 index 47982233..2025bca3 100644 --- a/src/app/Observers/EntitlementObserver.php +++ b/src/app/Observers/EntitlementObserver.php @@ -1,182 +1,192 @@ wallet_id); if (!$wallet || !$wallet->owner) { return false; } $sku = \App\Sku::find($entitlement->sku_id); if (!$sku) { return false; } $result = $sku->handler_class::preReq($entitlement, $wallet->owner); if (!$result) { return false; } return true; } /** * Handle the entitlement "created" event. * * @param \App\Entitlement $entitlement The entitlement. * * @return void */ public function created(Entitlement $entitlement) { $entitlement->entitleable->updated_at = Carbon::now(); $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); + } } /** * Handle the entitlement "deleted" event. * * @param \App\Entitlement $entitlement The entitlement. * * @return void */ 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? $sf = new \App\Auth\SecondFactor($entitlement->entitleable); $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); } } /** * Handle the entitlement "deleting" event. * * @param \App\Entitlement $entitlement The entitlement. * * @return void */ public function deleting(Entitlement $entitlement) { if ($entitlement->trashed()) { return; } // Start calculating the costs for the consumption of this entitlement if the // existing consumption spans >= 14 days. // // Effect is that anything's free for the first 14 days if ($entitlement->created_at >= Carbon::now()->subDays(14)) { return; } $owner = $entitlement->wallet->owner; if ($owner->isDegraded()) { return; } $now = Carbon::now(); // Determine if we're still within the trial period $trial = $entitlement->wallet->trialInfo(); if ( !empty($trial) && $entitlement->updated_at < $trial['end'] && in_array($entitlement->sku_id, $trial['skus']) ) { if ($trial['end'] >= $now) { return; } $entitlement->updated_at = $trial['end']; } // get the discount rate applied to the wallet. $discount = $entitlement->wallet->getDiscountRate(); // just in case this had not been billed yet, ever $diffInMonths = $entitlement->updated_at->diffInMonths($now); $cost = (int) ($entitlement->cost * $discount * $diffInMonths); $fee = (int) ($entitlement->fee * $diffInMonths); // this moves the hypothetical updated at forward to however many months past the original $updatedAt = $entitlement->updated_at->copy()->addMonthsWithoutOverflow($diffInMonths); // now we have the diff in days since the last "billed" period end. // This may be an entitlement paid up until February 28th, 2020, with today being March // 12th 2020. Calculating the costs for the entitlement is based on the daily price // the price per day is based on the number of days in the last month // or the current month if the period does not overlap with the previous month // FIXME: This really should be simplified to $daysInMonth=30 $diffInDays = $updatedAt->diffInDays($now); if ($now->day >= $diffInDays) { $daysInMonth = $now->daysInMonth; } else { $daysInMonth = \App\Utils::daysInLastMonth(); } $pricePerDay = $entitlement->cost / $daysInMonth; $feePerDay = $entitlement->fee / $daysInMonth; $cost += (int) (round($pricePerDay * $discount * $diffInDays, 0)); $fee += (int) (round($feePerDay * $diffInDays, 0)); $profit = $cost - $fee; if ($profit != 0 && $owner->tenant && ($wallet = $owner->tenant->wallet())) { $desc = "Charged user {$owner->email}"; $method = $profit > 0 ? 'credit' : 'debit'; $wallet->{$method}(abs($profit), $desc); } if ($cost == 0) { return; } // FIXME: Shouldn't we create per-entitlement transaction record? $entitlement->wallet->debit($cost); } } diff --git a/src/app/Observers/GroupObserver.php b/src/app/Observers/GroupObserver.php index 8d50b211..73f46746 100644 --- a/src/app/Observers/GroupObserver.php +++ b/src/app/Observers/GroupObserver.php @@ -1,102 +1,102 @@ 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]; } } /** * Handle the group "created" event. * * @param \App\Group $group The group * * @return void */ public function created(Group $group) { \App\Jobs\Group\CreateJob::dispatch($group->id); } /** * Handle the group "deleted" event. * * @param \App\Group $group The group * * @return void */ public function deleted(Group $group) { if ($group->isForceDeleting()) { return; } \App\Jobs\Group\DeleteJob::dispatch($group->id); } /** * Handle the group "updated" event. * * @param \App\Group $group The group * * @return void */ public function updated(Group $group) { \App\Jobs\Group\UpdateJob::dispatch($group->id); } /** * Handle the group "restoring" event. * * @param \App\Group $group The group * * @return void */ public function restoring(Group $group) { // Make sure it's not DELETED/LDAP_READY/SUSPENDED anymore if ($group->isDeleted()) { $group->status ^= Group::STATUS_DELETED; } if ($group->isLdapReady()) { $group->status ^= Group::STATUS_LDAP_READY; } if ($group->isSuspended()) { $group->status ^= Group::STATUS_SUSPENDED; } $group->status |= Group::STATUS_ACTIVE; // Note: $group->save() is invoked between 'restoring' and 'restored' events } /** * Handle the group "restored" event. * * @param \App\Group $group The group * * @return void */ public function restored(Group $group) { \App\Jobs\Group\CreateJob::dispatch($group->id); } } diff --git a/src/app/Observers/GroupSettingObserver.php b/src/app/Observers/GroupSettingObserver.php index 3d73dd8c..da61bac9 100644 --- a/src/app/Observers/GroupSettingObserver.php +++ b/src/app/Observers/GroupSettingObserver.php @@ -1,51 +1,58 @@ key, LDAP::GROUP_SETTINGS)) { - \App\Jobs\Group\UpdateJob::dispatch($groupSetting->group_id); - } + $this->dispatchUpdateJob($groupSetting); } /** * Handle the group setting "updated" event. * * @param \App\GroupSetting $groupSetting Settings object * * @return void */ public function updated(GroupSetting $groupSetting) { - if (in_array($groupSetting->key, LDAP::GROUP_SETTINGS)) { - \App\Jobs\Group\UpdateJob::dispatch($groupSetting->group_id); - } + $this->dispatchUpdateJob($groupSetting); } /** * Handle the group setting "deleted" event. * * @param \App\GroupSetting $groupSetting Settings object * * @return void */ public function deleted(GroupSetting $groupSetting) { - if (in_array($groupSetting->key, LDAP::GROUP_SETTINGS)) { + $this->dispatchUpdateJob($groupSetting); + } + + /** + * Dispatch group update job (if needed). + * + * @param \App\GroupSetting $groupSetting Settings object + */ + private function dispatchUpdateJob(GroupSetting $groupSetting): void + { + if ((\config('app.with_ldap') && in_array($groupSetting->key, \App\Backends\LDAP::GROUP_SETTINGS)) + || in_array($groupSetting->key, \App\Backends\IMAP::GROUP_SETTINGS) + ) { \App\Jobs\Group\UpdateJob::dispatch($groupSetting->group_id); } } } diff --git a/src/app/Observers/ResourceObserver.php b/src/app/Observers/ResourceObserver.php index b46f7d54..4bf48e62 100644 --- a/src/app/Observers/ResourceObserver.php +++ b/src/app/Observers/ResourceObserver.php @@ -1,92 +1,88 @@ status |= Resource::STATUS_NEW | Resource::STATUS_ACTIVE; + $resource->status |= Resource::STATUS_NEW; } /** * Handle the resource "created" event. * * @param \App\Resource $resource The resource * * @return void */ public function created(Resource $resource) { $domainName = explode('@', $resource->email, 2)[1]; $settings = [ 'folder' => "shared/Resources/{$resource->name}@{$domainName}", ]; foreach ($settings as $key => $value) { $settings[$key] = [ 'key' => $key, 'value' => $value, 'resource_id' => $resource->id, ]; } // Note: Don't use setSettings() here to bypass ResourceSetting observers // 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); } /** * Handle the resource "deleted" event. * * @param \App\Resource $resource The resource * * @return void */ public function deleted(Resource $resource) { if ($resource->isForceDeleting()) { return; } \App\Jobs\Resource\DeleteJob::dispatch($resource->id); } /** * Handle the resource "updated" event. * * @param \App\Resource $resource The resource * * @return void */ public function updated(Resource $resource) { \App\Jobs\Resource\UpdateJob::dispatch($resource->id); // Update the folder property if name changed if ($resource->name != $resource->getOriginal('name')) { $domainName = explode('@', $resource->email, 2)[1]; $folder = "shared/Resources/{$resource->name}@{$domainName}"; // Note: This does not invoke ResourceSetting observer events, good. $resource->settings()->where('key', 'folder')->update(['value' => $folder]); } } } diff --git a/src/app/Observers/ResourceSettingObserver.php b/src/app/Observers/ResourceSettingObserver.php index 4af7ca81..c6babe7e 100644 --- a/src/app/Observers/ResourceSettingObserver.php +++ b/src/app/Observers/ResourceSettingObserver.php @@ -1,51 +1,59 @@ key, LDAP::RESOURCE_SETTINGS)) { - \App\Jobs\Resource\UpdateJob::dispatch($resourceSetting->resource_id); - } + $this->dispatchUpdateJob($resourceSetting); } /** * Handle the resource setting "updated" event. * * @param \App\ResourceSetting $resourceSetting Settings object * * @return void */ public function updated(ResourceSetting $resourceSetting) { - if (in_array($resourceSetting->key, LDAP::RESOURCE_SETTINGS)) { - \App\Jobs\Resource\UpdateJob::dispatch($resourceSetting->resource_id); - } + $this->dispatchUpdateJob($resourceSetting); } /** * Handle the resource setting "deleted" event. * * @param \App\ResourceSetting $resourceSetting Settings object * * @return void */ public function deleted(ResourceSetting $resourceSetting) { - if (in_array($resourceSetting->key, LDAP::RESOURCE_SETTINGS)) { - \App\Jobs\Resource\UpdateJob::dispatch($resourceSetting->resource_id); + $this->dispatchUpdateJob($resourceSetting); + } + + /** + * Dispatch resource update job (if needed) + * + * @param \App\ResourceSetting $resourceSetting Settings object + */ + private function dispatchUpdateJob(ResourceSetting $resourceSetting): void + { + if ((\config('app.with_ldap') && in_array($resourceSetting->key, \App\Backends\LDAP::RESOURCE_SETTINGS)) + || in_array($resourceSetting->key, \App\Backends\IMAP::RESOURCE_SETTINGS) + ) { + $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 index 10af85e1..7afb2365 100644 --- a/src/app/Observers/SharedFolderObserver.php +++ b/src/app/Observers/SharedFolderObserver.php @@ -1,96 +1,92 @@ type)) { $folder->type = 'mail'; } - $folder->status |= SharedFolder::STATUS_NEW | SharedFolder::STATUS_ACTIVE; + $folder->status |= SharedFolder::STATUS_NEW; } /** * 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); + // Create the shared folder in the backend (LDAP and IMAP) + \App\Jobs\SharedFolder\CreateJob::dispatch($folder->id); } /** * 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]); } } } diff --git a/src/app/Observers/SharedFolderSettingObserver.php b/src/app/Observers/SharedFolderSettingObserver.php index 7accfb03..ae46128f 100644 --- a/src/app/Observers/SharedFolderSettingObserver.php +++ b/src/app/Observers/SharedFolderSettingObserver.php @@ -1,51 +1,59 @@ key, LDAP::SHARED_FOLDER_SETTINGS)) { - \App\Jobs\SharedFolder\UpdateJob::dispatch($folderSetting->shared_folder_id); - } + $this->dispatchUpdateJob($folderSetting); } /** * 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); - } + $this->dispatchUpdateJob($folderSetting); } /** * 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); + $this->dispatchUpdateJob($folderSetting); + } + + /** + * Dispatch shared folder update job (if needed). + * + * @param \App\SharedFolderSetting $folderSetting Settings object + */ + private function dispatchUpdateJob(SharedFolderSetting $folderSetting): void + { + if ((\config('app.with_ldap') && in_array($folderSetting->key, \App\Backends\LDAP::SHARED_FOLDER_SETTINGS)) + || in_array($folderSetting->key, \App\Backends\IMAP::SHARED_FOLDER_SETTINGS) + ) { + $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 index a91ca44c..247629d2 100644 --- a/src/app/Observers/UserObserver.php +++ b/src/app/Observers/UserObserver.php @@ -1,339 +1,330 @@ 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; } /** * Handle the "created" event. * * Ensures the user has at least one wallet. * * Should ensure some basic settings are available as well. * * @param \App\User $user The user created. * * @return void */ public function created(User $user) { $settings = [ 'country' => \App\Utils::countryForRequest(), 'currency' => \config('app.currency'), /* 'first_name' => '', 'last_name' => '', 'billing_address' => '', 'organization' => '', 'phone' => '', 'external_email' => '', */ ]; foreach ($settings as $key => $value) { $settings[$key] = [ 'key' => $key, 'value' => $value, 'user_id' => $user->id, ]; } // Note: Don't use setSettings() here to bypass UserSetting observers // Note: This is a single multi-insert query $user->settings()->insert(array_values($settings)); $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); } } /** * Handle the "deleted" event. * * @param \App\User $user The user deleted. * * @return void */ public function deleted(User $user) { // Remove the user from existing groups $wallet = $user->wallet(); if ($wallet && $wallet->owner) { $wallet->owner->groups()->each(function ($group) use ($user) { if (in_array($user->email, $group->members)) { $group->members = array_diff($group->members, [$user->email]); $group->save(); } }); } // TODO: Remove Permission records for the user // TODO: Remove file permissions for the user } /** * Handle the "deleting" event. * * @param User $user The user that is being deleted. * * @return void */ public function deleting(User $user) { // Remove owned users/domains/groups/resources/etc self::removeRelatedObjects($user, $user->isForceDeleting()); // TODO: Especially in tests we're doing delete() on a already deleted user. // Should we escape here - for performance reasons? if (!$user->isForceDeleting()) { \App\Jobs\User\DeleteJob::dispatch($user->id); if (\App\Tenant::getConfig($user->tenant_id, 'pgp.enable')) { \App\Jobs\PGP\KeyDeleteJob::dispatch($user->id, $user->email); } // Debit the reseller's wallet with the user negative balance $balance = 0; foreach ($user->wallets as $wallet) { // Note: here we assume all user wallets are using the same currency. // It might get changed in the future $balance += $wallet->balance; } if ($balance < 0 && $user->tenant && ($wallet = $user->tenant->wallet())) { $wallet->debit($balance * -1, "Deleted user {$user->email}"); } } } /** * Handle the user "restoring" event. * * @param \App\User $user The user * * @return void */ public function restoring(User $user) { // Make sure it's not DELETED/LDAP_READY/IMAP_READY/SUSPENDED anymore if ($user->isDeleted()) { $user->status ^= User::STATUS_DELETED; } if ($user->isLdapReady()) { $user->status ^= User::STATUS_LDAP_READY; } if ($user->isImapReady()) { $user->status ^= User::STATUS_IMAP_READY; } if ($user->isSuspended()) { $user->status ^= User::STATUS_SUSPENDED; } $user->status |= User::STATUS_ACTIVE; // Note: $user->save() is invoked between 'restoring' and 'restored' events } /** * Handle the user "restored" event. * * @param \App\User $user The user * * @return void */ public function restored(User $user) { // We need at least the user domain so it can be created in ldap. // FIXME: What if the domain is owned by someone else? $domain = $user->domain(); if ($domain->trashed() && !$domain->isPublic()) { // Note: Domain entitlements will be restored by the DomainObserver $domain->restore(); } // 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); } /** * Handle the "updated" event. * * @param \App\User $user The user that is being updated. * * @return void */ public function updated(User $user) { \App\Jobs\User\UpdateJob::dispatch($user->id); $oldStatus = $user->getOriginal('status'); $newStatus = $user->status; if (($oldStatus & User::STATUS_DEGRADED) !== ($newStatus & User::STATUS_DEGRADED)) { $wallets = []; $isDegraded = $user->isDegraded(); // Charge all entitlements as if they were being deleted, // but don't delete them. Just debit the wallet and update // entitlements' updated_at timestamp. On un-degrade we still // update updated_at, but with no debit (the cost is 0 on a degraded account). foreach ($user->wallets as $wallet) { $wallet->updateEntitlements($isDegraded); // Remember time of the degradation for sending periodic reminders // and reset it on un-degradation $val = $isDegraded ? \Carbon\Carbon::now()->toDateTimeString() : null; $wallet->setSetting('degraded_last_reminder', $val); $wallets[] = $wallet->id; } // (Un-)degrade users by invoking an update job. // LDAP backend will read the wallet owner's degraded status and // set LDAP attributes accordingly. // We do not change their status as their wallets have its own state \App\Entitlement::whereIn('wallet_id', $wallets) ->where('entitleable_id', '!=', $user->id) ->where('entitleable_type', User::class) ->pluck('entitleable_id') ->unique() ->each(function ($user_id) { \App\Jobs\User\UpdateJob::dispatch($user_id); }); } // Save the old password in the password history $oldPassword = $user->getOriginal('password'); if ($oldPassword && $user->password != $oldPassword) { self::saveOldPassword($user, $oldPassword); } } /** * Remove entitleables/transactions related to the user (in user's wallets) * * @param \App\User $user The user * @param bool $force Force-delete mode */ private static function removeRelatedObjects(User $user, $force = false): void { $wallets = $user->wallets->pluck('id')->all(); \App\Entitlement::withTrashed() ->select('entitleable_id', 'entitleable_type') ->distinct() ->whereIn('wallet_id', $wallets) ->get() ->each(function ($entitlement) use ($user, $force) { // Skip the current user (infinite recursion loop) if ($entitlement->entitleable_type == User::class && $entitlement->entitleable_id == $user->id) { return; } if (!$entitlement->entitleable) { return; } // Objects need to be deleted one by one to make sure observers can do the proper cleanup if ($force) { $entitlement->entitleable->forceDelete(); } elseif (!$entitlement->entitleable->trashed()) { $entitlement->entitleable->delete(); } }); if ($force) { // Remove "wallet" transactions, they have no foreign key constraint \App\Transaction::where('object_type', Wallet::class) ->whereIn('object_id', $wallets) ->delete(); } // regardless of force delete, we're always purging whitelists... just in case \App\Policy\RateLimitWhitelist::where( [ 'whitelistable_id' => $user->id, 'whitelistable_type' => User::class ] )->delete(); } /** * Store the old password in user password history. Make sure * we do not store more passwords than we need in the history. * * @param \App\User $user The user * @param string $password The old password */ private static function saveOldPassword(User $user, string $password): void { // Remember the timestamp of the last password change and unset the last warning date $user->setSettings([ 'password_expiration_warning' => null, // Note: We could get this from user_passwords table, but only if the policy // enables storing of old passwords there. 'password_update' => now()->format('Y-m-d H:i:s'), ]); // Note: All this is kinda heavy and complicated because we don't want to store // more old passwords than we need. However, except the complication/performance, // there's one issue with it. E.g. the policy changes from 2 to 4, and we already // removed the old passwords that were excessive before, but not now. // Get the account password policy $policy = new \App\Rules\Password($user->walletOwner()); $rules = $policy->rules(); // Password history disabled? if (empty($rules['last']) || $rules['last']['param'] < 2) { return; } // Store the old password $user->passwords()->create(['password' => $password]); // Remove passwords that we don't need anymore $limit = $rules['last']['param'] - 1; $ids = $user->passwords()->latest()->limit($limit)->pluck('id')->all(); if (count($ids) >= $limit) { $user->passwords()->where('id', '<', $ids[count($ids) - 1])->delete(); } } } diff --git a/src/app/Observers/UserSettingObserver.php b/src/app/Observers/UserSettingObserver.php index bffd01a8..2bb4ff46 100644 --- a/src/app/Observers/UserSettingObserver.php +++ b/src/app/Observers/UserSettingObserver.php @@ -1,51 +1,58 @@ key, LDAP::USER_SETTINGS)) { - \App\Jobs\User\UpdateJob::dispatch($userSetting->user_id); - } + $this->dispatchUpdateJob($userSetting); } /** * Handle the user setting "updated" event. * * @param \App\UserSetting $userSetting Settings object * * @return void */ public function updated(UserSetting $userSetting) { - if (in_array($userSetting->key, LDAP::USER_SETTINGS)) { - \App\Jobs\User\UpdateJob::dispatch($userSetting->user_id); - } + $this->dispatchUpdateJob($userSetting); } /** * Handle the user setting "deleted" event. * * @param \App\UserSetting $userSetting Settings object * * @return void */ public function deleted(UserSetting $userSetting) { - if (in_array($userSetting->key, LDAP::USER_SETTINGS)) { + $this->dispatchUpdateJob($userSetting); + } + + /** + * Dispatch the user update job (if needed). + * + * @param \App\UserSetting $userSetting Settings object + */ + private function dispatchUpdateJob(UserSetting $userSetting): void + { + if ((\config('app.with_ldap') && in_array($userSetting->key, \App\Backends\LDAP::USER_SETTINGS)) + || in_array($userSetting->key, \App\Backends\IMAP::USER_SETTINGS) + ) { \App\Jobs\User\UpdateJob::dispatch($userSetting->user_id); } } } diff --git a/src/app/Traits/EntitleableTrait.php b/src/app/Traits/EntitleableTrait.php index ee7a48e7..373c246f 100644 --- a/src/app/Traits/EntitleableTrait.php +++ b/src/app/Traits/EntitleableTrait.php @@ -1,310 +1,322 @@ skus as $sku) { for ($i = $sku->pivot->qty; $i > 0; $i--) { Entitlement::create([ 'wallet_id' => $wallet->id, 'sku_id' => $sku->id, 'cost' => $sku->pivot->cost(), 'fee' => $sku->pivot->fee(), 'entitleable_id' => $this->id, 'entitleable_type' => self::class ]); } } return $this; } /** * Assign a SKU to an entitleable object. * * @param \App\Sku $sku The sku to assign. * @param int $count Count of entitlements to add * @param ?\App\Wallet $wallet The wallet to use when objects's wallet is unknown * * @return $this * @throws \Exception */ public function assignSku(Sku $sku, int $count = 1, $wallet = null) { if (!$wallet) { $wallet = $this->wallet(); } if (!$wallet) { throw new \Exception("No wallet specified for the new entitlement"); } $exists = $this->entitlements()->where('sku_id', $sku->id)->count(); while ($count > 0) { Entitlement::create([ 'wallet_id' => $wallet->id, 'sku_id' => $sku->id, 'cost' => $exists >= $sku->units_free ? $sku->cost : 0, 'fee' => $exists >= $sku->units_free ? $sku->fee : 0, 'entitleable_id' => $this->id, 'entitleable_type' => self::class ]); $exists++; $count--; } return $this; } /** * Assign the object to a wallet. * * @param \App\Wallet $wallet The wallet * @param ?string $title Optional SKU title * * @return $this * @throws \Exception */ public function assignToWallet(Wallet $wallet, $title = null) { if (empty($this->id)) { throw new \Exception("Object not yet exists"); } if ($this->entitlements()->count()) { throw new \Exception("Object already assigned to a wallet"); } // Find the SKU title, e.g. \App\SharedFolder -> shared-folder // Note: it does not work with User/Domain model (yet) if (!$title) { $title = Str::kebab(\class_basename(self::class)); } $sku = $this->skuByTitle($title); $exists = $wallet->entitlements()->where('sku_id', $sku->id)->count(); Entitlement::create([ 'wallet_id' => $wallet->id, 'sku_id' => $sku->id, 'cost' => $exists >= $sku->units_free ? $sku->cost : 0, 'fee' => $exists >= $sku->units_free ? $sku->fee : 0, 'entitleable_id' => $this->id, 'entitleable_type' => self::class ]); return $this; } /** * Boot function from Laravel. */ protected static function bootEntitleableTrait() { // Soft-delete and force-delete object's entitlements on object's delete static::deleting(function ($model) { $force = $model->isForceDeleting(); $entitlements = $model->entitlements(); if ($force) { $entitlements = $entitlements->withTrashed(); } $list = $entitlements->get() ->map(function ($entitlement) use ($force) { if ($force) { $entitlement->forceDelete(); } else { $entitlement->delete(); } return $entitlement->id; }) ->all(); // Remove transactions, they have no foreign key constraint if ($force && !empty($list)) { \App\Transaction::where('object_type', \App\Entitlement::class) ->whereIn('object_id', $list) ->delete(); } }); // Restore object's entitlements on restore static::restored(function ($model) { $model->restoreEntitlements(); }); } + /** + * 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. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function entitlements() { return $this->hasMany(Entitlement::class, 'entitleable_id', 'id') ->where('entitleable_type', self::class); } /** * Check if an entitlement for the specified SKU exists. * * @param string $title The SKU title * * @return bool True if specified SKU entitlement exists */ 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; } /** * Remove a number of entitlements for the SKU. * * @param \App\Sku $sku The SKU * @param int $count The number of entitlements to remove * * @return $this */ public function removeSku(Sku $sku, int $count = 1) { $entitlements = $this->entitlements() ->where('sku_id', $sku->id) ->orderBy('cost', 'desc') ->orderBy('created_at') ->get(); $entitlements_count = count($entitlements); foreach ($entitlements as $entitlement) { if ($entitlements_count <= $sku->units_free) { continue; } if ($count > 0) { $entitlement->delete(); $entitlements_count--; $count--; } } return $this; } /** * Restore object entitlements. */ public function restoreEntitlements(): void { // We'll restore only these that were deleted last. So, first we get // the maximum deleted_at timestamp and then use it to select // entitlements for restore $deleted_at = $this->entitlements()->withTrashed()->max('deleted_at'); if ($deleted_at) { $threshold = (new \Carbon\Carbon($deleted_at))->subMinute(); // Restore object entitlements $this->entitlements()->withTrashed() ->where('deleted_at', '>=', $threshold) ->update(['updated_at' => now(), 'deleted_at' => null]); // Note: We're assuming that cost of entitlements was correct // on deletion, so we don't have to re-calculate it again. // TODO: We should probably re-calculate the cost } } /** * Find the SKU object by title. Use current object's tenant context. * * @param string $title SKU title. * * @return ?\App\Sku A SKU object */ protected function skuByTitle(string $title): ?Sku { return Sku::withObjectTenantContext($this)->where('title', $title)->first(); } /** * Returns entitleable object title (e.g. email or domain name). * * @return string|null An object title/name */ public function toString(): ?string { // This method should be overloaded by the model class // if the object has not email attribute return $this->email; } /** * Returns the wallet by which the object is controlled * * @return ?\App\Wallet A wallet object */ public function wallet(): ?Wallet { $entitlement = $this->entitlements()->withTrashed()->orderBy('created_at', 'desc')->first(); if ($entitlement) { return $entitlement->wallet; } // TODO: No entitlement should not happen, but in tests we have // such cases, so we fallback to the user's wallet in this case if ($this instanceof \App\User) { return $this->wallets()->first(); } return null; } /** * Return the owner of the wallet (account) this entitleable is assigned to * * @return ?\App\User Account owner */ public function walletOwner(): ?\App\User { $wallet = $this->wallet(); if ($wallet) { if ($this instanceof \App\User && $wallet->user_id == $this->id) { return $this; } return $wallet->owner; } return null; } } diff --git a/src/config/app.php b/src/config/app.php index 054f2b5c..773fabb9 100644 --- a/src/config/app.php +++ b/src/config/app.php @@ -1,269 +1,274 @@ env('APP_NAME', 'Laravel'), /* |-------------------------------------------------------------------------- | Application Environment |-------------------------------------------------------------------------- | | This value determines the "environment" your application is currently | running in. This may determine how you prefer to configure various | services the application utilizes. Set this in your ".env" file. | */ 'env' => env('APP_ENV', 'production'), /* |-------------------------------------------------------------------------- | Application Debug Mode |-------------------------------------------------------------------------- | | When your application is in debug mode, detailed error messages with | stack traces will be shown on every error that occurs within your | application. If disabled, a simple generic error page is shown. | */ 'debug' => env('APP_DEBUG', false), /* |-------------------------------------------------------------------------- | Application URL |-------------------------------------------------------------------------- | | This URL is used by the console to properly generate URLs when using | the Artisan command line tool. You should set this to the root of | your application so that it is used when running Artisan tasks. */ 'url' => env('APP_URL', 'http://localhost'), 'passphrase' => env('APP_PASSPHRASE', null), 'public_url' => env('APP_PUBLIC_URL', env('APP_URL', 'http://localhost')), 'asset_url' => env('ASSET_URL'), 'support_url' => env('SUPPORT_URL', null), 'support_email' => env('SUPPORT_EMAIL', null), 'webmail_url' => env('WEBMAIL_URL', null), 'theme' => env('APP_THEME', 'default'), 'tenant_id' => env('APP_TENANT_ID', null), 'currency' => \strtoupper(env('APP_CURRENCY', 'CHF')), + 'backends' => env('BACKENDS', 'imap,ldap'), + /* |-------------------------------------------------------------------------- | Application Domain |-------------------------------------------------------------------------- | | System domain used for user signup (kolab identity) */ 'domain' => env('APP_DOMAIN', 'domain.tld'), 'website_domain' => env('APP_WEBSITE_DOMAIN', env('APP_DOMAIN', 'domain.tld')), /* |-------------------------------------------------------------------------- | Application Timezone |-------------------------------------------------------------------------- | | Here you may specify the default timezone for your application, which | will be used by the PHP date and date-time functions. We have gone | ahead and set this to a sensible default for you out of the box. | */ 'timezone' => 'UTC', /* |-------------------------------------------------------------------------- | Application Locale Configuration |-------------------------------------------------------------------------- | | The application locale determines the default locale that will be used | by the translation service provider. You are free to set this value | to any of the locales which will be supported by the application. | */ 'locale' => env('APP_LOCALE', 'en'), /* |-------------------------------------------------------------------------- | Application Fallback Locale |-------------------------------------------------------------------------- | | The fallback locale determines the locale to use when the current one | is not available. You may change the value to correspond to any of | the language folders that are provided through your application. | */ 'fallback_locale' => 'en', /* |-------------------------------------------------------------------------- | Faker Locale |-------------------------------------------------------------------------- | | This locale will be used by the Faker PHP library when generating fake | data for your database seeds. For example, this will be used to get | localized telephone numbers, street address information and more. | */ 'faker_locale' => 'en_US', /* |-------------------------------------------------------------------------- | Encryption Key |-------------------------------------------------------------------------- | | This key is used by the Illuminate encrypter service and should be set | to a random, 32 character string, otherwise these encrypted strings | will not be safe. Please do this before deploying an application! | */ 'key' => env('APP_KEY'), 'cipher' => 'AES-256-CBC', /* |-------------------------------------------------------------------------- | Autoloaded Service Providers |-------------------------------------------------------------------------- | | The service providers listed here will be automatically loaded on the | request to your application. Feel free to add your own services to | this array to grant expanded functionality to your applications. | */ 'providers' => [ /* * Laravel Framework Service Providers... */ Illuminate\Auth\AuthServiceProvider::class, Illuminate\Broadcasting\BroadcastServiceProvider::class, Illuminate\Bus\BusServiceProvider::class, Illuminate\Cache\CacheServiceProvider::class, Illuminate\Foundation\Providers\ConsoleSupportServiceProvider::class, Illuminate\Cookie\CookieServiceProvider::class, Illuminate\Database\DatabaseServiceProvider::class, Illuminate\Encryption\EncryptionServiceProvider::class, Illuminate\Filesystem\FilesystemServiceProvider::class, Illuminate\Foundation\Providers\FoundationServiceProvider::class, Illuminate\Hashing\HashServiceProvider::class, Illuminate\Mail\MailServiceProvider::class, Illuminate\Notifications\NotificationServiceProvider::class, Illuminate\Pagination\PaginationServiceProvider::class, Illuminate\Pipeline\PipelineServiceProvider::class, Illuminate\Queue\QueueServiceProvider::class, Illuminate\Redis\RedisServiceProvider::class, Illuminate\Auth\Passwords\PasswordResetServiceProvider::class, Illuminate\Session\SessionServiceProvider::class, Illuminate\Translation\TranslationServiceProvider::class, Illuminate\Validation\ValidationServiceProvider::class, Illuminate\View\ViewServiceProvider::class, /* * Application Service Providers... */ App\Providers\AppServiceProvider::class, App\Providers\AuthServiceProvider::class, // App\Providers\BroadcastServiceProvider::class, App\Providers\EventServiceProvider::class, App\Providers\HorizonServiceProvider::class, App\Providers\PassportServiceProvider::class, App\Providers\RouteServiceProvider::class, ], /* |-------------------------------------------------------------------------- | Class Aliases |-------------------------------------------------------------------------- | | This array of class aliases will be registered when this application | is started. However, feel free to register as many as you wish as | the aliases are "lazy" loaded so they don't hinder performance. | */ 'aliases' => \Illuminate\Support\Facades\Facade::defaultAliases()->toArray(), 'headers' => [ 'csp' => env('APP_HEADER_CSP', ""), 'xfo' => env('APP_HEADER_XFO', ""), ], // Locations of knowledge base articles 'kb' => [ // An article about suspended accounts 'account_suspended' => env('KB_ACCOUNT_SUSPENDED'), // An article about a way to delete an owned account 'account_delete' => env('KB_ACCOUNT_DELETE'), // An article about the payment system 'payment_system' => env('KB_PAYMENT_SYSTEM'), ], 'company' => [ 'name' => env('COMPANY_NAME'), 'address' => env('COMPANY_ADDRESS'), 'details' => env('COMPANY_DETAILS'), 'email' => env('COMPANY_EMAIL'), 'logo' => env('COMPANY_LOGO'), 'footer' => env('COMPANY_FOOTER', env('COMPANY_DETAILS')), ], 'storage' => [ 'min_qty' => (int) env('STORAGE_MIN_QTY', 5), // in GB ], 'vat' => [ 'countries' => env('VAT_COUNTRIES'), 'rate' => (float) env('VAT_RATE'), ], 'password_policy' => env('PASSWORD_POLICY') ?: 'min:6,max:255', 'payment' => [ 'methods_oneoff' => env('PAYMENT_METHODS_ONEOFF', 'creditcard,paypal,banktransfer,bitcoin'), 'methods_recurring' => env('PAYMENT_METHODS_RECURRING', 'creditcard'), ], + + 'with_ldap' => (bool) env('APP_LDAP', true), + 'with_admin' => (bool) env('APP_WITH_ADMIN', false), 'with_files' => (bool) env('APP_WITH_FILES', false), 'with_reseller' => (bool) env('APP_WITH_RESELLER', false), 'with_services' => (bool) env('APP_WITH_SERVICES', false), 'signup' => [ 'email_limit' => (int) env('SIGNUP_LIMIT_EMAIL', 0), 'ip_limit' => (int) env('SIGNUP_LIMIT_IP', 0), ], 'woat_ns1' => env('WOAT_NS1', 'ns01.' . env('APP_DOMAIN')), 'woat_ns2' => env('WOAT_NS2', 'ns02.' . env('APP_DOMAIN')), 'ratelimit_whitelist' => explode(',', env('RATELIMIT_WHITELIST', '')) ]; diff --git a/src/include/rcube_imap_generic.php b/src/include/rcube_imap_generic.php index 127c4cfa..adb0b7b8 100644 --- a/src/include/rcube_imap_generic.php +++ b/src/include/rcube_imap_generic.php @@ -1,4106 +1,4128 @@ | | Author: Ryo Chijiiwa | +-----------------------------------------------------------------------+ */ /** * PHP based wrapper class to connect to an IMAP server * * @package Framework * @subpackage Storage */ class rcube_imap_generic { public $error; public $errornum; public $result; public $resultcode; public $selected; public $data = array(); public $flags = array( 'SEEN' => '\\Seen', 'DELETED' => '\\Deleted', 'ANSWERED' => '\\Answered', 'DRAFT' => '\\Draft', 'FLAGGED' => '\\Flagged', 'FORWARDED' => '$Forwarded', 'MDNSENT' => '$MDNSent', '*' => '\\*', ); protected $fp; protected $host; protected $cmd_tag; protected $cmd_num = 0; protected $resourceid; protected $prefs = array(); protected $logged = false; protected $capability = array(); protected $capability_readed = false; protected $debug = false; protected $debug_handler = false; const ERROR_OK = 0; const ERROR_NO = -1; const ERROR_BAD = -2; const ERROR_BYE = -3; const ERROR_UNKNOWN = -4; const ERROR_COMMAND = -5; const ERROR_READONLY = -6; const COMMAND_NORESPONSE = 1; const COMMAND_CAPABILITY = 2; const COMMAND_LASTLINE = 4; const COMMAND_ANONYMIZED = 8; const DEBUG_LINE_LENGTH = 4098; // 4KB + 2B for \r\n /** * Send simple (one line) command to the connection stream * * @param string $string Command string * @param bool $endln True if CRLF need to be added at the end of command * @param bool $anonymized Don't write the given data to log but a placeholder * * @param int Number of bytes sent, False on error */ protected function putLine($string, $endln = true, $anonymized = false) { if (!$this->fp) { return false; } if ($this->debug) { // anonymize the sent command for logging $cut = $endln ? 2 : 0; if ($anonymized && preg_match('/^(A\d+ (?:[A-Z]+ )+)(.+)/', $string, $m)) { $log = $m[1] . sprintf('****** [%d]', strlen($m[2]) - $cut); } else if ($anonymized) { $log = sprintf('****** [%d]', strlen($string) - $cut); } else { $log = rtrim($string); } $this->debug('C: ' . $log); } if ($endln) { $string .= "\r\n"; } $res = fwrite($this->fp, $string); if ($res === false) { $this->closeSocket(); } return $res; } /** * Send command to the connection stream with Command Continuation * Requests (RFC3501 7.5) and LITERAL+ (RFC2088) support * * @param string $string Command string * @param bool $endln True if CRLF need to be added at the end of command * @param bool $anonymized Don't write the given data to log but a placeholder * * @return int|bool Number of bytes sent, False on error */ protected function putLineC($string, $endln=true, $anonymized=false) { if (!$this->fp) { return false; } if ($endln) { $string .= "\r\n"; } $res = 0; if ($parts = preg_split('/(\{[0-9]+\}\r\n)/m', $string, -1, PREG_SPLIT_DELIM_CAPTURE)) { for ($i=0, $cnt=count($parts); $i<$cnt; $i++) { if ($i+1 < $cnt && preg_match('/^\{([0-9]+)\}\r\n$/', $parts[$i+1], $matches)) { // LITERAL+ support if ($this->prefs['literal+']) { $parts[$i+1] = sprintf("{%d+}\r\n", $matches[1]); } $bytes = $this->putLine($parts[$i].$parts[$i+1], false, $anonymized); if ($bytes === false) { return false; } $res += $bytes; // don't wait if server supports LITERAL+ capability if (!$this->prefs['literal+']) { $line = $this->readLine(1000); // handle error in command if ($line[0] != '+') { return false; } } $i++; } else { $bytes = $this->putLine($parts[$i], false, $anonymized); if ($bytes === false) { return false; } $res += $bytes; } } } return $res; } /** * Reads line from the connection stream * * @param int $size Buffer size * * @return string Line of text response */ protected function readLine($size = 1024) { $line = ''; if (!$size) { $size = 1024; } do { if ($this->eof()) { return $line ?: null; } $buffer = fgets($this->fp, $size); if ($buffer === false) { $this->closeSocket(); break; } if ($this->debug) { $this->debug('S: '. rtrim($buffer)); } $line .= $buffer; } while (substr($buffer, -1) != "\n"); return $line; } /** * Reads more data from the connection stream when provided * data contain string literal * * @param string $line Response text * @param bool $escape Enables escaping * * @return string Line of text response */ protected function multLine($line, $escape = false) { $line = rtrim($line); if (preg_match('/\{([0-9]+)\}$/', $line, $m)) { $out = ''; $str = substr($line, 0, -strlen($m[0])); $bytes = $m[1]; while (strlen($out) < $bytes) { $line = $this->readBytes($bytes); if ($line === null) { break; } $out .= $line; } $line = $str . ($escape ? $this->escape($out) : $out); } return $line; } /** * Reads specified number of bytes from the connection stream * * @param int $bytes Number of bytes to get * * @return string Response text */ protected function readBytes($bytes) { $data = ''; $len = 0; while ($len < $bytes && !$this->eof()) { $d = fread($this->fp, $bytes-$len); if ($this->debug) { $this->debug('S: '. $d); } $data .= $d; $data_len = strlen($data); if ($len == $data_len) { break; // nothing was read -> exit to avoid apache lockups } $len = $data_len; } return $data; } /** * Reads complete response to the IMAP command * * @param array $untagged Will be filled with untagged response lines * * @return string Response text */ protected function readReply(&$untagged = null) { do { $line = trim($this->readLine(1024)); // store untagged response lines if ($line[0] == '*') { $untagged[] = $line; } } while ($line[0] == '*'); if ($untagged) { $untagged = implode("\n", $untagged); } return $line; } /** * Response parser. * * @param string $string Response text * @param string $err_prefix Error message prefix * * @return int Response status */ protected function parseResult($string, $err_prefix = '') { if (preg_match('/^[a-z0-9*]+ (OK|NO|BAD|BYE)(.*)$/i', trim($string), $matches)) { $res = strtoupper($matches[1]); $str = trim($matches[2]); if ($res == 'OK') { $this->errornum = self::ERROR_OK; } else if ($res == 'NO') { $this->errornum = self::ERROR_NO; } else if ($res == 'BAD') { $this->errornum = self::ERROR_BAD; } else if ($res == 'BYE') { $this->closeSocket(); $this->errornum = self::ERROR_BYE; } if ($str) { $str = trim($str); // get response string and code (RFC5530) if (preg_match("/^\[([a-z-]+)\]/i", $str, $m)) { $this->resultcode = strtoupper($m[1]); $str = trim(substr($str, strlen($m[1]) + 2)); } else { $this->resultcode = null; // parse response for [APPENDUID 1204196876 3456] if (preg_match("/^\[APPENDUID [0-9]+ ([0-9]+)\]/i", $str, $m)) { $this->data['APPENDUID'] = $m[1]; } // parse response for [COPYUID 1204196876 3456:3457 123:124] else if (preg_match("/^\[COPYUID [0-9]+ ([0-9,:]+) ([0-9,:]+)\]/i", $str, $m)) { $this->data['COPYUID'] = array($m[1], $m[2]); } } $this->result = $str; if ($this->errornum != self::ERROR_OK) { $this->error = $err_prefix ? $err_prefix.$str : $str; } } return $this->errornum; } return self::ERROR_UNKNOWN; } /** * Checks connection stream state. * * @return bool True if connection is closed */ protected function eof() { if (!is_resource($this->fp)) { return true; } // If a connection opened by fsockopen() wasn't closed // by the server, feof() will hang. $start = microtime(true); if (feof($this->fp) || ($this->prefs['timeout'] && (microtime(true) - $start > $this->prefs['timeout'])) ) { $this->closeSocket(); return true; } return false; } /** * Closes connection stream. */ protected function closeSocket() { @fclose($this->fp); $this->fp = null; } /** * Error code/message setter. */ protected function setError($code, $msg = '') { $this->errornum = $code; $this->error = $msg; return $code; } /** * Checks response status. * Checks if command response line starts with specified prefix (or * BYE/BAD) * * @param string $string Response text * @param string $match Prefix to match with (case-sensitive) * @param bool $error Enables BYE/BAD checking * @param bool $nonempty Enables empty response checking * * @return bool True any check is true or connection is closed. */ protected function startsWith($string, $match, $error = false, $nonempty = false) { if (!$this->fp) { return true; } if (strncmp($string, $match, strlen($match)) == 0) { return true; } if ($error && preg_match('/^\* (BYE|BAD) /i', $string, $m)) { if (strtoupper($m[1]) == 'BYE') { $this->closeSocket(); } return true; } if ($nonempty && !strlen($string)) { return true; } return false; } /** * Capabilities checker */ protected function hasCapability($name) { if (empty($this->capability) || $name == '') { return false; } if (in_array($name, $this->capability)) { return true; } else if (strpos($name, '=')) { return false; } $result = array(); foreach ($this->capability as $cap) { $entry = explode('=', $cap); if ($entry[0] == $name) { $result[] = $entry[1]; } } return $result ?: false; } /** * Capabilities checker * * @param string $name Capability name * * @return mixed Capability values array for key=value pairs, true/false for others */ public function getCapability($name) { $result = $this->hasCapability($name); if (!empty($result)) { return $result; } else if ($this->capability_readed) { return false; } // get capabilities (only once) because initial // optional CAPABILITY response may differ $result = $this->execute('CAPABILITY'); if ($result[0] == self::ERROR_OK) { $this->parseCapability($result[1]); } $this->capability_readed = true; return $this->hasCapability($name); } /** * Clears detected server capabilities */ public function clearCapability() { $this->capability = array(); $this->capability_readed = false; } /** * DIGEST-MD5/CRAM-MD5/PLAIN Authentication * * @param string $user Username * @param string $pass Password * @param string $type Authentication type (PLAIN/CRAM-MD5/DIGEST-MD5) * * @return resource Connection resourse on success, error code on error */ protected function authenticate($user, $pass, $type = 'PLAIN') { if ($type == 'CRAM-MD5' || $type == 'DIGEST-MD5') { if ($type == 'DIGEST-MD5' && !class_exists('Auth_SASL')) { return $this->setError(self::ERROR_BYE, "The Auth_SASL package is required for DIGEST-MD5 authentication"); } $this->putLine($this->nextTag() . " AUTHENTICATE $type"); $line = trim($this->readReply()); if ($line[0] == '+') { $challenge = substr($line, 2); } else { return $this->parseResult($line); } if ($type == 'CRAM-MD5') { // RFC2195: CRAM-MD5 $ipad = ''; $opad = ''; $xor = function($str1, $str2) { $result = ''; $size = strlen($str1); for ($i=0; $i<$size; $i++) { $result .= chr(ord($str1[$i]) ^ ord($str2[$i])); } return $result; }; // initialize ipad, opad for ($i=0; $i<64; $i++) { $ipad .= chr(0x36); $opad .= chr(0x5C); } // pad $pass so it's 64 bytes $pass = str_pad($pass, 64, chr(0)); // generate hash $hash = md5($xor($pass, $opad) . pack("H*", md5($xor($pass, $ipad) . base64_decode($challenge)))); $reply = base64_encode($user . ' ' . $hash); // send result $this->putLine($reply, true, true); } else { // RFC2831: DIGEST-MD5 // proxy authorization if (!empty($this->prefs['auth_cid'])) { $authc = $this->prefs['auth_cid']; $pass = $this->prefs['auth_pw']; } else { $authc = $user; $user = ''; } $auth_sasl = new Auth_SASL; $auth_sasl = $auth_sasl->factory('digestmd5'); $reply = base64_encode($auth_sasl->getResponse($authc, $pass, base64_decode($challenge), $this->host, 'imap', $user)); // send result $this->putLine($reply, true, true); $line = trim($this->readReply()); if ($line[0] != '+') { return $this->parseResult($line); } // check response $challenge = substr($line, 2); $challenge = base64_decode($challenge); if (strpos($challenge, 'rspauth=') === false) { return $this->setError(self::ERROR_BAD, "Unexpected response from server to DIGEST-MD5 response"); } $this->putLine(''); } $line = $this->readReply(); $result = $this->parseResult($line); } else if ($type == 'GSSAPI') { if (!extension_loaded('krb5')) { return $this->setError(self::ERROR_BYE, "The krb5 extension is required for GSSAPI authentication"); } if (empty($this->prefs['gssapi_cn'])) { return $this->setError(self::ERROR_BYE, "The gssapi_cn parameter is required for GSSAPI authentication"); } if (empty($this->prefs['gssapi_context'])) { return $this->setError(self::ERROR_BYE, "The gssapi_context parameter is required for GSSAPI authentication"); } putenv('KRB5CCNAME=' . $this->prefs['gssapi_cn']); try { $ccache = new KRB5CCache(); $ccache->open($this->prefs['gssapi_cn']); $gssapicontext = new GSSAPIContext(); $gssapicontext->acquireCredentials($ccache); $token = ''; $success = $gssapicontext->initSecContext($this->prefs['gssapi_context'], null, null, null, $token); $token = base64_encode($token); } catch (Exception $e) { trigger_error($e->getMessage(), E_USER_WARNING); return $this->setError(self::ERROR_BYE, "GSSAPI authentication failed"); } $this->putLine($this->nextTag() . " AUTHENTICATE GSSAPI " . $token); $line = trim($this->readReply()); if ($line[0] != '+') { return $this->parseResult($line); } try { $itoken = base64_decode(substr($line, 2)); if (!$gssapicontext->unwrap($itoken, $itoken)) { throw new Exception("GSSAPI SASL input token unwrap failed"); } if (strlen($itoken) < 4) { throw new Exception("GSSAPI SASL input token invalid"); } // Integrity/encryption layers are not supported. The first bit // indicates that the server supports "no security layers". // 0x00 should not occur, but support broken implementations. $server_layers = ord($itoken[0]); if ($server_layers && ($server_layers & 0x1) != 0x1) { throw new Exception("Server requires GSSAPI SASL integrity/encryption"); } // Construct output token. 0x01 in the first octet = SASL layer "none", // zero in the following three octets = no data follows. // See https://github.com/cyrusimap/cyrus-sasl/blob/e41cfb986c1b1935770de554872247453fdbb079/plugins/gssapi.c#L1284 if (!$gssapicontext->wrap(pack("CCCC", 0x1, 0, 0, 0), $otoken, true)) { throw new Exception("GSSAPI SASL output token wrap failed"); } } catch (Exception $e) { trigger_error($e->getMessage(), E_USER_WARNING); return $this->setError(self::ERROR_BYE, "GSSAPI authentication failed"); } $this->putLine(base64_encode($otoken)); $line = $this->readReply(); $result = $this->parseResult($line); } else if ($type == 'PLAIN') { // proxy authorization if (!empty($this->prefs['auth_cid'])) { $authc = $this->prefs['auth_cid']; $pass = $this->prefs['auth_pw']; } else { $authc = $user; $user = ''; } $reply = base64_encode($user . chr(0) . $authc . chr(0) . $pass); // RFC 4959 (SASL-IR): save one round trip if ($this->getCapability('SASL-IR')) { list($result, $line) = $this->execute("AUTHENTICATE PLAIN", array($reply), self::COMMAND_LASTLINE | self::COMMAND_CAPABILITY | self::COMMAND_ANONYMIZED); } else { $this->putLine($this->nextTag() . " AUTHENTICATE PLAIN"); $line = trim($this->readReply()); if ($line[0] != '+') { return $this->parseResult($line); } // send result, get reply and process it $this->putLine($reply, true, true); $line = $this->readReply(); $result = $this->parseResult($line); } } else if ($type == 'LOGIN') { $this->putLine($this->nextTag() . " AUTHENTICATE LOGIN"); $line = trim($this->readReply()); if ($line[0] != '+') { return $this->parseResult($line); } $this->putLine(base64_encode($user), true, true); $line = trim($this->readReply()); if ($line[0] != '+') { return $this->parseResult($line); } // send result, get reply and process it $this->putLine(base64_encode($pass), true, true); $line = $this->readReply(); $result = $this->parseResult($line); } if ($result === self::ERROR_OK) { // optional CAPABILITY response if ($line && preg_match('/\[CAPABILITY ([^]]+)\]/i', $line, $matches)) { $this->parseCapability($matches[1], true); } return $this->fp; } return $this->setError($result, "AUTHENTICATE $type: $line"); } /** * LOGIN Authentication * * @param string $user Username * @param string $pass Password * * @return resource Connection resourse on success, error code on error */ protected function login($user, $password) { // Prevent from sending credentials in plain text when connection is not secure if ($this->getCapability('LOGINDISABLED')) { return $this->setError(self::ERROR_BAD, "Login disabled by IMAP server"); } list($code, $response) = $this->execute('LOGIN', array( $this->escape($user), $this->escape($password)), self::COMMAND_CAPABILITY | self::COMMAND_ANONYMIZED); // re-set capabilities list if untagged CAPABILITY response provided if (preg_match('/\* CAPABILITY (.+)/i', $response, $matches)) { $this->parseCapability($matches[1], true); } if ($code == self::ERROR_OK) { return $this->fp; } return $code; } /** * Detects hierarchy delimiter * * @return string The delimiter */ public function getHierarchyDelimiter() { if (isset($this->prefs['delimiter'])) { return $this->prefs['delimiter']; } // try (LIST "" ""), should return delimiter (RFC2060 Sec 6.3.8) list($code, $response) = $this->execute('LIST', array($this->escape(''), $this->escape(''))); if ($code == self::ERROR_OK) { $args = $this->tokenizeResponse($response, 4); $delimiter = $args[3]; if (strlen($delimiter) > 0) { return ($this->prefs['delimiter'] = $delimiter); } } } /** * NAMESPACE handler (RFC 2342) * * @return array Namespace data hash (personal, other, shared) */ public function getNamespace() { if (array_key_exists('namespace', $this->prefs)) { return $this->prefs['namespace']; } if (!$this->getCapability('NAMESPACE')) { return self::ERROR_BAD; } list($code, $response) = $this->execute('NAMESPACE'); if ($code == self::ERROR_OK && preg_match('/^\* NAMESPACE /', $response)) { $response = substr($response, 11); $data = $this->tokenizeResponse($response); } if (!is_array($data)) { return $code; } $this->prefs['namespace'] = array( 'personal' => $data[0], 'other' => $data[1], 'shared' => $data[2], ); return $this->prefs['namespace']; } /** * Connects to IMAP server and authenticates. * * @param string $host Server hostname or IP * @param string $user User name * @param string $password Password * @param array $options Connection and class options * * @return bool True on success, False on failure */ public function connect($host, $user, $password, $options = array()) { // configure $this->set_prefs($options); $this->host = $host; $this->user = $user; $this->logged = false; $this->selected = null; // check input if (empty($host)) { $this->setError(self::ERROR_BAD, "Empty host"); return false; } if (empty($user)) { $this->setError(self::ERROR_NO, "Empty user"); return false; } if (empty($password) && empty($options['gssapi_cn'])) { $this->setError(self::ERROR_NO, "Empty password"); return false; } // Connect if (!$this->_connect($host)) { return false; } // Send ID info if (!empty($this->prefs['ident']) && $this->getCapability('ID')) { $this->data['ID'] = $this->id($this->prefs['ident']); } $auth_method = $this->prefs['auth_type']; $auth_methods = array(); $result = null; // check for supported auth methods if (!$auth_method || $auth_method == 'CHECK') { if ($auth_caps = $this->getCapability('AUTH')) { $auth_methods = $auth_caps; } // Use best (for security) supported authentication method $all_methods = array('DIGEST-MD5', 'CRAM-MD5', 'CRAM_MD5', 'PLAIN', 'LOGIN'); if (!empty($this->prefs['gssapi_cn'])) { array_unshift($all_methods, 'GSSAPI'); } foreach ($all_methods as $auth_method) { if (in_array($auth_method, $auth_methods)) { break; } } // Prefer LOGIN over AUTHENTICATE LOGIN for performance reasons if ($auth_method == 'LOGIN' && !$this->getCapability('LOGINDISABLED')) { $auth_method = 'IMAP'; } } // pre-login capabilities can be not complete $this->capability_readed = false; // Authenticate switch ($auth_method) { case 'CRAM_MD5': $auth_method = 'CRAM-MD5'; case 'CRAM-MD5': case 'DIGEST-MD5': case 'GSSAPI': case 'PLAIN': case 'LOGIN': $result = $this->authenticate($user, $password, $auth_method); break; case 'IMAP': $result = $this->login($user, $password); break; default: $this->setError(self::ERROR_BAD, "Configuration error. Unknown auth method: $auth_method"); } // Connected and authenticated if (is_resource($result)) { if (!empty($this->prefs['force_caps'])) { $this->clearCapability(); } $this->logged = true; return true; } $this->closeConnection(); return false; } /** * Connects to IMAP server. * * @param string $host Server hostname or IP * * @return bool True on success, False on failure */ protected function _connect($host) { // initialize connection $this->error = ''; $this->errornum = self::ERROR_OK; if (!$this->prefs['port']) { $this->prefs['port'] = 143; } // check for SSL if (!empty($this->prefs['ssl_mode']) && $this->prefs['ssl_mode'] != 'tls') { $host = $this->prefs['ssl_mode'] . '://' . $host; } if (empty($this->prefs['timeout']) || $this->prefs['timeout'] < 0) { $this->prefs['timeout'] = max(0, intval(ini_get('default_socket_timeout'))); } if ($this->debug) { // set connection identifier for debug output $this->resourceid = strtoupper(substr(md5(microtime() . $host . $this->user), 0, 4)); $_host = ($this->prefs['ssl_mode'] == 'tls' ? 'tls://' : '') . $host . ':' . $this->prefs['port']; $this->debug("Connecting to $_host..."); } if (!empty($this->prefs['socket_options'])) { $context = stream_context_create($this->prefs['socket_options']); $this->fp = stream_socket_client($host . ':' . $this->prefs['port'], $errno, $errstr, $this->prefs['timeout'], STREAM_CLIENT_CONNECT, $context); } else { $this->fp = @fsockopen($host, $this->prefs['port'], $errno, $errstr, $this->prefs['timeout']); } if (!$this->fp) { $this->setError(self::ERROR_BAD, sprintf("Could not connect to %s:%d: %s", $host, $this->prefs['port'], $errstr ?: "Unknown reason")); return false; } if ($this->prefs['timeout'] > 0) { stream_set_timeout($this->fp, $this->prefs['timeout']); } $line = trim(fgets($this->fp, 8192)); if ($this->debug && $line) { $this->debug('S: '. $line); } // Connected to wrong port or connection error? if (!preg_match('/^\* (OK|PREAUTH)/i', $line)) { if ($line) $error = sprintf("Wrong startup greeting (%s:%d): %s", $host, $this->prefs['port'], $line); else $error = sprintf("Empty startup greeting (%s:%d)", $host, $this->prefs['port']); $this->setError(self::ERROR_BAD, $error); $this->closeConnection(); return false; } $this->data['GREETING'] = trim(preg_replace('/\[[^\]]+\]\s*/', '', $line)); // RFC3501 [7.1] optional CAPABILITY response if (preg_match('/\[CAPABILITY ([^]]+)\]/i', $line, $matches)) { $this->parseCapability($matches[1], true); } // TLS connection if ($this->prefs['ssl_mode'] == 'tls' && $this->getCapability('STARTTLS')) { $res = $this->execute('STARTTLS'); if ($res[0] != self::ERROR_OK) { $this->closeConnection(); return false; } if (isset($this->prefs['socket_options']['ssl']['crypto_method'])) { $crypto_method = $this->prefs['socket_options']['ssl']['crypto_method']; } else { // There is no flag to enable all TLS methods. Net_SMTP // handles enabling TLS similarly. $crypto_method = STREAM_CRYPTO_METHOD_TLS_CLIENT | @STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT | @STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT; } if (!stream_socket_enable_crypto($this->fp, true, $crypto_method)) { $this->setError(self::ERROR_BAD, "Unable to negotiate TLS"); $this->closeConnection(); return false; } // Now we're secure, capabilities need to be reread $this->clearCapability(); } return true; } /** * Initializes environment */ protected function set_prefs($prefs) { // set preferences if (is_array($prefs)) { $this->prefs = $prefs; } // set auth method if (!empty($this->prefs['auth_type'])) { $this->prefs['auth_type'] = strtoupper($this->prefs['auth_type']); } else { $this->prefs['auth_type'] = 'CHECK'; } // disabled capabilities if (!empty($this->prefs['disabled_caps'])) { $this->prefs['disabled_caps'] = array_map('strtoupper', (array)$this->prefs['disabled_caps']); } // additional message flags if (!empty($this->prefs['message_flags'])) { $this->flags = array_merge($this->flags, $this->prefs['message_flags']); unset($this->prefs['message_flags']); } } /** * Checks connection status * * @return bool True if connection is active and user is logged in, False otherwise. */ public function connected() { return $this->fp && $this->logged; } /** * Closes connection with logout. */ public function closeConnection() { if ($this->logged && $this->putLine($this->nextTag() . ' LOGOUT')) { $this->readReply(); } $this->closeSocket(); } /** * Executes SELECT command (if mailbox is already not in selected state) * * @param string $mailbox Mailbox name * @param array $qresync_data QRESYNC data (RFC5162) * * @return boolean True on success, false on error */ public function select($mailbox, $qresync_data = null) { if (!strlen($mailbox)) { return false; } if ($this->selected === $mailbox) { return true; } $params = array($this->escape($mailbox)); // QRESYNC data items // 0. the last known UIDVALIDITY, // 1. the last known modification sequence, // 2. the optional set of known UIDs, and // 3. an optional parenthesized list of known sequence ranges and their // corresponding UIDs. if (!empty($qresync_data)) { if (!empty($qresync_data[2])) { $qresync_data[2] = self::compressMessageSet($qresync_data[2]); } $params[] = array('QRESYNC', $qresync_data); } list($code, $response) = $this->execute('SELECT', $params); if ($code == self::ERROR_OK) { $this->clear_mailbox_cache(); $response = explode("\r\n", $response); foreach ($response as $line) { if (preg_match('/^\* OK \[/i', $line)) { $pos = strcspn($line, ' ]', 6); $token = strtoupper(substr($line, 6, $pos)); $pos += 7; switch ($token) { case 'UIDNEXT': case 'UIDVALIDITY': case 'UNSEEN': if ($len = strspn($line, '0123456789', $pos)) { $this->data[$token] = (int) substr($line, $pos, $len); } break; case 'HIGHESTMODSEQ': if ($len = strspn($line, '0123456789', $pos)) { $this->data[$token] = (string) substr($line, $pos, $len); } break; case 'NOMODSEQ': $this->data[$token] = true; break; case 'PERMANENTFLAGS': $start = strpos($line, '(', $pos); $end = strrpos($line, ')'); if ($start && $end) { $flags = substr($line, $start + 1, $end - $start - 1); $this->data[$token] = explode(' ', $flags); } break; } } else if (preg_match('/^\* ([0-9]+) (EXISTS|RECENT|FETCH)/i', $line, $match)) { $token = strtoupper($match[2]); switch ($token) { case 'EXISTS': case 'RECENT': $this->data[$token] = (int) $match[1]; break; case 'FETCH': // QRESYNC FETCH response (RFC5162) $line = substr($line, strlen($match[0])); $fetch_data = $this->tokenizeResponse($line, 1); $data = array('id' => $match[1]); for ($i=0, $size=count($fetch_data); $i<$size; $i+=2) { $data[strtolower($fetch_data[$i])] = $fetch_data[$i+1]; } $this->data['QRESYNC'][$data['uid']] = $data; break; } } // QRESYNC VANISHED response (RFC5162) else if (preg_match('/^\* VANISHED [()EARLIER]*/i', $line, $match)) { $line = substr($line, strlen($match[0])); $v_data = $this->tokenizeResponse($line, 1); $this->data['VANISHED'] = $v_data; } } $this->data['READ-WRITE'] = $this->resultcode != 'READ-ONLY'; $this->selected = $mailbox; return true; } return false; } /** * Executes STATUS command * * @param string $mailbox Mailbox name * @param array $items Additional requested item names. By default * MESSAGES and UNSEEN are requested. Other defined * in RFC3501: UIDNEXT, UIDVALIDITY, RECENT * * @return array Status item-value hash * @since 0.5-beta */ public function status($mailbox, $items = array()) { if (!strlen($mailbox)) { return false; } if (!in_array('MESSAGES', $items)) { $items[] = 'MESSAGES'; } if (!in_array('UNSEEN', $items)) { $items[] = 'UNSEEN'; } list($code, $response) = $this->execute('STATUS', array($this->escape($mailbox), '(' . implode(' ', $items) . ')'), 0, '/^\* STATUS /i'); if ($code == self::ERROR_OK && $response) { $result = array(); $response = substr($response, 9); // remove prefix "* STATUS " list($mbox, $items) = $this->tokenizeResponse($response, 2); // Fix for #1487859. Some buggy server returns not quoted // folder name with spaces. Let's try to handle this situation if (!is_array($items) && ($pos = strpos($response, '(')) !== false) { $response = substr($response, $pos); $items = $this->tokenizeResponse($response, 1); } if (!is_array($items)) { return $result; } for ($i=0, $len=count($items); $i<$len; $i += 2) { $result[$items[$i]] = $items[$i+1]; } $this->data['STATUS:'.$mailbox] = $result; return $result; } return false; } /** * Executes EXPUNGE command * * @param string $mailbox Mailbox name * @param string|array $messages Message UIDs to expunge * * @return boolean True on success, False on error */ public function expunge($mailbox, $messages = null) { if (!$this->select($mailbox)) { return false; } if (!$this->data['READ-WRITE']) { $this->setError(self::ERROR_READONLY, "Mailbox is read-only"); return false; } // Clear internal status cache $this->clear_status_cache($mailbox); if (!empty($messages) && $messages != '*' && $this->hasCapability('UIDPLUS')) { $messages = self::compressMessageSet($messages); $result = $this->execute('UID EXPUNGE', array($messages), self::COMMAND_NORESPONSE); } else { $result = $this->execute('EXPUNGE', null, self::COMMAND_NORESPONSE); } if ($result == self::ERROR_OK) { $this->selected = null; // state has changed, need to reselect return true; } return false; } /** * Executes CLOSE command * * @return boolean True on success, False on error * @since 0.5 */ public function close() { $result = $this->execute('CLOSE', null, self::COMMAND_NORESPONSE); if ($result == self::ERROR_OK) { $this->selected = null; return true; } return false; } /** * Folder subscription (SUBSCRIBE) * * @param string $mailbox Mailbox name * * @return boolean True on success, False on error */ public function subscribe($mailbox) { $result = $this->execute('SUBSCRIBE', array($this->escape($mailbox)), self::COMMAND_NORESPONSE); return $result == self::ERROR_OK; } /** * Folder unsubscription (UNSUBSCRIBE) * * @param string $mailbox Mailbox name * * @return boolean True on success, False on error */ public function unsubscribe($mailbox) { $result = $this->execute('UNSUBSCRIBE', array($this->escape($mailbox)), self::COMMAND_NORESPONSE); return $result == self::ERROR_OK; } /** * Folder creation (CREATE) * * @param string $mailbox Mailbox name * @param array $types Optional folder types (RFC 6154) * * @return bool True on success, False on error */ public function createFolder($mailbox, $types = null) { $args = array($this->escape($mailbox)); // RFC 6154: CREATE-SPECIAL-USE if (!empty($types) && $this->getCapability('CREATE-SPECIAL-USE')) { $args[] = '(USE (' . implode(' ', $types) . '))'; } $result = $this->execute('CREATE', $args, self::COMMAND_NORESPONSE); return $result == self::ERROR_OK; } /** * Folder renaming (RENAME) * * @param string $mailbox Mailbox name * * @return bool True on success, False on error */ public function renameFolder($from, $to) { $result = $this->execute('RENAME', array($this->escape($from), $this->escape($to)), self::COMMAND_NORESPONSE); return $result == self::ERROR_OK; } /** * Executes DELETE command * * @param string $mailbox Mailbox name * * @return boolean True on success, False on error */ public function deleteFolder($mailbox) { $result = $this->execute('DELETE', array($this->escape($mailbox)), self::COMMAND_NORESPONSE); return $result == self::ERROR_OK; } /** * Removes all messages in a folder * * @param string $mailbox Mailbox name * * @return boolean True on success, False on error */ public function clearFolder($mailbox) { $res = false; if ($this->countMessages($mailbox) > 0) { $res = $this->flag($mailbox, '1:*', 'DELETED'); } if ($res) { if ($this->selected === $mailbox) { $res = $this->close(); } else { $res = $this->expunge($mailbox); } } return $res; } /** * Returns list of mailboxes * * @param string $ref Reference name * @param string $mailbox Mailbox name * @param array $return_opts (see self::_listMailboxes) * @param array $select_opts (see self::_listMailboxes) * * @return array|bool List of mailboxes or hash of options if STATUS/MYROGHTS response * is requested, False on error. */ public function listMailboxes($ref, $mailbox, $return_opts = array(), $select_opts = array()) { return $this->_listMailboxes($ref, $mailbox, false, $return_opts, $select_opts); } /** * Returns list of subscribed mailboxes * * @param string $ref Reference name * @param string $mailbox Mailbox name * @param array $return_opts (see self::_listMailboxes) * * @return array|bool List of mailboxes or hash of options if STATUS/MYROGHTS response * is requested, False on error. */ public function listSubscribed($ref, $mailbox, $return_opts = array()) { return $this->_listMailboxes($ref, $mailbox, true, $return_opts, null); } /** * IMAP LIST/LSUB command * * @param string $ref Reference name * @param string $mailbox Mailbox name * @param bool $subscribed Enables returning subscribed mailboxes only * @param array $return_opts List of RETURN options (RFC5819: LIST-STATUS, RFC5258: LIST-EXTENDED) * Possible: MESSAGES, RECENT, UIDNEXT, UIDVALIDITY, UNSEEN, * MYRIGHTS, SUBSCRIBED, CHILDREN * @param array $select_opts List of selection options (RFC5258: LIST-EXTENDED) * Possible: SUBSCRIBED, RECURSIVEMATCH, REMOTE, * SPECIAL-USE (RFC6154) * * @return array|bool List of mailboxes or hash of options if STATUS/MYROGHTS response * is requested, False on error. */ protected function _listMailboxes($ref, $mailbox, $subscribed=false, $return_opts=array(), $select_opts=array()) { if (!strlen($mailbox)) { $mailbox = '*'; } $args = array(); $rets = array(); if (!empty($select_opts) && $this->getCapability('LIST-EXTENDED')) { $select_opts = (array) $select_opts; $args[] = '(' . implode(' ', $select_opts) . ')'; } $lstatus = false; $args[] = $this->escape($ref); $args[] = $this->escape($mailbox); if (!empty($return_opts) && $this->getCapability('LIST-EXTENDED')) { $ext_opts = array('SUBSCRIBED', 'CHILDREN'); $rets = array_intersect($return_opts, $ext_opts); $return_opts = array_diff($return_opts, $rets); } if (!empty($return_opts) && $this->getCapability('LIST-STATUS')) { $lstatus = true; $status_opts = array('MESSAGES', 'RECENT', 'UIDNEXT', 'UIDVALIDITY', 'UNSEEN'); $opts = array_diff($return_opts, $status_opts); $status_opts = array_diff($return_opts, $opts); if (!empty($status_opts)) { $rets[] = 'STATUS (' . implode(' ', $status_opts) . ')'; } if (!empty($opts)) { $rets = array_merge($rets, $opts); } } if (!empty($rets)) { $args[] = 'RETURN (' . implode(' ', $rets) . ')'; } list($code, $response) = $this->execute($subscribed ? 'LSUB' : 'LIST', $args); if ($code == self::ERROR_OK) { $folders = array(); $last = 0; $pos = 0; $response .= "\r\n"; while ($pos = strpos($response, "\r\n", $pos+1)) { // literal string, not real end-of-command-line if ($response[$pos-1] == '}') { continue; } $line = substr($response, $last, $pos - $last); $last = $pos + 2; if (!preg_match('/^\* (LIST|LSUB|STATUS|MYRIGHTS) /i', $line, $m)) { continue; } $cmd = strtoupper($m[1]); $line = substr($line, strlen($m[0])); // * LIST () if ($cmd == 'LIST' || $cmd == 'LSUB') { list($opts, $delim, $mailbox) = $this->tokenizeResponse($line, 3); // Remove redundant separator at the end of folder name, UW-IMAP bug? (#1488879) if ($delim) { $mailbox = rtrim($mailbox, $delim); } // Add to result array if (!$lstatus) { $folders[] = $mailbox; } else { $folders[$mailbox] = array(); } // store folder options if ($cmd == 'LIST') { // Add to options array if (empty($this->data['LIST'][$mailbox])) { $this->data['LIST'][$mailbox] = $opts; } else if (!empty($opts)) { $this->data['LIST'][$mailbox] = array_unique(array_merge( $this->data['LIST'][$mailbox], $opts)); } } } else if ($lstatus) { // * STATUS () if ($cmd == 'STATUS') { list($mailbox, $status) = $this->tokenizeResponse($line, 2); for ($i=0, $len=count($status); $i<$len; $i += 2) { list($name, $value) = $this->tokenizeResponse($status, 2); $folders[$mailbox][$name] = $value; } } // * MYRIGHTS else if ($cmd == 'MYRIGHTS') { list($mailbox, $acl) = $this->tokenizeResponse($line, 2); $folders[$mailbox]['MYRIGHTS'] = $acl; } } } return $folders; } return false; } /** * Returns count of all messages in a folder * * @param string $mailbox Mailbox name * * @return int Number of messages, False on error */ public function countMessages($mailbox) { if ($this->selected === $mailbox && isset($this->data['EXISTS'])) { return $this->data['EXISTS']; } if (isset($this->data["STATUS:".$mailbox])) { $cache = $this->data["STATUS:".$mailbox]; if (!empty($cache) && isset($cache['MESSAGES'])) { return (int) $cache['MESSAGES']; } } // Try STATUS (should be faster than SELECT) $counts = $this->status($mailbox); if (is_array($counts)) { return (int) $counts['MESSAGES']; } return false; } /** * Returns count of messages with \Recent flag in a folder * * @param string $mailbox Mailbox name * * @return int Number of messages, False on error */ public function countRecent($mailbox) { if ($this->selected === $mailbox && isset($this->data['RECENT'])) { return $this->data['RECENT']; } // Check internal cache $cache = $this->data['STATUS:'.$mailbox]; if (!empty($cache) && isset($cache['RECENT'])) { return (int) $cache['RECENT']; } // Try STATUS (should be faster than SELECT) $counts = $this->status($mailbox, array('RECENT')); if (is_array($counts)) { return (int) $counts['RECENT']; } return false; } /** * Returns count of messages without \Seen flag in a specified folder * * @param string $mailbox Mailbox name * * @return int Number of messages, False on error */ public function countUnseen($mailbox) { // Check internal cache $cache = $this->data['STATUS:'.$mailbox]; if (!empty($cache) && isset($cache['UNSEEN'])) { return (int) $cache['UNSEEN']; } // Try STATUS (should be faster than SELECT+SEARCH) $counts = $this->status($mailbox); if (is_array($counts)) { return (int) $counts['UNSEEN']; } // Invoke SEARCH as a fallback $index = $this->search($mailbox, 'ALL UNSEEN', false, array('COUNT')); if (!$index->is_error()) { return $index->count(); } return false; } /** * Executes ID command (RFC2971) * * @param array $items Client identification information key/value hash * * @return array Server identification information key/value hash * @since 0.6 */ public function id($items = array()) { if (is_array($items) && !empty($items)) { foreach ($items as $key => $value) { $args[] = $this->escape($key, true); $args[] = $this->escape($value, true); } } list($code, $response) = $this->execute('ID', array(!empty($args) ? '(' . implode(' ', (array) $args) . ')' : $this->escape(null)), 0, '/^\* ID /i'); if ($code == self::ERROR_OK && $response) { $response = substr($response, 5); // remove prefix "* ID " $items = $this->tokenizeResponse($response, 1); $result = null; for ($i=0, $len=count($items); $i<$len; $i += 2) { $result[$items[$i]] = $items[$i+1]; } return $result; } return false; } /** * Executes ENABLE command (RFC5161) * * @param mixed $extension Extension name to enable (or array of names) * * @return array|bool List of enabled extensions, False on error * @since 0.6 */ public function enable($extension) { if (empty($extension)) { return false; } if (!$this->hasCapability('ENABLE')) { return false; } if (!is_array($extension)) { $extension = array($extension); } if (!empty($this->extensions_enabled)) { // check if all extensions are already enabled $diff = array_diff($extension, $this->extensions_enabled); if (empty($diff)) { return $extension; } // Make sure the mailbox isn't selected, before enabling extension(s) if ($this->selected !== null) { $this->close(); } } list($code, $response) = $this->execute('ENABLE', $extension, 0, '/^\* ENABLED /i'); if ($code == self::ERROR_OK && $response) { $response = substr($response, 10); // remove prefix "* ENABLED " $result = (array) $this->tokenizeResponse($response); $this->extensions_enabled = array_unique(array_merge((array)$this->extensions_enabled, $result)); return $this->extensions_enabled; } return false; } /** * Executes SORT command * * @param string $mailbox Mailbox name * @param string $field Field to sort by (ARRIVAL, CC, DATE, FROM, SIZE, SUBJECT, TO) * @param string $criteria Searching criteria * @param bool $return_uid Enables UID SORT usage * @param string $encoding Character set * * @return rcube_result_index Response data */ public function sort($mailbox, $field = 'ARRIVAL', $criteria = '', $return_uid = false, $encoding = 'US-ASCII') { $old_sel = $this->selected; $supported = array('ARRIVAL', 'CC', 'DATE', 'FROM', 'SIZE', 'SUBJECT', 'TO'); $field = strtoupper($field); if ($field == 'INTERNALDATE') { $field = 'ARRIVAL'; } if (!in_array($field, $supported)) { return new rcube_result_index($mailbox); } if (!$this->select($mailbox)) { return new rcube_result_index($mailbox); } // return empty result when folder is empty and we're just after SELECT if ($old_sel != $mailbox && !$this->data['EXISTS']) { return new rcube_result_index($mailbox, '* SORT'); } // RFC 5957: SORT=DISPLAY if (($field == 'FROM' || $field == 'TO') && $this->getCapability('SORT=DISPLAY')) { $field = 'DISPLAY' . $field; } $encoding = $encoding ? trim($encoding) : 'US-ASCII'; $criteria = $criteria ? 'ALL ' . trim($criteria) : 'ALL'; list($code, $response) = $this->execute($return_uid ? 'UID SORT' : 'SORT', array("($field)", $encoding, $criteria)); if ($code != self::ERROR_OK) { $response = null; } return new rcube_result_index($mailbox, $response); } /** * Executes THREAD command * * @param string $mailbox Mailbox name * @param string $algorithm Threading algorithm (ORDEREDSUBJECT, REFERENCES, REFS) * @param string $criteria Searching criteria * @param bool $return_uid Enables UIDs in result instead of sequence numbers * @param string $encoding Character set * * @return rcube_result_thread Thread data */ public function thread($mailbox, $algorithm = 'REFERENCES', $criteria = '', $return_uid = false, $encoding = 'US-ASCII') { $old_sel = $this->selected; if (!$this->select($mailbox)) { return new rcube_result_thread($mailbox); } // return empty result when folder is empty and we're just after SELECT if ($old_sel != $mailbox && !$this->data['EXISTS']) { return new rcube_result_thread($mailbox, '* THREAD'); } $encoding = $encoding ? trim($encoding) : 'US-ASCII'; $algorithm = $algorithm ? trim($algorithm) : 'REFERENCES'; $criteria = $criteria ? 'ALL '.trim($criteria) : 'ALL'; list($code, $response) = $this->execute($return_uid ? 'UID THREAD' : 'THREAD', array($algorithm, $encoding, $criteria)); if ($code != self::ERROR_OK) { $response = null; } return new rcube_result_thread($mailbox, $response); } /** * Executes SEARCH command * * @param string $mailbox Mailbox name * @param string $criteria Searching criteria * @param bool $return_uid Enable UID in result instead of sequence ID * @param array $items Return items (MIN, MAX, COUNT, ALL) * * @return rcube_result_index Result data */ public function search($mailbox, $criteria, $return_uid = false, $items = array()) { $old_sel = $this->selected; if (!$this->select($mailbox)) { return new rcube_result_index($mailbox); } // return empty result when folder is empty and we're just after SELECT if ($old_sel != $mailbox && !$this->data['EXISTS']) { return new rcube_result_index($mailbox, '* SEARCH'); } // If ESEARCH is supported always use ALL // but not when items are specified or using simple id2uid search if (empty($items) && preg_match('/[^0-9]/', $criteria)) { $items = array('ALL'); } $esearch = empty($items) ? false : $this->getCapability('ESEARCH'); $criteria = trim($criteria); $params = ''; // RFC4731: ESEARCH if (!empty($items) && $esearch) { $params .= 'RETURN (' . implode(' ', $items) . ')'; } if (!empty($criteria)) { $params .= ($params ? ' ' : '') . $criteria; } else { $params .= 'ALL'; } list($code, $response) = $this->execute($return_uid ? 'UID SEARCH' : 'SEARCH', array($params)); if ($code != self::ERROR_OK) { $response = null; } return new rcube_result_index($mailbox, $response); } /** * Simulates SORT command by using FETCH and sorting. * * @param string $mailbox Mailbox name * @param string|array $message_set Searching criteria (list of messages to return) * @param string $index_field Field to sort by (ARRIVAL, CC, DATE, FROM, SIZE, SUBJECT, TO) * @param bool $skip_deleted Makes that DELETED messages will be skipped * @param bool $uidfetch Enables UID FETCH usage * @param bool $return_uid Enables returning UIDs instead of IDs * * @return rcube_result_index Response data */ public function index($mailbox, $message_set, $index_field='', $skip_deleted=true, $uidfetch=false, $return_uid=false) { $msg_index = $this->fetchHeaderIndex($mailbox, $message_set, $index_field, $skip_deleted, $uidfetch, $return_uid); if (!empty($msg_index)) { asort($msg_index); // ASC $msg_index = array_keys($msg_index); $msg_index = '* SEARCH ' . implode(' ', $msg_index); } else { $msg_index = is_array($msg_index) ? '* SEARCH' : null; } return new rcube_result_index($mailbox, $msg_index); } /** * Fetches specified header/data value for a set of messages. * * @param string $mailbox Mailbox name * @param string|array $message_set Searching criteria (list of messages to return) * @param string $index_field Field to sort by (ARRIVAL, CC, DATE, FROM, SIZE, SUBJECT, TO) * @param bool $skip_deleted Makes that DELETED messages will be skipped * @param bool $uidfetch Enables UID FETCH usage * @param bool $return_uid Enables returning UIDs instead of IDs * * @return array|bool List of header values or False on failure */ public function fetchHeaderIndex($mailbox, $message_set, $index_field = '', $skip_deleted = true, $uidfetch = false, $return_uid = false) { if (is_array($message_set)) { if (!($message_set = $this->compressMessageSet($message_set))) { return false; } } else { list($from_idx, $to_idx) = explode(':', $message_set); if (empty($message_set) || (isset($to_idx) && $to_idx != '*' && (int)$from_idx > (int)$to_idx) ) { return false; } } $index_field = empty($index_field) ? 'DATE' : strtoupper($index_field); $fields_a['DATE'] = 1; $fields_a['INTERNALDATE'] = 4; $fields_a['ARRIVAL'] = 4; $fields_a['FROM'] = 1; $fields_a['REPLY-TO'] = 1; $fields_a['SENDER'] = 1; $fields_a['TO'] = 1; $fields_a['CC'] = 1; $fields_a['SUBJECT'] = 1; $fields_a['UID'] = 2; $fields_a['SIZE'] = 2; $fields_a['SEEN'] = 3; $fields_a['RECENT'] = 3; $fields_a['DELETED'] = 3; if (!($mode = $fields_a[$index_field])) { return false; } // Select the mailbox if (!$this->select($mailbox)) { return false; } // build FETCH command string $key = $this->nextTag(); $cmd = $uidfetch ? 'UID FETCH' : 'FETCH'; $fields = array(); if ($return_uid) { $fields[] = 'UID'; } if ($skip_deleted) { $fields[] = 'FLAGS'; } if ($mode == 1) { if ($index_field == 'DATE') { $fields[] = 'INTERNALDATE'; } $fields[] = "BODY.PEEK[HEADER.FIELDS ($index_field)]"; } else if ($mode == 2) { if ($index_field == 'SIZE') { $fields[] = 'RFC822.SIZE'; } else if (!$return_uid || $index_field != 'UID') { $fields[] = $index_field; } } else if ($mode == 3 && !$skip_deleted) { $fields[] = 'FLAGS'; } else if ($mode == 4) { $fields[] = 'INTERNALDATE'; } $request = "$key $cmd $message_set (" . implode(' ', $fields) . ")"; if (!$this->putLine($request)) { $this->setError(self::ERROR_COMMAND, "Failed to send $cmd command"); return false; } $result = array(); do { $line = rtrim($this->readLine(200)); $line = $this->multLine($line); if (preg_match('/^\* ([0-9]+) FETCH/', $line, $m)) { $id = $m[1]; $flags = null; if ($return_uid) { if (preg_match('/UID ([0-9]+)/', $line, $matches)) { $id = (int) $matches[1]; } else { continue; } } if ($skip_deleted && preg_match('/FLAGS \(([^)]+)\)/', $line, $matches)) { $flags = explode(' ', strtoupper($matches[1])); if (in_array('\\DELETED', $flags)) { continue; } } if ($mode == 1 && $index_field == 'DATE') { if (preg_match('/BODY\[HEADER\.FIELDS \("*DATE"*\)\] (.*)/', $line, $matches)) { $value = preg_replace(array('/^"*[a-z]+:/i'), '', $matches[1]); $value = trim($value); $result[$id] = rcube_utils::strtotime($value); } // non-existent/empty Date: header, use INTERNALDATE if (empty($result[$id])) { if (preg_match('/INTERNALDATE "([^"]+)"/', $line, $matches)) { $result[$id] = rcube_utils::strtotime($matches[1]); } else { $result[$id] = 0; } } } else if ($mode == 1) { if (preg_match('/BODY\[HEADER\.FIELDS \("?(FROM|REPLY-TO|SENDER|TO|SUBJECT)"?\)\] (.*)/', $line, $matches)) { $value = preg_replace(array('/^"*[a-z]+:/i', '/\s+$/sm'), array('', ''), $matches[2]); $result[$id] = trim($value); } else { $result[$id] = ''; } } else if ($mode == 2) { if (preg_match('/' . $index_field . ' ([0-9]+)/', $line, $matches)) { $result[$id] = trim($matches[1]); } else { $result[$id] = 0; } } else if ($mode == 3) { if (!$flags && preg_match('/FLAGS \(([^)]+)\)/', $line, $matches)) { $flags = explode(' ', $matches[1]); } $result[$id] = in_array("\\".$index_field, (array) $flags) ? 1 : 0; } else if ($mode == 4) { if (preg_match('/INTERNALDATE "([^"]+)"/', $line, $matches)) { $result[$id] = rcube_utils::strtotime($matches[1]); } else { $result[$id] = 0; } } } } while (!$this->startsWith($line, $key, true, true)); return $result; } /** * Returns message sequence identifier * * @param string $mailbox Mailbox name * @param int $uid Message unique identifier (UID) * * @return int Message sequence identifier */ public function UID2ID($mailbox, $uid) { if ($uid > 0) { $index = $this->search($mailbox, "UID $uid"); if ($index->count() == 1) { $arr = $index->get(); return (int) $arr[0]; } } } /** * Returns message unique identifier (UID) * * @param string $mailbox Mailbox name * @param int $uid Message sequence identifier * * @return int Message unique identifier */ public function ID2UID($mailbox, $id) { if (empty($id) || $id < 0) { return null; } if (!$this->select($mailbox)) { return null; } if ($uid = $this->data['UID-MAP'][$id]) { return $uid; } if (isset($this->data['EXISTS']) && $id > $this->data['EXISTS']) { return null; } $index = $this->search($mailbox, $id, true); if ($index->count() == 1) { $arr = $index->get(); return $this->data['UID-MAP'][$id] = (int) $arr[0]; } } /** * Sets flag of the message(s) * * @param string $mailbox Mailbox name * @param string|array $messages Message UID(s) * @param string $flag Flag name * * @return bool True on success, False on failure */ public function flag($mailbox, $messages, $flag) { return $this->modFlag($mailbox, $messages, $flag, '+'); } /** * Unsets flag of the message(s) * * @param string $mailbox Mailbox name * @param string|array $messages Message UID(s) * @param string $flag Flag name * * @return bool True on success, False on failure */ public function unflag($mailbox, $messages, $flag) { return $this->modFlag($mailbox, $messages, $flag, '-'); } /** * Changes flag of the message(s) * * @param string $mailbox Mailbox name * @param string|array $messages Message UID(s) * @param string $flag Flag name * @param string $mod Modifier [+|-]. Default: "+". * * @return bool True on success, False on failure */ protected function modFlag($mailbox, $messages, $flag, $mod = '+') { if (!$flag) { return false; } if (!$this->select($mailbox)) { return false; } if (!$this->data['READ-WRITE']) { $this->setError(self::ERROR_READONLY, "Mailbox is read-only"); return false; } if ($this->flags[strtoupper($flag)]) { $flag = $this->flags[strtoupper($flag)]; } // if PERMANENTFLAGS is not specified all flags are allowed if (!empty($this->data['PERMANENTFLAGS']) && !in_array($flag, (array) $this->data['PERMANENTFLAGS']) && !in_array('\\*', (array) $this->data['PERMANENTFLAGS']) ) { return false; } // Clear internal status cache if ($flag == 'SEEN') { unset($this->data['STATUS:'.$mailbox]['UNSEEN']); } if ($mod != '+' && $mod != '-') { $mod = '+'; } $result = $this->execute('UID STORE', array( $this->compressMessageSet($messages), $mod . 'FLAGS.SILENT', "($flag)"), self::COMMAND_NORESPONSE); return $result == self::ERROR_OK; } /** * Copies message(s) from one folder to another * * @param string|array $messages Message UID(s) * @param string $from Mailbox name * @param string $to Destination mailbox name * * @return bool True on success, False on failure */ public function copy($messages, $from, $to) { // Clear last COPYUID data unset($this->data['COPYUID']); if (!$this->select($from)) { return false; } // Clear internal status cache unset($this->data['STATUS:'.$to]); $result = $this->execute('UID COPY', array( $this->compressMessageSet($messages), $this->escape($to)), self::COMMAND_NORESPONSE); return $result == self::ERROR_OK; } /** * Moves message(s) from one folder to another. * * @param string|array $messages Message UID(s) * @param string $from Mailbox name * @param string $to Destination mailbox name * * @return bool True on success, False on failure */ public function move($messages, $from, $to) { if (!$this->select($from)) { return false; } if (!$this->data['READ-WRITE']) { $this->setError(self::ERROR_READONLY, "Mailbox is read-only"); return false; } // use MOVE command (RFC 6851) if ($this->hasCapability('MOVE')) { // Clear last COPYUID data unset($this->data['COPYUID']); // Clear internal status cache unset($this->data['STATUS:'.$to]); $this->clear_status_cache($from); $result = $this->execute('UID MOVE', array( $this->compressMessageSet($messages), $this->escape($to)), self::COMMAND_NORESPONSE); return $result == self::ERROR_OK; } // use COPY + STORE +FLAGS.SILENT \Deleted + EXPUNGE $result = $this->copy($messages, $from, $to); if ($result) { // Clear internal status cache unset($this->data['STATUS:'.$from]); $result = $this->flag($from, $messages, 'DELETED'); if ($messages == '*') { // CLOSE+SELECT should be faster than EXPUNGE $this->close(); } else { $this->expunge($from, $messages); } } return $result; } /** * FETCH command (RFC3501) * * @param string $mailbox Mailbox name * @param mixed $message_set Message(s) sequence identifier(s) or UID(s) * @param bool $is_uid True if $message_set contains UIDs * @param array $query_items FETCH command data items * @param string $mod_seq Modification sequence for CHANGEDSINCE (RFC4551) query * @param bool $vanished Enables VANISHED parameter (RFC5162) for CHANGEDSINCE query * * @return array List of rcube_message_header elements, False on error * @since 0.6 */ public function fetch($mailbox, $message_set, $is_uid = false, $query_items = array(), $mod_seq = null, $vanished = false) { if (!$this->select($mailbox)) { return false; } $message_set = $this->compressMessageSet($message_set); $result = array(); $key = $this->nextTag(); $cmd = ($is_uid ? 'UID ' : '') . 'FETCH'; $request = "$key $cmd $message_set (" . implode(' ', $query_items) . ")"; if ($mod_seq !== null && $this->hasCapability('CONDSTORE')) { $request .= " (CHANGEDSINCE $mod_seq" . ($vanished ? " VANISHED" : '') .")"; } if (!$this->putLine($request)) { $this->setError(self::ERROR_COMMAND, "Failed to send $cmd command"); return false; } do { $line = $this->readLine(4096); if (!$line) { break; } // Sample reply line: // * 321 FETCH (UID 2417 RFC822.SIZE 2730 FLAGS (\Seen) // INTERNALDATE "16-Nov-2008 21:08:46 +0100" BODYSTRUCTURE (...) // BODY[HEADER.FIELDS ... if (preg_match('/^\* ([0-9]+) FETCH/', $line, $m)) { $id = intval($m[1]); $result[$id] = new rcube_message_header; $result[$id]->id = $id; $result[$id]->subject = ''; $result[$id]->messageID = 'mid:' . $id; $headers = null; $lines = array(); $line = substr($line, strlen($m[0]) + 2); $ln = 0; // get complete entry while (preg_match('/\{([0-9]+)\}\r\n$/', $line, $m)) { $bytes = $m[1]; $out = ''; while (strlen($out) < $bytes) { $out = $this->readBytes($bytes); if ($out === null) { break; } $line .= $out; } $str = $this->readLine(4096); if ($str === false) { break; } $line .= $str; } // Tokenize response and assign to object properties while (list($name, $value) = $this->tokenizeResponse($line, 2)) { if ($name == 'UID') { $result[$id]->uid = intval($value); } else if ($name == 'RFC822.SIZE') { $result[$id]->size = intval($value); } else if ($name == 'RFC822.TEXT') { $result[$id]->body = $value; } else if ($name == 'INTERNALDATE') { $result[$id]->internaldate = $value; $result[$id]->date = $value; $result[$id]->timestamp = rcube_utils::strtotime($value); } else if ($name == 'FLAGS') { if (!empty($value)) { foreach ((array)$value as $flag) { $flag = str_replace(array('$', "\\"), '', $flag); $flag = strtoupper($flag); $result[$id]->flags[$flag] = true; } } } else if ($name == 'MODSEQ') { $result[$id]->modseq = $value[0]; } else if ($name == 'ENVELOPE') { $result[$id]->envelope = $value; } else if ($name == 'BODYSTRUCTURE' || ($name == 'BODY' && count($value) > 2)) { if (!is_array($value[0]) && (strtolower($value[0]) == 'message' && strtolower($value[1]) == 'rfc822')) { $value = array($value); } $result[$id]->bodystructure = $value; } else if ($name == 'RFC822') { $result[$id]->body = $value; } else if (stripos($name, 'BODY[') === 0) { $name = str_replace(']', '', substr($name, 5)); if ($name == 'HEADER.FIELDS') { // skip ']' after headers list $this->tokenizeResponse($line, 1); $headers = $this->tokenizeResponse($line, 1); } else if (strlen($name)) { $result[$id]->bodypart[$name] = $value; } else { $result[$id]->body = $value; } } } // create array with header field:data if (!empty($headers)) { $headers = explode("\n", trim($headers)); foreach ($headers as $resln) { if (ord($resln[0]) <= 32) { $lines[$ln] .= (empty($lines[$ln]) ? '' : "\n") . trim($resln); } else { $lines[++$ln] = trim($resln); } } foreach ($lines as $str) { list($field, $string) = explode(':', $str, 2); $field = strtolower($field); $string = preg_replace('/\n[\t\s]*/', ' ', trim($string)); switch ($field) { case 'date'; $string = substr($string, 0, 128); $result[$id]->date = $string; $result[$id]->timestamp = rcube_utils::strtotime($string); break; case 'to': $result[$id]->to = preg_replace('/undisclosed-recipients:[;,]*/', '', $string); break; case 'from': case 'subject': $string = substr($string, 0, 2048); case 'cc': case 'bcc': case 'references': $result[$id]->{$field} = $string; break; case 'reply-to': $result[$id]->replyto = $string; break; case 'content-transfer-encoding': $result[$id]->encoding = substr($string, 0, 32); break; case 'content-type': $ctype_parts = preg_split('/[; ]+/', $string); $result[$id]->ctype = strtolower(array_shift($ctype_parts)); if (preg_match('/charset\s*=\s*"?([a-z0-9\-\.\_]+)"?/i', $string, $regs)) { $result[$id]->charset = $regs[1]; } break; case 'in-reply-to': $result[$id]->in_reply_to = str_replace(array("\n", '<', '>'), '', $string); break; case 'return-receipt-to': case 'disposition-notification-to': case 'x-confirm-reading-to': $result[$id]->mdn_to = substr($string, 0, 2048); break; case 'message-id': $result[$id]->messageID = substr($string, 0, 2048); break; case 'x-priority': if (preg_match('/^(\d+)/', $string, $matches)) { $result[$id]->priority = intval($matches[1]); } break; default: if (strlen($field) < 3) { break; } if ($result[$id]->others[$field]) { $string = array_merge((array)$result[$id]->others[$field], (array)$string); } $result[$id]->others[$field] = $string; } } } } // VANISHED response (QRESYNC RFC5162) // Sample: * VANISHED (EARLIER) 300:310,405,411 else if (preg_match('/^\* VANISHED [()EARLIER]*/i', $line, $match)) { $line = substr($line, strlen($match[0])); $v_data = $this->tokenizeResponse($line, 1); $this->data['VANISHED'] = $v_data; } } while (!$this->startsWith($line, $key, true)); return $result; } /** * Returns message(s) data (flags, headers, etc.) * * @param string $mailbox Mailbox name * @param mixed $message_set Message(s) sequence identifier(s) or UID(s) * @param bool $is_uid True if $message_set contains UIDs * @param bool $bodystr Enable to add BODYSTRUCTURE data to the result * @param array $add_headers List of additional headers * * @return bool|array List of rcube_message_header elements, False on error */ public function fetchHeaders($mailbox, $message_set, $is_uid = false, $bodystr = false, $add_headers = array()) { $query_items = array('UID', 'RFC822.SIZE', 'FLAGS', 'INTERNALDATE'); $headers = array('DATE', 'FROM', 'TO', 'SUBJECT', 'CONTENT-TYPE', 'CC', 'REPLY-TO', 'LIST-POST', 'DISPOSITION-NOTIFICATION-TO', 'X-PRIORITY'); if (!empty($add_headers)) { $add_headers = array_map('strtoupper', $add_headers); $headers = array_unique(array_merge($headers, $add_headers)); } if ($bodystr) { $query_items[] = 'BODYSTRUCTURE'; } $query_items[] = 'BODY.PEEK[HEADER.FIELDS (' . implode(' ', $headers) . ')]'; return $this->fetch($mailbox, $message_set, $is_uid, $query_items); } /** * Returns message data (flags, headers, etc.) * * @param string $mailbox Mailbox name * @param int $id Message sequence identifier or UID * @param bool $is_uid True if $id is an UID * @param bool $bodystr Enable to add BODYSTRUCTURE data to the result * @param array $add_headers List of additional headers * * @return bool|rcube_message_header Message data, False on error */ public function fetchHeader($mailbox, $id, $is_uid = false, $bodystr = false, $add_headers = array()) { $a = $this->fetchHeaders($mailbox, $id, $is_uid, $bodystr, $add_headers); if (is_array($a)) { return array_shift($a); } return false; } /** * Sort messages by specified header field * * @param array $messages Array of rcube_message_header objects * @param string $field Name of the property to sort by * @param string $flag Sorting order (ASC|DESC) * * @return array Sorted input array */ public static function sortHeaders($messages, $field, $flag) { $field = empty($field) ? 'uid' : strtolower($field); $order = empty($flag) ? 'ASC' : strtoupper($flag); $index = array(); reset($messages); // Create an index foreach ($messages as $key => $headers) { switch ($field) { case 'arrival': $field = 'internaldate'; // no-break case 'date': case 'internaldate': case 'timestamp': $value = rcube_utils::strtotime($headers->$field); if (!$value && $field != 'timestamp') { $value = $headers->timestamp; } break; default: // @TODO: decode header value, convert to UTF-8 $value = $headers->$field; if (is_string($value)) { $value = str_replace('"', '', $value); if ($field == 'subject') { $value = preg_replace('/^(Re:\s*|Fwd:\s*|Fw:\s*)+/i', '', $value); } } } $index[$key] = $value; } $sort_order = $flag == 'ASC' ? SORT_ASC : SORT_DESC; $sort_flags = SORT_STRING | SORT_FLAG_CASE; if (in_array($field, array('arrival', 'date', 'internaldate', 'timestamp'))) { $sort_flags = SORT_NUMERIC; } array_multisort($index, $sort_order, $sort_flags, $messages); return $messages; } /** * Fetch MIME headers of specified message parts * * @param string $mailbox Mailbox name * @param int $uid Message UID * @param array $parts Message part identifiers * @param bool $mime Use MIME instad of HEADER * * @return array|bool Array containing headers string for each specified body * False on failure. */ public function fetchMIMEHeaders($mailbox, $uid, $parts, $mime = true) { if (!$this->select($mailbox)) { return false; } $result = false; $parts = (array) $parts; $key = $this->nextTag(); $peeks = array(); $type = $mime ? 'MIME' : 'HEADER'; // format request foreach ($parts as $part) { $peeks[] = "BODY.PEEK[$part.$type]"; } $request = "$key UID FETCH $uid (" . implode(' ', $peeks) . ')'; // send request if (!$this->putLine($request)) { $this->setError(self::ERROR_COMMAND, "Failed to send UID FETCH command"); return false; } do { $line = $this->readLine(1024); if (preg_match('/^\* [0-9]+ FETCH [0-9UID( ]+/', $line, $m)) { $line = ltrim(substr($line, strlen($m[0]))); while (preg_match('/^BODY\[([0-9\.]+)\.'.$type.'\]/', $line, $matches)) { $line = substr($line, strlen($matches[0])); $result[$matches[1]] = trim($this->multLine($line)); $line = $this->readLine(1024); } } } while (!$this->startsWith($line, $key, true)); return $result; } /** * Fetches message part header */ public function fetchPartHeader($mailbox, $id, $is_uid = false, $part = null) { $part = empty($part) ? 'HEADER' : $part.'.MIME'; return $this->handlePartBody($mailbox, $id, $is_uid, $part); } /** * Fetches body of the specified message part */ public function handlePartBody($mailbox, $id, $is_uid=false, $part='', $encoding=null, $print=null, $file=null, $formatted=false, $max_bytes=0) { if (!$this->select($mailbox)) { return false; } $binary = true; do { if (!$initiated) { switch ($encoding) { case 'base64': $mode = 1; break; case 'quoted-printable': $mode = 2; break; case 'x-uuencode': case 'x-uue': case 'uue': case 'uuencode': $mode = 3; break; default: $mode = 0; } // Use BINARY extension when possible (and safe) $binary = $binary && $mode && preg_match('/^[0-9.]+$/', $part) && $this->hasCapability('BINARY'); $fetch_mode = $binary ? 'BINARY' : 'BODY'; $partial = $max_bytes ? sprintf('<0.%d>', $max_bytes) : ''; // format request $key = $this->nextTag(); $cmd = ($is_uid ? 'UID ' : '') . 'FETCH'; $request = "$key $cmd $id ($fetch_mode.PEEK[$part]$partial)"; $result = false; $found = false; $initiated = true; // send request if (!$this->putLine($request)) { $this->setError(self::ERROR_COMMAND, "Failed to send $cmd command"); return false; } if ($binary) { // WARNING: Use $formatted argument with care, this may break binary data stream $mode = -1; } } $line = trim($this->readLine(1024)); if (!$line) { break; } // handle UNKNOWN-CTE response - RFC 3516, try again with standard BODY request if ($binary && !$found && preg_match('/^' . $key . ' NO \[(UNKNOWN-CTE|PARSE)\]/i', $line)) { $binary = $initiated = false; continue; } // skip irrelevant untagged responses (we have a result already) if ($found || !preg_match('/^\* ([0-9]+) FETCH (.*)$/', $line, $m)) { continue; } $line = $m[2]; // handle one line response if ($line[0] == '(' && substr($line, -1) == ')') { // tokenize content inside brackets // the content can be e.g.: (UID 9844 BODY[2.4] NIL) $tokens = $this->tokenizeResponse(preg_replace('/(^\(|\)$)/', '', $line)); for ($i=0; $i 0) { $line = $this->readLine(8192); if ($line === null) { break; } $len = strlen($line); if ($len > $bytes) { $line = substr($line, 0, $bytes); $len = strlen($line); } $bytes -= $len; // BASE64 if ($mode == 1) { $line = preg_replace('|[^a-zA-Z0-9+=/]|', '', $line); // create chunks with proper length for base64 decoding $line = $prev.$line; $length = strlen($line); if ($length % 4) { $length = floor($length / 4) * 4; $prev = substr($line, $length); $line = substr($line, 0, $length); } else { $prev = ''; } $line = base64_decode($line); } // QUOTED-PRINTABLE else if ($mode == 2) { $line = rtrim($line, "\t\r\0\x0B"); $line = quoted_printable_decode($line); } // UUENCODE else if ($mode == 3) { $line = rtrim($line, "\t\r\n\0\x0B"); if ($line == 'end' || preg_match('/^begin\s+[0-7]+\s+.+$/', $line)) { continue; } $line = convert_uudecode($line); } // default else if ($formatted) { $line = rtrim($line, "\t\r\n\0\x0B") . "\n"; } if ($file) { if (fwrite($file, $line) === false) { break; } } else if ($print) { echo $line; } else { $result .= $line; } } } } while (!$this->startsWith($line, $key, true) || !$initiated); if ($result !== false) { if ($file) { return fwrite($file, $result); } else if ($print) { echo $result; return true; } return $result; } return false; } /** * Handler for IMAP APPEND command * * @param string $mailbox Mailbox name * @param string|array $message The message source string or array (of strings and file pointers) * @param array $flags Message flags * @param string $date Message internal date * @param bool $binary Enable BINARY append (RFC3516) * * @return string|bool On success APPENDUID response (if available) or True, False on failure */ public function append($mailbox, &$message, $flags = array(), $date = null, $binary = false) { unset($this->data['APPENDUID']); if ($mailbox === null || $mailbox === '') { return false; } $binary = $binary && $this->getCapability('BINARY'); $literal_plus = !$binary && $this->prefs['literal+']; $len = 0; $msg = is_array($message) ? $message : array(&$message); $chunk_size = 512000; for ($i=0, $cnt=count($msg); $i<$cnt; $i++) { if (is_resource($msg[$i])) { $stat = fstat($msg[$i]); if ($stat === false) { return false; } $len += $stat['size']; } else { if (!$binary) { $msg[$i] = str_replace("\r", '', $msg[$i]); $msg[$i] = str_replace("\n", "\r\n", $msg[$i]); } $len += strlen($msg[$i]); } } if (!$len) { return false; } // build APPEND command $key = $this->nextTag(); $request = "$key APPEND " . $this->escape($mailbox) . ' (' . $this->flagsToStr($flags) . ')'; if (!empty($date)) { $request .= ' ' . $this->escape($date); } $request .= ' ' . ($binary ? '~' : '') . '{' . $len . ($literal_plus ? '+' : '') . '}'; // send APPEND command if (!$this->putLine($request)) { $this->setError(self::ERROR_COMMAND, "Failed to send APPEND command"); return false; } // Do not wait when LITERAL+ is supported if (!$literal_plus) { $line = $this->readReply(); if ($line[0] != '+') { $this->parseResult($line, 'APPEND: '); return false; } } foreach ($msg as $msg_part) { // file pointer if (is_resource($msg_part)) { rewind($msg_part); while (!feof($msg_part) && $this->fp) { $buffer = fread($msg_part, $chunk_size); $this->putLine($buffer, false); } fclose($msg_part); } // string else { $size = strlen($msg_part); // Break up the data by sending one chunk (up to 512k) at a time. // This approach reduces our peak memory usage for ($offset = 0; $offset < $size; $offset += $chunk_size) { $chunk = substr($msg_part, $offset, $chunk_size); if (!$this->putLine($chunk, false)) { return false; } } } } if (!$this->putLine('')) { // \r\n return false; } do { $line = $this->readLine(); } while (!$this->startsWith($line, $key, true, true)); // Clear internal status cache unset($this->data['STATUS:'.$mailbox]); if ($this->parseResult($line, 'APPEND: ') != self::ERROR_OK) { return false; } if (!empty($this->data['APPENDUID'])) { return $this->data['APPENDUID']; } return true; } /** * Handler for IMAP APPEND command. * * @param string $mailbox Mailbox name * @param string $path Path to the file with message body * @param string $headers Message headers * @param array $flags Message flags * @param string $date Message internal date * @param bool $binary Enable BINARY append (RFC3516) * * @return string|bool On success APPENDUID response (if available) or True, False on failure */ public function appendFromFile($mailbox, $path, $headers=null, $flags = array(), $date = null, $binary = false) { // open message file if (file_exists(realpath($path))) { $fp = fopen($path, 'r'); } if (!$fp) { $this->setError(self::ERROR_UNKNOWN, "Couldn't open $path for reading"); return false; } $message = array(); if ($headers) { $message[] = trim($headers, "\r\n") . "\r\n\r\n"; } $message[] = $fp; return $this->append($mailbox, $message, $flags, $date, $binary); } /** * Returns QUOTA information * * @param string $mailbox Mailbox name * * @return array Quota information */ public function getQuota($mailbox = null) { if ($mailbox === null || $mailbox === '') { $mailbox = 'INBOX'; } // a0001 GETQUOTAROOT INBOX // * QUOTAROOT INBOX user/sample // * QUOTA user/sample (STORAGE 654 9765) // a0001 OK Completed list($code, $response) = $this->execute('GETQUOTAROOT', array($this->escape($mailbox)), 0, '/^\* QUOTA /i'); $result = false; $min_free = PHP_INT_MAX; $all = array(); if ($code == self::ERROR_OK) { foreach (explode("\n", $response) as $line) { list(, , $quota_root) = $this->tokenizeResponse($line, 3); $quotas = $this->tokenizeResponse($line, 1); if (empty($quotas)) { continue; } foreach (array_chunk($quotas, 3) as $quota) { list($type, $used, $total) = $quota; $type = strtolower($type); if ($type && $total) { $all[$quota_root][$type]['used'] = intval($used); $all[$quota_root][$type]['total'] = intval($total); } } if (empty($all[$quota_root]['storage'])) { continue; } $used = $all[$quota_root]['storage']['used']; $total = $all[$quota_root]['storage']['total']; $free = $total - $used; // calculate lowest available space from all storage quotas if ($free < $min_free) { $min_free = $free; $result['used'] = $used; $result['total'] = $total; $result['percent'] = min(100, round(($used/max(1,$total))*100)); $result['free'] = 100 - $result['percent']; } } } if (!empty($result)) { $result['all'] = $all; } 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) * * @param string $mailbox Mailbox name * @param string $user User name * @param mixed $acl ACL string or array * * @return boolean True on success, False on failure * * @since 0.5-beta */ public function setACL($mailbox, $user, $acl) { if (is_array($acl)) { $acl = implode('', $acl); } $result = $this->execute('SETACL', array( $this->escape($mailbox), $this->escape($user), strtolower($acl)), self::COMMAND_NORESPONSE); return ($result == self::ERROR_OK); } /** * Send the DELETEACL command (RFC4314) * * @param string $mailbox Mailbox name * @param string $user User name * * @return boolean True on success, False on failure * * @since 0.5-beta */ public function deleteACL($mailbox, $user) { $result = $this->execute('DELETEACL', array( $this->escape($mailbox), $this->escape($user)), self::COMMAND_NORESPONSE); return ($result == self::ERROR_OK); } /** * Send the GETACL command (RFC4314) * * @param string $mailbox Mailbox name * * @return array User-rights array on success, NULL on error * @since 0.5-beta */ public function getACL($mailbox) { list($code, $response) = $this->execute('GETACL', array($this->escape($mailbox)), 0, '/^\* ACL /i'); if ($code == self::ERROR_OK && $response) { // Parse server response (remove "* ACL ") $response = substr($response, 6); $ret = $this->tokenizeResponse($response); $mbox = array_shift($ret); $size = count($ret); // Create user-rights hash array // @TODO: consider implementing fixACL() method according to RFC4314.2.1.1 // so we could return only standard rights defined in RFC4314, // excluding 'c' and 'd' defined in RFC2086. if ($size % 2 == 0) { for ($i=0; $i<$size; $i++) { $ret[$ret[$i]] = str_split($ret[++$i]); unset($ret[$i-1]); unset($ret[$i]); } return $ret; } $this->setError(self::ERROR_COMMAND, "Incomplete ACL response"); } } /** * Send the LISTRIGHTS command (RFC4314) * * @param string $mailbox Mailbox name * @param string $user User name * * @return array List of user rights * @since 0.5-beta */ public function listRights($mailbox, $user) { list($code, $response) = $this->execute('LISTRIGHTS', array($this->escape($mailbox), $this->escape($user)), 0, '/^\* LISTRIGHTS /i'); if ($code == self::ERROR_OK && $response) { // Parse server response (remove "* LISTRIGHTS ") $response = substr($response, 13); $ret_mbox = $this->tokenizeResponse($response, 1); $ret_user = $this->tokenizeResponse($response, 1); $granted = $this->tokenizeResponse($response, 1); $optional = trim($response); return array( 'granted' => str_split($granted), 'optional' => explode(' ', $optional), ); } } /** * Send the MYRIGHTS command (RFC4314) * * @param string $mailbox Mailbox name * * @return array MYRIGHTS response on success, NULL on error * @since 0.5-beta */ public function myRights($mailbox) { list($code, $response) = $this->execute('MYRIGHTS', array($this->escape($mailbox)), 0, '/^\* MYRIGHTS /i'); if ($code == self::ERROR_OK && $response) { // Parse server response (remove "* MYRIGHTS ") $response = substr($response, 11); $ret_mbox = $this->tokenizeResponse($response, 1); $rights = $this->tokenizeResponse($response, 1); return str_split($rights); } } /** * Send the SETMETADATA command (RFC5464) * * @param string $mailbox Mailbox name * @param array $entries Entry-value array (use NULL value as NIL) * * @return boolean True on success, False on failure * @since 0.5-beta */ public function setMetadata($mailbox, $entries) { if (!is_array($entries) || empty($entries)) { $this->setError(self::ERROR_COMMAND, "Wrong argument for SETMETADATA command"); return false; } foreach ($entries as $name => $value) { $entries[$name] = $this->escape($name) . ' ' . $this->escape($value, true); } $entries = implode(' ', $entries); $result = $this->execute('SETMETADATA', array( $this->escape($mailbox), '(' . $entries . ')'), self::COMMAND_NORESPONSE); return ($result == self::ERROR_OK); } /** * Send the SETMETADATA command with NIL values (RFC5464) * * @param string $mailbox Mailbox name * @param array $entries Entry names array * * @return boolean True on success, False on failure * * @since 0.5-beta */ public function deleteMetadata($mailbox, $entries) { if (!is_array($entries) && !empty($entries)) { $entries = explode(' ', $entries); } if (empty($entries)) { $this->setError(self::ERROR_COMMAND, "Wrong argument for SETMETADATA command"); return false; } foreach ($entries as $entry) { $data[$entry] = null; } return $this->setMetadata($mailbox, $data); } /** * Send the GETMETADATA command (RFC5464) * * @param string $mailbox Mailbox name * @param array $entries Entries * @param array $options Command options (with MAXSIZE and DEPTH keys) * * @return array GETMETADATA result on success, NULL on error * * @since 0.5-beta */ public function getMetadata($mailbox, $entries, $options=array()) { if (!is_array($entries)) { $entries = array($entries); } // create entries string foreach ($entries as $idx => $name) { $entries[$idx] = $this->escape($name); } $optlist = ''; $entlist = '(' . implode(' ', $entries) . ')'; // create options string if (is_array($options)) { $options = array_change_key_case($options, CASE_UPPER); $opts = array(); if (!empty($options['MAXSIZE'])) { $opts[] = 'MAXSIZE '.intval($options['MAXSIZE']); } if (!empty($options['DEPTH'])) { $opts[] = 'DEPTH '.intval($options['DEPTH']); } if ($opts) { $optlist = '(' . implode(' ', $opts) . ')'; } } $optlist .= ($optlist ? ' ' : '') . $entlist; list($code, $response) = $this->execute('GETMETADATA', array( $this->escape($mailbox), $optlist)); if ($code == self::ERROR_OK) { $result = array(); $data = $this->tokenizeResponse($response); // The METADATA response can contain multiple entries in a single // response or multiple responses for each entry or group of entries for ($i = 0, $size = count($data); $i < $size; $i++) { if ($data[$i] === '*' && $data[++$i] === 'METADATA' && is_string($mbox = $data[++$i]) && is_array($data[++$i]) ) { for ($x = 0, $size2 = count($data[$i]); $x < $size2; $x += 2) { if ($data[$i][$x+1] !== null) { $result[$mbox][$data[$i][$x]] = $data[$i][$x+1]; } } } } return $result; } } /** * Send the SETANNOTATION command (draft-daboo-imap-annotatemore) * * @param string $mailbox Mailbox name * @param array $data Data array where each item is an array with * three elements: entry name, attribute name, value * * @return boolean True on success, False on failure * @since 0.5-beta */ public function setAnnotation($mailbox, $data) { if (!is_array($data) || empty($data)) { $this->setError(self::ERROR_COMMAND, "Wrong argument for SETANNOTATION command"); return false; } foreach ($data as $entry) { // ANNOTATEMORE drafts before version 08 require quoted parameters $entries[] = sprintf('%s (%s %s)', $this->escape($entry[0], true), $this->escape($entry[1], true), $this->escape($entry[2], true)); } $entries = implode(' ', $entries); $result = $this->execute('SETANNOTATION', array( $this->escape($mailbox), $entries), self::COMMAND_NORESPONSE); return ($result == self::ERROR_OK); } /** * Send the SETANNOTATION command with NIL values (draft-daboo-imap-annotatemore) * * @param string $mailbox Mailbox name * @param array $data Data array where each item is an array with * two elements: entry name and attribute name * * @return boolean True on success, False on failure * * @since 0.5-beta */ public function deleteAnnotation($mailbox, $data) { if (!is_array($data) || empty($data)) { $this->setError(self::ERROR_COMMAND, "Wrong argument for SETANNOTATION command"); return false; } return $this->setAnnotation($mailbox, $data); } /** * Send the GETANNOTATION command (draft-daboo-imap-annotatemore) * * @param string $mailbox Mailbox name * @param array $entries Entries names * @param array $attribs Attribs names * * @return array Annotations result on success, NULL on error * * @since 0.5-beta */ public function getAnnotation($mailbox, $entries, $attribs) { if (!is_array($entries)) { $entries = array($entries); } // create entries string // ANNOTATEMORE drafts before version 08 require quoted parameters foreach ($entries as $idx => $name) { $entries[$idx] = $this->escape($name, true); } $entries = '(' . implode(' ', $entries) . ')'; if (!is_array($attribs)) { $attribs = array($attribs); } // create attributes string foreach ($attribs as $idx => $name) { $attribs[$idx] = $this->escape($name, true); } $attribs = '(' . implode(' ', $attribs) . ')'; list($code, $response) = $this->execute('GETANNOTATION', array( $this->escape($mailbox), $entries, $attribs)); if ($code == self::ERROR_OK) { $result = array(); $data = $this->tokenizeResponse($response); // Here we returns only data compatible with METADATA result format if (!empty($data) && ($size = count($data))) { for ($i=0; $i<$size; $i++) { $entry = $data[$i]; if (isset($mbox) && is_array($entry)) { $attribs = $entry; $entry = $last_entry; } else if ($entry == '*') { if ($data[$i+1] == 'ANNOTATION') { $mbox = $data[$i+2]; unset($data[$i]); // "*" unset($data[++$i]); // "ANNOTATION" unset($data[++$i]); // Mailbox } // get rid of other untagged responses else { unset($mbox); unset($data[$i]); } continue; } else if (isset($mbox)) { $attribs = $data[++$i]; } else { unset($data[$i]); continue; } if (!empty($attribs)) { for ($x=0, $len=count($attribs); $x<$len;) { $attr = $attribs[$x++]; $value = $attribs[$x++]; if ($attr == 'value.priv' && $value !== null) { $result[$mbox]['/private' . $entry] = $value; } else if ($attr == 'value.shared' && $value !== null) { $result[$mbox]['/shared' . $entry] = $value; } } } $last_entry = $entry; unset($data[$i]); } } return $result; } } /** * Returns BODYSTRUCTURE for the specified message. * * @param string $mailbox Folder name * @param int $id Message sequence number or UID * @param bool $is_uid True if $id is an UID * * @return array/bool Body structure array or False on error. * @since 0.6 */ public function getStructure($mailbox, $id, $is_uid = false) { $result = $this->fetch($mailbox, $id, $is_uid, array('BODYSTRUCTURE')); if (is_array($result)) { $result = array_shift($result); return $result->bodystructure; } return false; } /** * Returns data of a message part according to specified structure. * * @param array $structure Message structure (getStructure() result) * @param string $part Message part identifier * * @return array Part data as hash array (type, encoding, charset, size) */ public static function getStructurePartData($structure, $part) { $part_a = self::getStructurePartArray($structure, $part); $data = array(); if (empty($part_a)) { return $data; } // content-type if (is_array($part_a[0])) { $data['type'] = 'multipart'; } else { $data['type'] = strtolower($part_a[0]); $data['subtype'] = strtolower($part_a[1]); $data['encoding'] = strtolower($part_a[5]); // charset if (is_array($part_a[2])) { foreach ($part_a[2] as $key => $val) { if (strcasecmp($val, 'charset') == 0) { $data['charset'] = $part_a[2][$key+1]; break; } } } } // size $data['size'] = intval($part_a[6]); return $data; } public static function getStructurePartArray($a, $part) { if (!is_array($a)) { return false; } if (empty($part)) { return $a; } $ctype = is_string($a[0]) && is_string($a[1]) ? $a[0] . '/' . $a[1] : ''; if (strcasecmp($ctype, 'message/rfc822') == 0) { $a = $a[8]; } if (strpos($part, '.') > 0) { $orig_part = $part; $pos = strpos($part, '.'); $rest = substr($orig_part, $pos+1); $part = substr($orig_part, 0, $pos); return self::getStructurePartArray($a[$part-1], $rest); } else if ($part > 0) { return (is_array($a[$part-1])) ? $a[$part-1] : $a; } } /** * Creates next command identifier (tag) * * @return string Command identifier * @since 0.5-beta */ public function nextTag() { $this->cmd_num++; $this->cmd_tag = sprintf('A%04d', $this->cmd_num); return $this->cmd_tag; } /** * Sends IMAP command and parses result * * @param string $command IMAP command * @param array $arguments Command arguments * @param int $options Execution options * @param string $filter Line filter (regexp) * * @return mixed Response code or list of response code and data * @since 0.5-beta */ public function execute($command, $arguments = array(), $options = 0, $filter = null) { $tag = $this->nextTag(); $query = $tag . ' ' . $command; $noresp = ($options & self::COMMAND_NORESPONSE); $response = $noresp ? null : ''; if (!empty($arguments)) { foreach ($arguments as $arg) { $query .= ' ' . self::r_implode($arg); } } // Send command if (!$this->putLineC($query, true, ($options & self::COMMAND_ANONYMIZED))) { preg_match('/^[A-Z0-9]+ ((UID )?[A-Z]+)/', $query, $matches); $cmd = $matches[1] ?: 'UNKNOWN'; $this->setError(self::ERROR_COMMAND, "Failed to send $cmd command"); return $noresp ? self::ERROR_COMMAND : array(self::ERROR_COMMAND, ''); } // Parse response do { $line = $this->readLine(4096); if ($response !== null) { // TODO: Better string literals handling with filter if (!$filter || preg_match($filter, $line)) { $response .= $line; } } // parse untagged response for [COPYUID 1204196876 3456:3457 123:124] (RFC6851) if ($line && $command == 'UID MOVE') { if (preg_match("/^\* OK \[COPYUID [0-9]+ ([0-9,:]+) ([0-9,:]+)\]/i", $line, $m)) { $this->data['COPYUID'] = array($m[1], $m[2]); } } } while (!$this->startsWith($line, $tag . ' ', true, true)); $code = $this->parseResult($line, $command . ': '); // Remove last line from response if ($response) { if (!$filter) { $line_len = min(strlen($response), strlen($line)); $response = substr($response, 0, -$line_len); } $response = rtrim($response, "\r\n"); } // optional CAPABILITY response if (($options & self::COMMAND_CAPABILITY) && $code == self::ERROR_OK && preg_match('/\[CAPABILITY ([^]]+)\]/i', $line, $matches) ) { $this->parseCapability($matches[1], true); } // return last line only (without command tag, result and response code) if ($line && ($options & self::COMMAND_LASTLINE)) { $response = preg_replace("/^$tag (OK|NO|BAD|BYE|PREAUTH)?\s*(\[[a-z-]+\])?\s*/i", '', trim($line)); } return $noresp ? $code : array($code, $response); } /** * Splits IMAP response into string tokens * * @param string &$str The IMAP's server response * @param int $num Number of tokens to return * * @return mixed Tokens array or string if $num=1 * @since 0.5-beta */ public static function tokenizeResponse(&$str, $num=0) { $result = array(); while (!$num || count($result) < $num) { // remove spaces from the beginning of the string $str = ltrim($str); // empty string if ($str === '' || $str === null) { break; } switch ($str[0]) { // String literal case '{': if (($epos = strpos($str, "}\r\n", 1)) == false) { // error } if (!is_numeric(($bytes = substr($str, 1, $epos - 1)))) { // error } $result[] = $bytes ? substr($str, $epos + 3, $bytes) : ''; $str = substr($str, $epos + 3 + $bytes); break; // Quoted string case '"': $len = strlen($str); for ($pos=1; $pos<$len; $pos++) { if ($str[$pos] == '"') { break; } if ($str[$pos] == "\\") { if ($str[$pos + 1] == '"' || $str[$pos + 1] == "\\") { $pos++; } } } // we need to strip slashes for a quoted string $result[] = stripslashes(substr($str, 1, $pos - 1)); $str = substr($str, $pos + 1); break; // Parenthesized list case '(': $str = substr($str, 1); $result[] = self::tokenizeResponse($str); break; case ')': $str = substr($str, 1); return $result; // String atom, number, astring, NIL, *, % default: // excluded chars: SP, CTL, ), DEL // we do not exclude [ and ] (#1489223) if (preg_match('/^([^\x00-\x20\x29\x7F]+)/', $str, $m)) { $result[] = $m[1] == 'NIL' ? null : $m[1]; $str = substr($str, strlen($m[1])); } break; } } return $num == 1 ? $result[0] : $result; } /** * Joins IMAP command line elements (recursively) */ protected static function r_implode($element) { $string = ''; if (is_array($element)) { reset($element); foreach ($element as $value) { $string .= ' ' . self::r_implode($value); } } else { return $element; } return '(' . trim($string) . ')'; } /** * Converts message identifiers array into sequence-set syntax * * @param array $messages Message identifiers * @param bool $force Forces compression of any size * * @return string Compressed sequence-set */ public static function compressMessageSet($messages, $force=false) { // given a comma delimited list of independent mid's, // compresses by grouping sequences together if (!is_array($messages)) { // if less than 255 bytes long, let's not bother if (!$force && strlen($messages) < 255) { return preg_match('/[^0-9:,*]/', $messages) ? 'INVALID' : $messages; } // see if it's already been compressed if (strpos($messages, ':') !== false) { return preg_match('/[^0-9:,*]/', $messages) ? 'INVALID' : $messages; } // separate, then sort $messages = explode(',', $messages); } sort($messages); $result = array(); $start = $prev = $messages[0]; foreach ($messages as $id) { $incr = $id - $prev; if ($incr > 1) { // found a gap if ($start == $prev) { $result[] = $prev; // push single id } else { $result[] = $start . ':' . $prev; // push sequence as start_id:end_id } $start = $id; // start of new sequence } $prev = $id; } // handle the last sequence/id if ($start == $prev) { $result[] = $prev; } else { $result[] = $start.':'.$prev; } // return as comma separated string $result = implode(',', $result); return preg_match('/[^0-9:,*]/', $result) ? 'INVALID' : $result; } /** * Converts message sequence-set into array * * @param string $messages Message identifiers * * @return array List of message identifiers */ public static function uncompressMessageSet($messages) { if (empty($messages)) { return array(); } $result = array(); $messages = explode(',', $messages); foreach ($messages as $idx => $part) { $items = explode(':', $part); $max = max($items[0], $items[1]); for ($x=$items[0]; $x<=$max; $x++) { $result[] = (int)$x; } unset($messages[$idx]); } return $result; } /** * Clear internal status cache */ protected function clear_status_cache($mailbox) { unset($this->data['STATUS:' . $mailbox]); $keys = array('EXISTS', 'RECENT', 'UNSEEN', 'UID-MAP'); foreach ($keys as $key) { unset($this->data[$key]); } } /** * Clear internal cache of the current mailbox */ protected function clear_mailbox_cache() { $this->clear_status_cache($this->selected); $keys = array('UIDNEXT', 'UIDVALIDITY', 'HIGHESTMODSEQ', 'NOMODSEQ', 'PERMANENTFLAGS', 'QRESYNC', 'VANISHED', 'READ-WRITE'); foreach ($keys as $key) { unset($this->data[$key]); } } /** * Converts flags array into string for inclusion in IMAP command * * @param array $flags Flags (see self::flags) * * @return string Space-separated list of flags */ protected function flagsToStr($flags) { foreach ((array)$flags as $idx => $flag) { if ($flag = $this->flags[strtoupper($flag)]) { $flags[$idx] = $flag; } } return implode(' ', (array)$flags); } /** * CAPABILITY response parser */ protected function parseCapability($str, $trusted=false) { $str = preg_replace('/^\* CAPABILITY /i', '', $str); $this->capability = explode(' ', strtoupper($str)); if (!empty($this->prefs['disabled_caps'])) { $this->capability = array_diff($this->capability, $this->prefs['disabled_caps']); } if (!isset($this->prefs['literal+']) && in_array('LITERAL+', $this->capability)) { $this->prefs['literal+'] = true; } if ($trusted) { $this->capability_readed = true; } } /** * Escapes a string when it contains special characters (RFC3501) * * @param string $string IMAP string * @param boolean $force_quotes Forces string quoting (for atoms) * * @return string String atom, quoted-string or string literal * @todo lists */ public static function escape($string, $force_quotes=false) { if ($string === null) { return 'NIL'; } if ($string === '') { return '""'; } // atom-string (only safe characters) if (!$force_quotes && !preg_match('/[\x00-\x20\x22\x25\x28-\x2A\x5B-\x5D\x7B\x7D\x80-\xFF]/', $string)) { return $string; } // quoted-string if (!preg_match('/[\r\n\x00\x80-\xFF]/', $string)) { return '"' . addcslashes($string, '\\"') . '"'; } // literal-string return sprintf("{%d}\r\n%s", strlen($string), $string); } /** * Set the value of the debugging flag. * * @param boolean $debug New value for the debugging flag. * @param callback $handler Logging handler function * * @since 0.5-stable */ public function setDebug($debug, $handler = null) { $this->debug = $debug; $this->debug_handler = $handler; } /** * Write the given debug text to the current debug output handler. * * @param string $message Debug message text. * * @since 0.5-stable */ protected function debug($message) { if (($len = strlen($message)) > self::DEBUG_LINE_LENGTH) { $diff = $len - self::DEBUG_LINE_LENGTH; $message = substr($message, 0, self::DEBUG_LINE_LENGTH) . "... [truncated $diff bytes]"; } if ($this->resourceid) { $message = sprintf('[%s] %s', $this->resourceid, $message); } if ($this->debug_handler) { call_user_func_array($this->debug_handler, array($this, $message)); } else { echo "DEBUG: $message\n"; } } } diff --git a/src/tests/Browser/StatusTest.php b/src/tests/Browser/StatusTest.php index d49e1bbd..5897dc92 100644 --- a/src/tests/Browser/StatusTest.php +++ b/src/tests/Browser/StatusTest.php @@ -1,289 +1,287 @@ first(); if ($domain->isConfirmed()) { $domain->status ^= Domain::STATUS_CONFIRMED; $domain->save(); } $john = $this->getTestUser('john@kolab.org'); $john->created_at = Carbon::now(); if ($john->isImapReady()) { $john->status ^= User::STATUS_IMAP_READY; } $john->save(); $this->browse(function ($browser) use ($john, $domain) { $browser->visit(new Home()) ->submitLogon('john@kolab.org', 'simple123', true) ->on(new Dashboard()) ->with(new Status(), function ($browser) use ($john) { $browser->assertSeeIn('@body', 'We are preparing your account') ->assertProgress(71, 'Creating a mailbox...', 'pending') ->assertMissing('#status-verify') ->assertMissing('#status-link') ->assertMissing('@refresh-button') ->assertMissing('@refresh-text'); $john->status |= User::STATUS_IMAP_READY; $john->save(); // Wait for auto-refresh, expect domain-confirmed step $browser->pause(6000) ->assertSeeIn('@body', 'Your account is almost ready') ->assertProgress(85, 'Verifying an ownership of a custom domain...', 'failed') ->assertMissing('@refresh-button') ->assertMissing('@refresh-text') ->assertMissing('#status-verify') ->assertVisible('#status-link'); }) // check if the link to domain info page works ->click('#status-link') ->on(new DomainInfo()) ->back() ->on(new Dashboard()) ->with(new Status(), function ($browser) { $browser->assertMissing('@refresh-button') ->assertProgress(85, 'Verifying an ownership of a custom domain...', 'failed'); }); // Confirm the domain and wait until the whole status box disappears $domain->status |= Domain::STATUS_CONFIRMED; $domain->save(); // This should take less than 10 seconds $browser->waitUntilMissing('@status', 10); }); - // Test the Refresh button + // Test the Refresh button if ($domain->isConfirmed()) { $domain->status ^= Domain::STATUS_CONFIRMED; $domain->save(); } $john->created_at = Carbon::now()->subSeconds(3600); if ($john->isImapReady()) { $john->status ^= User::STATUS_IMAP_READY; } $john->save(); $this->browse(function ($browser) use ($john, $domain) { $browser->visit(new Dashboard()) ->with(new Status(), function ($browser) use ($john, $domain) { $browser->assertSeeIn('@body', 'We are preparing your account') ->assertProgress(71, 'Creating a mailbox...', 'failed') ->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); }); } /** * Test domain status on domains list and domain info page * * @depends testDashboard */ public function testDomainStatus(): void { $domain = Domain::where('namespace', 'kolab.org')->first(); $domain->created_at = Carbon::now(); $domain->status = Domain::STATUS_NEW | Domain::STATUS_ACTIVE | Domain::STATUS_LDAP_READY; $domain->save(); // side-step $this->assertFalse($domain->isNew()); $this->assertTrue($domain->isActive()); $this->assertTrue($domain->isLdapReady()); $this->assertTrue($domain->isExternal()); $this->assertFalse($domain->isHosted()); $this->assertFalse($domain->isConfirmed()); $this->assertFalse($domain->isVerified()); $this->assertFalse($domain->isSuspended()); $this->assertFalse($domain->isDeleted()); $this->browse(function ($browser) use ($domain) { // Test auto-refresh $browser->on(new Dashboard()) ->click('@links a.link-domains') ->on(new DomainList()) ->waitFor('@table tbody tr') // Assert domain status icon ->assertVisible('@table tbody tr:first-child td:first-child svg.fa-globe.text-danger') ->assertText('@table tbody tr:first-child td:first-child svg title', 'Not Ready') ->click('@table tbody tr:first-child td:first-child a') ->on(new DomainInfo()) ->with(new Status(), function ($browser) { $browser->assertSeeIn('@body', 'We are preparing the domain') ->assertProgress(50, 'Verifying a custom domain...', 'pending') ->assertMissing('@refresh-button') ->assertMissing('@refresh-text') ->assertMissing('#status-link') ->assertMissing('#status-verify'); }); $domain->status |= Domain::STATUS_VERIFIED; $domain->save(); // This should take less than 10 seconds $browser->waitFor('@status.process-failed') ->with(new Status(), function ($browser) { $browser->assertSeeIn('@body', 'The domain is almost ready') ->assertProgress(75, 'Verifying an ownership of a custom domain...', 'failed') ->assertMissing('@refresh-button') ->assertMissing('@refresh-text') ->assertMissing('#status-link') ->assertVisible('#status-verify'); }); $domain->status |= Domain::STATUS_CONFIRMED; $domain->save(); // Test Verify button $browser->click('@status #status-verify') ->assertToast(Toast::TYPE_SUCCESS, 'Domain verified successfully.') ->waitUntilMissing('@status') ->waitUntilMissing('@verify') ->assertVisible('@config'); }); } /** * Test user status on users list and user info page * * @depends testDashboard */ public function testUserStatus(): void { $john = $this->getTestUser('john@kolab.org'); $john->created_at = Carbon::now(); if ($john->isImapReady()) { $john->status ^= User::STATUS_IMAP_READY; } $john->save(); $domain = Domain::where('namespace', 'kolab.org')->first(); if ($domain->isConfirmed()) { $domain->status ^= Domain::STATUS_CONFIRMED; $domain->save(); } $this->browse(function ($browser) use ($john, $domain) { $browser->visit(new Dashboard()) ->click('@links a.link-users') ->on(new UserList()) ->waitFor('@table tbody tr') // Assert user status icons ->assertVisible('@table tbody tr:first-child td:first-child svg.fa-user.text-success') ->assertText('@table tbody tr:first-child td:first-child svg title', 'Active') ->assertVisible('@table tbody tr:nth-child(3) td:first-child svg.fa-user.text-danger') ->assertText('@table tbody tr:nth-child(3) td:first-child svg title', 'Not Ready') ->click('@table tbody tr:nth-child(3) td:first-child a') ->on(new UserInfo()) ->with('@form', function (Browser $browser) { // Assert state in the user edit form $browser->assertSeeIn('div.row:nth-child(1) label', 'Status') ->assertSeeIn('div.row:nth-child(1) #status', 'Not Ready'); }) ->with(new Status(), function ($browser) use ($john) { $browser->assertSeeIn('@body', 'We are preparing the user account') ->assertProgress(71, 'Creating a mailbox...', 'pending') ->assertMissing('#status-verify') ->assertMissing('#status-link') ->assertMissing('@refresh-button') ->assertMissing('@refresh-text'); $john->status |= User::STATUS_IMAP_READY; $john->save(); // Wait for auto-refresh, expect domain-confirmed step $browser->pause(6000) ->assertSeeIn('@body', 'The user account is almost ready') ->assertProgress(85, 'Verifying an ownership of a custom domain...', 'failed') ->assertMissing('@refresh-button') ->assertMissing('@refresh-text') ->assertMissing('#status-verify') ->assertVisible('#status-link'); }) ->assertSeeIn('#status', 'Active'); // Confirm the domain and wait until the whole status box disappears $domain->status |= Domain::STATUS_CONFIRMED; $domain->save(); // This should take less than 10 seconds $browser->waitUntilMissing('@status', 10); }); } } diff --git a/src/tests/Feature/Backends/IMAPTest.php b/src/tests/Feature/Backends/IMAPTest.php index c28241de..d167a062 100644 --- a/src/tests/Feature/Backends/IMAPTest.php +++ b/src/tests/Feature/Backends/IMAPTest.php @@ -1,41 +1,276 @@ 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) * * @group imap */ public function testVerifyAccountExisting(): void { // existing user $result = IMAP::verifyAccount('john@kolab.org'); $this->assertTrue($result); // non-existing user $this->expectException(\Exception::class); IMAP::verifyAccount('non-existing@domain.tld'); } /** * Test verifying IMAP shared folder existence * * @group imap */ public function testVerifySharedFolder(): void { // non-existing $result = IMAP::verifySharedFolder('shared/Resources/UnknownResource@kolab.org'); $this->assertFalse($result); // existing $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/Backends/RoundcubeTest.php b/src/tests/Feature/Backends/RoundcubeTest.php index 2741176c..c53fe241 100644 --- a/src/tests/Feature/Backends/RoundcubeTest.php +++ b/src/tests/Feature/Backends/RoundcubeTest.php @@ -1,61 +1,67 @@ deleteTestUser('roundcube@' . \config('app.domain')); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('roundcube@' . \config('app.domain')); parent::tearDown(); } /** * Test creating a Roundcube user record (and related data) * * @group roundcube */ - public function testUserCreation(): void + public function testUserCreationAndDeletion(): void { $user = $this->getTestUser('roundcube@' . \config('app.domain')); $user->setSetting('first_name', 'First'); $user->setSetting('last_name', 'Last'); $db = Roundcube::dbh(); // delete the user record if exists if ($userid = Roundcube::userId($user->email, false)) { $db->table('users')->delete(); } // Create the user $userid = Roundcube::userId($user->email); $rcuser = $db->table('users')->where('username', $user->email)->first(); $this->assertTrue(!empty($rcuser)); $rcidentity = $db->table('identities')->where('user_id', $rcuser->user_id)->first(); $this->assertSame($user->email, $rcidentity->email); $this->assertSame('First Last', $rcidentity->name); $this->assertSame(1, $rcidentity->standard); + + // Delete the user + Roundcube::deleteUser($user->email); + + $this->assertNull($db->table('users')->where('username', $user->email)->first()); + $this->assertNull($db->table('identities')->where('user_id', $rcuser->user_id)->first()); } } diff --git a/src/tests/Feature/Console/User/StatusTest.php b/src/tests/Feature/Console/User/StatusTest.php index 20735cfa..092323ac 100644 --- a/src/tests/Feature/Console/User/StatusTest.php +++ b/src/tests/Feature/Console/User/StatusTest.php @@ -1,61 +1,68 @@ deleteTestUser('user@force-delete.com'); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('user@force-delete.com'); parent::tearDown(); } /** * Test command runs */ public function testHandle(): void { Queue::fake(); // Non-existing user $code = \Artisan::call("user:status unknown"); $output = trim(\Artisan::output()); $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 $code = \Artisan::call("user:status {$user->email}"); $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 index 4740d9e2..1d8d1ea9 100644 --- a/src/tests/Feature/Controller/AuthTest.php +++ b/src/tests/Feature/Controller/AuthTest.php @@ -1,330 +1,330 @@ app['auth']->guard($guard); if ($guard instanceof \Illuminate\Auth\SessionGuard) { $guard->logout(); } } $protectedProperty = new \ReflectionProperty($this->app['auth'], 'guards'); $protectedProperty->setAccessible(true); $protectedProperty->setValue($this->app['auth'], []); } /** * {@inheritDoc} */ public function setUp(): void { parent::setUp(); $this->deleteTestUser('UsersControllerTest1@userscontroller.com'); $this->deleteTestDomain('userscontroller.com'); $this->expectedExpiry = \config('auth.token_expiry_minutes') * 60; \App\IP4Net::where('net_number', inet_pton('127.0.0.0'))->delete(); $user = $this->getTestUser('john@kolab.org'); $user->setSetting('limit_geo', null); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('UsersControllerTest1@userscontroller.com'); $this->deleteTestDomain('userscontroller.com'); \App\IP4Net::where('net_number', inet_pton('127.0.0.0'))->delete(); $user = $this->getTestUser('john@kolab.org'); $user->setSetting('limit_geo', null); parent::tearDown(); } /** * Test fetching current user info (/api/auth/info) */ 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, ]); $response = $this->get("api/auth/info"); $response->assertStatus(401); $response = $this->actingAs($user)->get("api/auth/info"); $response->assertStatus(200); $json = $response->json(); $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'])); // Note: Details of the content are tested in testUserResponse() // Test token refresh via the info request // First we log in to get the refresh token $post = ['email' => 'john@kolab.org', 'password' => 'simple123']; $user = $this->getTestUser('john@kolab.org'); $response = $this->post("api/auth/login", $post); $json = $response->json(); $response = $this->actingAs($user) ->post("api/auth/info?refresh=1", ['refresh_token' => $json['refresh_token']]); $response->assertStatus(200); $json = $response->json(); $this->assertEquals('john@kolab.org', $json['email']); $this->assertTrue(is_array($json['statusInfo'])); $this->assertTrue(is_array($json['settings'])); $this->assertTrue(!empty($json['access_token'])); $this->assertTrue(!empty($json['expires_in'])); } /** * Test fetching current user location (/api/auth/location) */ public function testLocation(): void { $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); // Authentication required $response = $this->get("api/auth/location"); $response->assertStatus(401); $headers = ['X-Client-IP' => '127.0.0.2']; $response = $this->actingAs($user)->withHeaders($headers)->get("api/auth/location"); $response->assertStatus(200); $json = $response->json(); $this->assertSame('127.0.0.2', $json['ipAddress']); $this->assertSame('', $json['countryCode']); \App\IP4Net::create([ 'net_number' => '127.0.0.0', 'net_broadcast' => '127.255.255.255', 'net_mask' => 8, 'country' => 'US', 'rir_name' => 'test', 'serial' => 1, ]); $response = $this->actingAs($user)->withHeaders($headers)->get("api/auth/location"); $response->assertStatus(200); $json = $response->json(); $this->assertSame('127.0.0.2', $json['ipAddress']); $this->assertSame('US', $json['countryCode']); } /** * Test /api/auth/login */ public function testLogin(): string { $user = $this->getTestUser('john@kolab.org'); // Request with no data $response = $this->post("api/auth/login", []); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertArrayHasKey('email', $json['errors']); $this->assertArrayHasKey('password', $json['errors']); // Request with invalid password $post = ['email' => 'john@kolab.org', 'password' => 'wrong']; $response = $this->post("api/auth/login", $post); $response->assertStatus(401); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame('Invalid username or password.', $json['message']); // Valid user+password $post = ['email' => 'john@kolab.org', 'password' => 'simple123']; $response = $this->post("api/auth/login", $post); $json = $response->json(); $response->assertStatus(200); $this->assertTrue(!empty($json['access_token'])); $this->assertTrue( ($this->expectedExpiry - 5) < $json['expires_in'] && $json['expires_in'] < ($this->expectedExpiry + 5) ); $this->assertEquals('bearer', $json['token_type']); $this->assertEquals($user->id, $json['id']); $this->assertEquals($user->email, $json['email']); $this->assertTrue(is_array($json['statusInfo'])); $this->assertTrue(is_array($json['settings'])); // Valid user+password (upper-case) $post = ['email' => 'John@Kolab.org', 'password' => 'simple123']; $response = $this->post("api/auth/login", $post); $json = $response->json(); $response->assertStatus(200); $this->assertTrue(!empty($json['access_token'])); $this->assertTrue( ($this->expectedExpiry - 5) < $json['expires_in'] && $json['expires_in'] < ($this->expectedExpiry + 5) ); $this->assertEquals('bearer', $json['token_type']); // TODO: We have browser tests for 2FA but we should probably also test it here return $json['access_token']; } /** * Test /api/auth/login with geo-lockin */ public function testLoginGeoLock(): void { $user = $this->getTestUser('john@kolab.org'); $user->setConfig(['limit_geo' => ['US']]); $headers['X-Client-IP'] = '127.0.0.2'; $post = ['email' => 'john@kolab.org', 'password' => 'simple123']; $response = $this->withHeaders($headers)->post("api/auth/login", $post); $response->assertStatus(401); $json = $response->json(); $this->assertSame("Invalid username or password.", $json['message']); $this->assertSame('error', $json['status']); \App\IP4Net::create([ 'net_number' => '127.0.0.0', 'net_broadcast' => '127.255.255.255', 'net_mask' => 8, 'country' => 'US', 'rir_name' => 'test', 'serial' => 1, ]); $response = $this->withHeaders($headers)->post("api/auth/login", $post); $response->assertStatus(200); $json = $response->json(); $this->assertTrue(!empty($json['access_token'])); $this->assertEquals($user->id, $json['id']); } /** * Test /api/auth/logout * * @depends testLogin */ public function testLogout($token): void { // Request with no token, testing that it requires auth $response = $this->post("api/auth/logout"); $response->assertStatus(401); // Test the same using JSON mode $response = $this->json('POST', "api/auth/logout", []); $response->assertStatus(401); // Request with invalid token $response = $this->withHeaders(['Authorization' => 'Bearer ' . "foobar"])->post("api/auth/logout"); $response->assertStatus(401); // Request with valid token $response = $this->withHeaders(['Authorization' => 'Bearer ' . $token])->post("api/auth/logout"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals('success', $json['status']); $this->assertEquals('Successfully logged out.', $json['message']); $this->resetAuth(); // Check if it really destroyed the token? $response = $this->withHeaders(['Authorization' => 'Bearer ' . $token])->get("api/auth/info"); $response->assertStatus(401); } /** * Test /api/auth/refresh */ public function testRefresh(): void { // Request with no token, testing that it requires auth $response = $this->post("api/auth/refresh"); $response->assertStatus(401); // Test the same using JSON mode $response = $this->json('POST', "api/auth/refresh", []); $response->assertStatus(401); // Login the user to get a valid token $post = ['email' => 'john@kolab.org', 'password' => 'simple123']; $response = $this->post("api/auth/login", $post); $response->assertStatus(200); $json = $response->json(); $token = $json['access_token']; $user = $this->getTestUser('john@kolab.org'); // Request with a valid token $response = $this->actingAs($user)->post("api/auth/refresh", ['refresh_token' => $json['refresh_token']]); $response->assertStatus(200); $json = $response->json(); $this->assertTrue(!empty($json['access_token'])); $this->assertTrue($json['access_token'] != $token); $this->assertTrue( ($this->expectedExpiry - 5) < $json['expires_in'] && $json['expires_in'] < ($this->expectedExpiry + 5) ); $this->assertEquals('bearer', $json['token_type']); $new_token = $json['access_token']; // TODO: Shall we invalidate the old token? // And if the new token is working $response = $this->withHeaders(['Authorization' => 'Bearer ' . $new_token])->get("api/auth/info"); $response->assertStatus(200); } } diff --git a/src/tests/Feature/Controller/ResourcesTest.php b/src/tests/Feature/Controller/ResourcesTest.php index 7dce425f..b65e33af 100644 --- a/src/tests/Feature/Controller/ResourcesTest.php +++ b/src/tests/Feature/Controller/ResourcesTest.php @@ -1,531 +1,538 @@ deleteTestResource('resource-test@kolab.org'); Resource::where('name', 'Test Resource')->delete(); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestResource('resource-test@kolab.org'); Resource::where('name', 'Test Resource')->delete(); parent::tearDown(); } /** * Test resource deleting (DELETE /api/v4/resources/) */ public function testDestroy(): void { // First create some groups to delete $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $resource = $this->getTestResource('resource-test@kolab.org'); $resource->assignToWallet($john->wallets->first()); // Test unauth access $response = $this->delete("api/v4/resources/{$resource->id}"); $response->assertStatus(401); // Test non-existing resource $response = $this->actingAs($john)->delete("api/v4/resources/abc"); $response->assertStatus(404); // Test access to other user's resource $response = $this->actingAs($jack)->delete("api/v4/resources/{$resource->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 resource $response = $this->actingAs($john)->delete("api/v4/resources/{$resource->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals('success', $json['status']); $this->assertEquals("Resource deleted successfully.", $json['message']); } /** * Test resources listing (GET /api/v4/resources) */ 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/resources"); $response->assertStatus(401); // Test a user with no resources $response = $this->actingAs($jack)->get("/api/v4/resources"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(4, $json); $this->assertSame(0, $json['count']); $this->assertSame(false, $json['hasMore']); $this->assertSame("0 resources have been found.", $json['message']); $this->assertSame([], $json['list']); // Test a user with two resources $response = $this->actingAs($john)->get("/api/v4/resources"); $response->assertStatus(200); $json = $response->json(); $resource = Resource::where('name', 'Conference Room #1')->first(); $this->assertCount(4, $json); $this->assertSame(2, $json['count']); $this->assertSame(false, $json['hasMore']); $this->assertSame("2 resources have been found.", $json['message']); $this->assertCount(2, $json['list']); $this->assertSame($resource->id, $json['list'][0]['id']); $this->assertSame($resource->email, $json['list'][0]['email']); $this->assertSame($resource->name, $json['list'][0]['name']); $this->assertArrayHasKey('isDeleted', $json['list'][0]); $this->assertArrayHasKey('isActive', $json['list'][0]); $this->assertArrayHasKey('isLdapReady', $json['list'][0]); $this->assertArrayHasKey('isImapReady', $json['list'][0]); // Test that another wallet controller has access to resources $response = $this->actingAs($ned)->get("/api/v4/resources"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(4, $json); $this->assertSame(2, $json['count']); $this->assertSame(false, $json['hasMore']); $this->assertSame("2 resources have been found.", $json['message']); $this->assertCount(2, $json['list']); $this->assertSame($resource->email, $json['list'][0]['email']); } /** * Test resource config update (POST /api/v4/resources//config) */ public function testSetConfig(): void { $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $resource = $this->getTestResource('resource-test@kolab.org'); $resource->assignToWallet($john->wallets->first()); // Test unknown resource id $post = ['invitation_policy' => 'reject']; $response = $this->actingAs($john)->post("/api/v4/resources/123/config", $post); $json = $response->json(); $response->assertStatus(404); // Test access by user not being a wallet controller $post = ['invitation_policy' => 'reject']; $response = $this->actingAs($jack)->post("/api/v4/resources/{$resource->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/resources/{$resource->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']); $resource->refresh(); $this->assertNull($resource->getSetting('test')); $this->assertNull($resource->getSetting('invitation_policy')); // Test some valid data $post = ['invitation_policy' => 'reject']; $response = $this->actingAs($john)->post("/api/v4/resources/{$resource->id}/config", $post); $response->assertStatus(200); $json = $response->json(); $this->assertCount(2, $json); $this->assertSame('success', $json['status']); $this->assertSame("Resource settings updated successfully.", $json['message']); $this->assertSame(['invitation_policy' => 'reject'], $resource->fresh()->getConfig()); // Test input validation $post = ['invitation_policy' => 'aaa']; $response = $this->actingAs($john)->post("/api/v4/resources/{$resource->id}/config", $post); $response->assertStatus(422); $json = $response->json(); $this->assertCount(2, $json); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertSame( "The specified invitation policy is invalid.", $json['errors']['invitation_policy'] ); $this->assertSame(['invitation_policy' => 'reject'], $resource->fresh()->getConfig()); } /** * Test fetching resource data/profile (GET /api/v4/resources/) */ public function testShow(): void { $jack = $this->getTestUser('jack@kolab.org'); $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $resource = $this->getTestResource('resource-test@kolab.org'); $resource->assignToWallet($john->wallets->first()); $resource->setSetting('invitation_policy', 'reject'); // Test unauthorized access to a profile of other user $response = $this->get("/api/v4/resources/{$resource->id}"); $response->assertStatus(401); // Test unauthorized access to a resource of another user $response = $this->actingAs($jack)->get("/api/v4/resources/{$resource->id}"); $response->assertStatus(403); // John: Account owner - non-existing resource $response = $this->actingAs($john)->get("/api/v4/resources/abc"); $response->assertStatus(404); // John: Account owner $response = $this->actingAs($john)->get("/api/v4/resources/{$resource->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame($resource->id, $json['id']); $this->assertSame($resource->email, $json['email']); $this->assertSame($resource->name, $json['name']); $this->assertTrue(!empty($json['statusInfo'])); $this->assertArrayHasKey('isDeleted', $json); $this->assertArrayHasKey('isActive', $json); $this->assertArrayHasKey('isLdapReady', $json); $this->assertArrayHasKey('isImapReady', $json); $this->assertSame(['invitation_policy' => 'reject'], $json['config']); $this->assertCount(1, $json['skus']); } /** * Test fetching SKUs list for a resource (GET /resources//skus) */ public function testSkus(): void { Queue::fake(); $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $resource = $this->getTestResource('resource-test@kolab.org'); $resource->assignToWallet($john->wallets->first()); // Unauth access not allowed $response = $this->get("api/v4/resources/{$resource->id}/skus"); $response->assertStatus(401); // Unauthorized access not allowed $response = $this->actingAs($jack)->get("api/v4/resources/{$resource->id}/skus"); $response->assertStatus(403); $response = $this->actingAs($john)->get("api/v4/resources/{$resource->id}/skus"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(1, $json); $this->assertSkuElement('resource', $json[0], [ 'prio' => 0, 'type' => 'resource', 'handler' => 'Resource', 'enabled' => true, 'readonly' => true, ]); } /** * Test fetching a resource status (GET /api/v4/resources//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'); $resource = $this->getTestResource('resource-test@kolab.org'); $resource->assignToWallet($john->wallets->first()); // Test unauthorized access $response = $this->get("/api/v4/resources/abc/status"); $response->assertStatus(401); // Test unauthorized access $response = $this->actingAs($jack)->get("/api/v4/resources/{$resource->id}/status"); $response->assertStatus(403); $resource->status = Resource::STATUS_NEW | Resource::STATUS_ACTIVE; $resource->save(); // Get resource status $response = $this->actingAs($john)->get("/api/v4/resources/{$resource->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('resource-new', $json['process'][0]['label']); $this->assertSame(true, $json['process'][0]['state']); $this->assertSame('resource-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(); $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(); $response = $this->actingAs($john)->get("/api/v4/resources/{$resource->id}/status?refresh=1"); $response->assertStatus(200); $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); } /** * Test ResourcesController::statusInfo() */ public function testStatusInfo(): void { $john = $this->getTestUser('john@kolab.org'); $resource = $this->getTestResource('resource-test@kolab.org'); $resource->assignToWallet($john->wallets->first()); $resource->status = Resource::STATUS_NEW | Resource::STATUS_ACTIVE; $resource->save(); $domain = $this->getTestDomain('kolab.org'); $domain->status |= \App\Domain::STATUS_CONFIRMED; $domain->save(); $result = ResourcesController::statusInfo($resource); $this->assertFalse($result['isReady']); $this->assertCount(7, $result['process']); $this->assertSame('resource-new', $result['process'][0]['label']); $this->assertSame(true, $result['process'][0]['state']); $this->assertSame('resource-ldap-ready', $result['process'][1]['label']); $this->assertSame(false, $result['process'][1]['state']); $this->assertSame('running', $result['processState']); $resource->created_at = Carbon::now()->subSeconds(181); $resource->save(); $result = ResourcesController::statusInfo($resource); $this->assertSame('failed', $result['processState']); $resource->status |= Resource::STATUS_LDAP_READY | Resource::STATUS_IMAP_READY; $resource->save(); $result = ResourcesController::statusInfo($resource); $this->assertTrue($result['isReady']); $this->assertCount(7, $result['process']); $this->assertSame('resource-new', $result['process'][0]['label']); $this->assertSame(true, $result['process'][0]['state']); $this->assertSame('resource-ldap-ready', $result['process'][1]['label']); $this->assertSame(true, $result['process'][1]['state']); $this->assertSame('resource-ldap-ready', $result['process'][1]['label']); $this->assertSame(true, $result['process'][1]['state']); $this->assertSame('done', $result['processState']); } /** * Test resource creation (POST /api/v4/resources) */ 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/resources", []); $response->assertStatus(401); // Test non-controller user $response = $this->actingAs($jack)->post("/api/v4/resources", []); $response->assertStatus(403); // Test empty request $response = $this->actingAs($john)->post("/api/v4/resources", []); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame("The name field is required.", $json['errors']['name'][0]); $this->assertCount(2, $json); $this->assertCount(1, $json['errors']); // Test too long name $post = ['domain' => 'kolab.org', 'name' => str_repeat('A', 192)]; $response = $this->actingAs($john)->post("/api/v4/resources", $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'][0]); $this->assertCount(1, $json['errors']); // Test successful resource creation $post['name'] = 'Test Resource'; $response = $this->actingAs($john)->post("/api/v4/resources", $post); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertSame("Resource created successfully.", $json['message']); $this->assertCount(2, $json); $resource = Resource::where('name', $post['name'])->first(); $this->assertInstanceOf(Resource::class, $resource); $this->assertTrue($john->resources()->get()->contains($resource)); // Resource name must be unique within a domain $response = $this->actingAs($john)->post("/api/v4/resources", $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 resource update (PUT /api/v4/resources/) */ public function testUpdate(): void { Queue::fake(); $jack = $this->getTestUser('jack@kolab.org'); $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $resource = $this->getTestResource('resource-test@kolab.org'); $resource->assignToWallet($john->wallets->first()); // Test unauthorized update $response = $this->get("/api/v4/resources/{$resource->id}", []); $response->assertStatus(401); // Test unauthorized update $response = $this->actingAs($jack)->get("/api/v4/resources/{$resource->id}", []); $response->assertStatus(403); // Name change $post = [ 'name' => 'Test Res', ]; $response = $this->actingAs($john)->put("/api/v4/resources/{$resource->id}", $post); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertSame("Resource updated successfully.", $json['message']); $this->assertCount(2, $json); $resource->refresh(); $this->assertSame($post['name'], $resource->name); } } diff --git a/src/tests/Feature/Controller/SharedFoldersTest.php b/src/tests/Feature/Controller/SharedFoldersTest.php index ddd4f9b0..db0832b8 100644 --- a/src/tests/Feature/Controller/SharedFoldersTest.php +++ b/src/tests/Feature/Controller/SharedFoldersTest.php @@ -1,617 +1,624 @@ deleteTestSharedFolder('folder-test@kolab.org'); SharedFolder::where('name', 'like', 'Test_Folder')->forceDelete(); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestSharedFolder('folder-test@kolab.org'); SharedFolder::where('name', 'like', 'Test_Folder')->forceDelete(); 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(4, $json); $this->assertSame(0, $json['count']); $this->assertSame(false, $json['hasMore']); $this->assertSame("0 shared folders have been found.", $json['message']); $this->assertSame([], $json['list']); // 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(4, $json); $this->assertSame(2, $json['count']); $this->assertSame(false, $json['hasMore']); $this->assertSame("2 shared folders have been found.", $json['message']); $this->assertCount(2, $json['list']); $this->assertSame($folder->id, $json['list'][0]['id']); $this->assertSame($folder->email, $json['list'][0]['email']); $this->assertSame($folder->name, $json['list'][0]['name']); $this->assertSame($folder->type, $json['list'][0]['type']); $this->assertArrayHasKey('isDeleted', $json['list'][0]); $this->assertArrayHasKey('isActive', $json['list'][0]); $this->assertArrayHasKey('isLdapReady', $json['list'][0]); $this->assertArrayHasKey('isImapReady', $json['list'][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(4, $json); $this->assertSame(2, $json['count']); $this->assertSame(false, $json['hasMore']); $this->assertSame("2 shared folders have been found.", $json['message']); $this->assertCount(2, $json['list']); $this->assertSame($folder->email, $json['list'][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"]'); $folder->setAliases(['folder-alias@kolab.org']); // 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->assertSame(['folder-alias@kolab.org'], $json['aliases']); $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']); $this->assertCount(1, $json['skus']); } /** * Test fetching SKUs list for a shared folder (GET /shared-folders//skus) */ public function testSkus(): 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()); // Unauth access not allowed $response = $this->get("api/v4/shared-folders/{$folder->id}/skus"); $response->assertStatus(401); // Unauthorized access not allowed $response = $this->actingAs($jack)->get("api/v4/shared-folders/{$folder->id}/skus"); $response->assertStatus(403); // Non-existing folder $response = $this->actingAs($john)->get("api/v4/shared-folders/non-existing/skus"); $response->assertStatus(404); $response = $this->actingAs($john)->get("api/v4/shared-folders/{$folder->id}/skus"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(1, $json); $this->assertSkuElement('shared-folder', $json[0], [ 'prio' => 0, 'type' => 'sharedFolder', 'handler' => 'SharedFolder', 'enabled' => true, 'readonly' => true, ]); } /** * 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 + // 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(); $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->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); } /** * 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, invalid alias domain $post = [ 'domain' => 'kolab.org', 'name' => str_repeat('A', 192), 'type' => 'unknown', 'aliases' => ['folder-alias@unknown.org'], ]; $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->assertSame(["The specified domain is invalid."], $json['errors']['aliases']); $this->assertCount(3, $json['errors']); // Test successful folder creation $post['name'] = 'Test Folder'; $post['type'] = 'event'; $post['aliases'] = []; $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)); $this->assertSame([], $folder->aliases()->pluck('alias')->all()); // 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]); $folder->forceDelete(); // Test successful folder creation with aliases $post['name'] = 'Test Folder'; $post['type'] = 'mail'; $post['aliases'] = ['folder-alias@kolab.org']; $response = $this->actingAs($john)->post("/api/v4/shared-folders", $post); $json = $response->json(); $response->assertStatus(200); $folder = SharedFolder::where('name', $post['name'])->first(); $this->assertSame(['folder-alias@kolab.org'], $folder->aliases()->pluck('alias')->all()); $folder->forceDelete(); // Test handling subfolders and lmtp alias email $post['name'] = 'Test/Folder'; $post['type'] = 'mail'; $post['aliases'] = ['shared+shared/Test/Folder@kolab.org']; $response = $this->actingAs($john)->post("/api/v4/shared-folders", $post); $json = $response->json(); $response->assertStatus(200); $folder = SharedFolder::where('name', $post['name'])->first(); $this->assertSame(['shared+shared/Test/Folder@kolab.org'], $folder->aliases()->pluck('alias')->all()); } /** * 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); // Aliases with error $post['aliases'] = ['folder-alias1@kolab.org', 'folder-alias2@unknown.com']; $response = $this->actingAs($john)->put("/api/v4/shared-folders/{$folder->id}", $post); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(2, $json); $this->assertCount(1, $json['errors']); $this->assertCount(1, $json['errors']['aliases']); $this->assertSame("The specified domain is invalid.", $json['errors']['aliases'][1]); $this->assertSame([], $folder->aliases()->pluck('alias')->all()); // Aliases with success expected $post['aliases'] = ['folder-alias1@kolab.org', 'folder-alias2@kolab.org']; $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); $this->assertSame($post['aliases'], $folder->aliases()->pluck('alias')->all()); // All aliases removal $post['aliases'] = []; $response = $this->actingAs($john)->put("/api/v4/shared-folders/{$folder->id}", $post); $response->assertStatus(200); $this->assertSame($post['aliases'], $folder->aliases()->pluck('alias')->all()); } } diff --git a/src/tests/Feature/Controller/UsersTest.php b/src/tests/Feature/Controller/UsersTest.php index 4dfe9ea6..8f494ca5 100644 --- a/src/tests/Feature/Controller/UsersTest.php +++ b/src/tests/Feature/Controller/UsersTest.php @@ -1,1638 +1,1619 @@ clearBetaEntitlements(); $this->deleteTestUser('jane@kolabnow.com'); $this->deleteTestUser('UsersControllerTest1@userscontroller.com'); $this->deleteTestUser('UsersControllerTest2@userscontroller.com'); $this->deleteTestUser('UsersControllerTest3@userscontroller.com'); $this->deleteTestUser('UserEntitlement2A@UserEntitlement.com'); $this->deleteTestUser('john2.doe2@kolab.org'); $this->deleteTestUser('deleted@kolab.org'); $this->deleteTestUser('deleted@kolabnow.com'); $this->deleteTestDomain('userscontroller.com'); $this->deleteTestGroup('group-test@kolabnow.com'); $this->deleteTestGroup('group-test@kolab.org'); $this->deleteTestSharedFolder('folder-test@kolabnow.com'); $this->deleteTestResource('resource-test@kolabnow.com'); Sku::where('title', 'test')->delete(); $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); $wallet->discount()->dissociate(); $wallet->settings()->whereIn('key', ['mollie_id', 'stripe_id'])->delete(); $wallet->save(); $user->settings()->whereIn('key', ['greylist_enabled', 'guam_enabled'])->delete(); $user->status |= User::STATUS_IMAP_READY; $user->save(); } /** * {@inheritDoc} */ public function tearDown(): void { $this->clearBetaEntitlements(); $this->deleteTestUser('jane@kolabnow.com'); $this->deleteTestUser('UsersControllerTest1@userscontroller.com'); $this->deleteTestUser('UsersControllerTest2@userscontroller.com'); $this->deleteTestUser('UsersControllerTest3@userscontroller.com'); $this->deleteTestUser('UserEntitlement2A@UserEntitlement.com'); $this->deleteTestUser('john2.doe2@kolab.org'); $this->deleteTestUser('deleted@kolab.org'); $this->deleteTestUser('deleted@kolabnow.com'); $this->deleteTestDomain('userscontroller.com'); $this->deleteTestGroup('group-test@kolabnow.com'); $this->deleteTestGroup('group-test@kolab.org'); $this->deleteTestSharedFolder('folder-test@kolabnow.com'); $this->deleteTestResource('resource-test@kolabnow.com'); Sku::where('title', 'test')->delete(); $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); $wallet->discount()->dissociate(); $wallet->settings()->whereIn('key', ['mollie_id', 'stripe_id'])->delete(); $wallet->save(); $user->settings()->whereIn('key', ['greylist_enabled', 'guam_enabled'])->delete(); $user->status |= User::STATUS_IMAP_READY; $user->save(); parent::tearDown(); } /** * Test user deleting (DELETE /api/v4/users/) */ public function testDestroy(): void { // First create some users/accounts to delete $package_kolab = \App\Package::where('title', 'kolab')->first(); $package_domain = \App\Package::where('title', 'domain-hosting')->first(); $john = $this->getTestUser('john@kolab.org'); $user1 = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $user2 = $this->getTestUser('UsersControllerTest2@userscontroller.com'); $user3 = $this->getTestUser('UsersControllerTest3@userscontroller.com'); $domain = $this->getTestDomain('userscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_PUBLIC, ]); $user1->assignPackage($package_kolab); $domain->assignPackage($package_domain, $user1); $user1->assignPackage($package_kolab, $user2); $user1->assignPackage($package_kolab, $user3); // Test unauth access $response = $this->delete("api/v4/users/{$user2->id}"); $response->assertStatus(401); // Test access to other user/account $response = $this->actingAs($john)->delete("api/v4/users/{$user2->id}"); $response->assertStatus(403); $response = $this->actingAs($john)->delete("api/v4/users/{$user1->id}"); $response->assertStatus(403); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame("Access denied", $json['message']); $this->assertCount(2, $json); // Test that non-controller cannot remove himself $response = $this->actingAs($user3)->delete("api/v4/users/{$user3->id}"); $response->assertStatus(403); // Test removing a non-controller user $response = $this->actingAs($user1)->delete("api/v4/users/{$user3->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals('success', $json['status']); $this->assertEquals('User deleted successfully.', $json['message']); // Test removing self (an account with users) $response = $this->actingAs($user1)->delete("api/v4/users/{$user1->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals('success', $json['status']); $this->assertEquals('User deleted successfully.', $json['message']); } /** * Test user deleting (DELETE /api/v4/users/) */ public function testDestroyByController(): void { // Create an account with additional controller - $user2 $package_kolab = \App\Package::where('title', 'kolab')->first(); $package_domain = \App\Package::where('title', 'domain-hosting')->first(); $user1 = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $user2 = $this->getTestUser('UsersControllerTest2@userscontroller.com'); $user3 = $this->getTestUser('UsersControllerTest3@userscontroller.com'); $domain = $this->getTestDomain('userscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_PUBLIC, ]); $user1->assignPackage($package_kolab); $domain->assignPackage($package_domain, $user1); $user1->assignPackage($package_kolab, $user2); $user1->assignPackage($package_kolab, $user3); $user1->wallets()->first()->addController($user2); // TODO/FIXME: // For now controller can delete himself, as well as // the whole account he has control to, including the owner // Probably he should not be able to do none of those // However, this is not 0-regression scenario as we // do not fully support additional controllers. //$response = $this->actingAs($user2)->delete("api/v4/users/{$user2->id}"); //$response->assertStatus(403); $response = $this->actingAs($user2)->delete("api/v4/users/{$user3->id}"); $response->assertStatus(200); $response = $this->actingAs($user2)->delete("api/v4/users/{$user1->id}"); $response->assertStatus(200); // Note: More detailed assertions in testDestroy() above $this->assertTrue($user1->fresh()->trashed()); $this->assertTrue($user2->fresh()->trashed()); $this->assertTrue($user3->fresh()->trashed()); } /** * Test user listing (GET /api/v4/users) */ public function testIndex(): void { // Test unauth access $response = $this->get("api/v4/users"); $response->assertStatus(401); $jack = $this->getTestUser('jack@kolab.org'); $joe = $this->getTestUser('joe@kolab.org'); $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $response = $this->actingAs($jack)->get("/api/v4/users"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(false, $json['hasMore']); $this->assertSame(0, $json['count']); $this->assertCount(0, $json['list']); $response = $this->actingAs($john)->get("/api/v4/users"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(false, $json['hasMore']); $this->assertSame(4, $json['count']); $this->assertCount(4, $json['list']); $this->assertSame($jack->email, $json['list'][0]['email']); $this->assertSame($joe->email, $json['list'][1]['email']); $this->assertSame($john->email, $json['list'][2]['email']); $this->assertSame($ned->email, $json['list'][3]['email']); // Values below are tested by Unit tests $this->assertArrayHasKey('isDeleted', $json['list'][0]); $this->assertArrayHasKey('isDegraded', $json['list'][0]); $this->assertArrayHasKey('isAccountDegraded', $json['list'][0]); $this->assertArrayHasKey('isSuspended', $json['list'][0]); $this->assertArrayHasKey('isActive', $json['list'][0]); $this->assertArrayHasKey('isLdapReady', $json['list'][0]); $this->assertArrayHasKey('isImapReady', $json['list'][0]); $response = $this->actingAs($ned)->get("/api/v4/users"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(false, $json['hasMore']); $this->assertSame(4, $json['count']); $this->assertCount(4, $json['list']); $this->assertSame($jack->email, $json['list'][0]['email']); $this->assertSame($joe->email, $json['list'][1]['email']); $this->assertSame($john->email, $json['list'][2]['email']); $this->assertSame($ned->email, $json['list'][3]['email']); // Search by user email $response = $this->actingAs($john)->get("/api/v4/users?search=jack@k"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(false, $json['hasMore']); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($jack->email, $json['list'][0]['email']); // Search by alias $response = $this->actingAs($john)->get("/api/v4/users?search=monster"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(false, $json['hasMore']); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($joe->email, $json['list'][0]['email']); // Search by name $response = $this->actingAs($john)->get("/api/v4/users?search=land"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(false, $json['hasMore']); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($ned->email, $json['list'][0]['email']); // TODO: Test paging } /** * Test fetching user data/profile (GET /api/v4/users/) */ public function testShow(): void { $userA = $this->getTestUser('UserEntitlement2A@UserEntitlement.com'); // Test getting profile of self $response = $this->actingAs($userA)->get("/api/v4/users/{$userA->id}"); $json = $response->json(); $response->assertStatus(200); $this->assertEquals($userA->id, $json['id']); $this->assertEquals($userA->email, $json['email']); $this->assertTrue(is_array($json['statusInfo'])); $this->assertTrue(is_array($json['settings'])); $this->assertTrue($json['config']['greylist_enabled']); $this->assertFalse($json['config']['guam_enabled']); $this->assertSame([], $json['skus']); $this->assertSame([], $json['aliases']); // Values below are tested by Unit tests $this->assertArrayHasKey('isDeleted', $json); $this->assertArrayHasKey('isDegraded', $json); $this->assertArrayHasKey('isAccountDegraded', $json); $this->assertArrayHasKey('isSuspended', $json); $this->assertArrayHasKey('isActive', $json); $this->assertArrayHasKey('isLdapReady', $json); $this->assertArrayHasKey('isImapReady', $json); $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); // Test unauthorized access to a profile of other user $response = $this->actingAs($jack)->get("/api/v4/users/{$userA->id}"); $response->assertStatus(403); // Test authorized access to a profile of other user // Ned: Additional account controller $response = $this->actingAs($ned)->get("/api/v4/users/{$john->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(['john.doe@kolab.org'], $json['aliases']); $response = $this->actingAs($ned)->get("/api/v4/users/{$jack->id}"); $response->assertStatus(200); // John: Account owner $response = $this->actingAs($john)->get("/api/v4/users/{$jack->id}"); $response->assertStatus(200); $response = $this->actingAs($john)->get("/api/v4/users/{$ned->id}"); $response->assertStatus(200); $json = $response->json(); $storage_sku = Sku::withEnvTenantContext()->where('title', 'storage')->first(); $groupware_sku = Sku::withEnvTenantContext()->where('title', 'groupware')->first(); $mailbox_sku = Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); $secondfactor_sku = Sku::withEnvTenantContext()->where('title', '2fa')->first(); $this->assertCount(5, $json['skus']); $this->assertSame(5, $json['skus'][$storage_sku->id]['count']); $this->assertSame([0,0,0,0,0], $json['skus'][$storage_sku->id]['costs']); $this->assertSame(1, $json['skus'][$groupware_sku->id]['count']); $this->assertSame([490], $json['skus'][$groupware_sku->id]['costs']); $this->assertSame(1, $json['skus'][$mailbox_sku->id]['count']); $this->assertSame([500], $json['skus'][$mailbox_sku->id]['costs']); $this->assertSame(1, $json['skus'][$secondfactor_sku->id]['count']); $this->assertSame([0], $json['skus'][$secondfactor_sku->id]['costs']); $this->assertSame([], $json['aliases']); } /** * Test fetching SKUs list for a user (GET /users//skus) */ public function testSkus(): void { $user = $this->getTestUser('john@kolab.org'); // Unauth access not allowed $response = $this->get("api/v4/users/{$user->id}/skus"); $response->assertStatus(401); // Create an sku for another tenant, to make sure it is not included in the result $nsku = Sku::create([ 'title' => 'test', 'name' => 'Test', 'description' => '', 'active' => true, 'cost' => 100, 'handler_class' => 'Mailbox', ]); $tenant = Tenant::whereNotIn('id', [\config('app.tenant_id')])->first(); $nsku->tenant_id = $tenant->id; $nsku->save(); $response = $this->actingAs($user)->get("api/v4/users/{$user->id}/skus"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(5, $json); $this->assertSkuElement('mailbox', $json[0], [ 'prio' => 100, 'type' => 'user', 'handler' => 'Mailbox', 'enabled' => true, 'readonly' => true, ]); $this->assertSkuElement('storage', $json[1], [ 'prio' => 90, 'type' => 'user', 'handler' => 'Storage', 'enabled' => true, 'readonly' => true, 'range' => [ 'min' => 5, 'max' => 100, 'unit' => 'GB', ] ]); $this->assertSkuElement('groupware', $json[2], [ 'prio' => 80, 'type' => 'user', 'handler' => 'Groupware', 'enabled' => false, 'readonly' => false, ]); $this->assertSkuElement('activesync', $json[3], [ 'prio' => 70, 'type' => 'user', 'handler' => 'Activesync', 'enabled' => false, 'readonly' => false, 'required' => ['Groupware'], ]); $this->assertSkuElement('2fa', $json[4], [ 'prio' => 60, 'type' => 'user', 'handler' => 'Auth2F', 'enabled' => false, 'readonly' => false, 'forbidden' => ['Activesync'], ]); // Test inclusion of beta SKUs $sku = Sku::withEnvTenantContext()->where('title', 'beta')->first(); $user->assignSku($sku); $response = $this->actingAs($user)->get("api/v4/users/{$user->id}/skus"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(6, $json); $this->assertSkuElement('beta', $json[5], [ 'prio' => 10, 'type' => 'user', 'handler' => 'Beta', 'enabled' => false, 'readonly' => false, ]); } /** * Test fetching user status (GET /api/v4/users//status) * and forcing setup process update (?refresh=1) * * @group imap * @group dns */ public function testStatus(): void { Queue::fake(); $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); // Test unauthorized access $response = $this->actingAs($jack)->get("/api/v4/users/{$john->id}/status"); $response->assertStatus(403); if ($john->isImapReady()) { $john->status ^= User::STATUS_IMAP_READY; $john->save(); } // Get user status $response = $this->actingAs($john)->get("/api/v4/users/{$john->id}/status"); $response->assertStatus(200); $json = $response->json(); $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->assertTrue(empty($json['status'])); $this->assertTrue(empty($json['message'])); // Make sure the domain is confirmed (other test might unset that status) $domain = $this->getTestDomain('kolab.org'); $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); $json = $response->json(); $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); } /** * Test UsersController::statusInfo() */ public function testStatusInfo(): void { $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $domain = $this->getTestDomain('userscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_PUBLIC, ]); $user->created_at = Carbon::now(); $user->status = User::STATUS_NEW; $user->save(); $result = UsersController::statusInfo($user); $this->assertFalse($result['isReady']); $this->assertSame([], $result['skus']); $this->assertCount(3, $result['process']); $this->assertSame('user-new', $result['process'][0]['label']); $this->assertSame(true, $result['process'][0]['state']); $this->assertSame('user-ldap-ready', $result['process'][1]['label']); $this->assertSame(false, $result['process'][1]['state']); $this->assertSame('user-imap-ready', $result['process'][2]['label']); $this->assertSame(false, $result['process'][2]['state']); $this->assertSame('running', $result['processState']); $this->assertTrue($result['enableRooms']); $this->assertFalse($result['enableBeta']); $user->created_at = Carbon::now()->subSeconds(181); $user->save(); $result = UsersController::statusInfo($user); $this->assertSame('failed', $result['processState']); $user->status |= User::STATUS_LDAP_READY | User::STATUS_IMAP_READY; $user->save(); $result = UsersController::statusInfo($user); $this->assertTrue($result['isReady']); $this->assertCount(3, $result['process']); $this->assertSame('user-new', $result['process'][0]['label']); $this->assertSame(true, $result['process'][0]['state']); $this->assertSame('user-ldap-ready', $result['process'][1]['label']); $this->assertSame(true, $result['process'][1]['state']); $this->assertSame('user-imap-ready', $result['process'][2]['label']); $this->assertSame(true, $result['process'][2]['state']); $this->assertSame('done', $result['processState']); $domain->status |= Domain::STATUS_VERIFIED; $domain->type = Domain::TYPE_EXTERNAL; $domain->save(); $result = UsersController::statusInfo($user); $this->assertFalse($result['isReady']); $this->assertSame([], $result['skus']); $this->assertCount(7, $result['process']); $this->assertSame('user-new', $result['process'][0]['label']); $this->assertSame(true, $result['process'][0]['state']); $this->assertSame('user-ldap-ready', $result['process'][1]['label']); $this->assertSame(true, $result['process'][1]['state']); $this->assertSame('user-imap-ready', $result['process'][2]['label']); $this->assertSame(true, $result['process'][2]['state']); $this->assertSame('domain-new', $result['process'][3]['label']); $this->assertSame(true, $result['process'][3]['state']); $this->assertSame('domain-ldap-ready', $result['process'][4]['label']); $this->assertSame(false, $result['process'][4]['state']); $this->assertSame('domain-verified', $result['process'][5]['label']); $this->assertSame(true, $result['process'][5]['state']); $this->assertSame('domain-confirmed', $result['process'][6]['label']); $this->assertSame(false, $result['process'][6]['state']); // Test 'skus' property $user->assignSku(Sku::withEnvTenantContext()->where('title', 'beta')->first()); $result = UsersController::statusInfo($user); $this->assertSame(['beta'], $result['skus']); $this->assertTrue($result['enableBeta']); $user->assignSku(Sku::withEnvTenantContext()->where('title', 'groupware')->first()); $result = UsersController::statusInfo($user); $this->assertSame(['beta', 'groupware'], $result['skus']); // Degraded user $user->status |= User::STATUS_DEGRADED; $user->save(); $result = UsersController::statusInfo($user); $this->assertTrue($result['enableBeta']); $this->assertFalse($result['enableRooms']); // User in a tenant without 'room' SKU $user->status = User::STATUS_LDAP_READY | User::STATUS_IMAP_READY | User::STATUS_ACTIVE; $user->tenant_id = Tenant::where('title', 'Sample Tenant')->first()->id; $user->save(); $result = UsersController::statusInfo($user); $this->assertTrue($result['enableBeta']); $this->assertFalse($result['enableRooms']); } /** * Test user config update (POST /api/v4/users//config) */ public function testSetConfig(): void { $jack = $this->getTestUser('jack@kolab.org'); $john = $this->getTestUser('john@kolab.org'); $john->setSetting('greylist_enabled', null); $john->setSetting('guam_enabled', null); $john->setSetting('password_policy', null); $john->setSetting('max_password_age', null); // Test unknown user id $post = ['greylist_enabled' => 1]; $response = $this->actingAs($john)->post("/api/v4/users/123/config", $post); $json = $response->json(); $response->assertStatus(404); // Test access by user not being a wallet controller $post = ['greylist_enabled' => 1]; $response = $this->actingAs($jack)->post("/api/v4/users/{$john->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 = ['grey' => 1, 'password_policy' => 'min:1,max:255']; $response = $this->actingAs($john)->post("/api/v4/users/{$john->id}/config", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json); $this->assertCount(2, $json['errors']); $this->assertSame("The requested configuration parameter is not supported.", $json['errors']['grey']); $this->assertSame("Minimum password length cannot be less than 6.", $json['errors']['password_policy']); $this->assertNull($john->fresh()->getSetting('greylist_enabled')); // Test some valid data $post = [ 'greylist_enabled' => 1, 'guam_enabled' => 1, 'password_policy' => 'min:10,max:255,upper,lower,digit,special', 'max_password_age' => 6, ]; $response = $this->actingAs($john)->post("/api/v4/users/{$john->id}/config", $post); $response->assertStatus(200); $json = $response->json(); $this->assertCount(2, $json); $this->assertSame('success', $json['status']); $this->assertSame('User settings updated successfully.', $json['message']); $this->assertSame('true', $john->getSetting('greylist_enabled')); $this->assertSame('true', $john->getSetting('guam_enabled')); $this->assertSame('min:10,max:255,upper,lower,digit,special', $john->getSetting('password_policy')); $this->assertSame('6', $john->getSetting('max_password_age')); // Test some valid data, acting as another account controller $ned = $this->getTestUser('ned@kolab.org'); $post = ['greylist_enabled' => 0, 'guam_enabled' => 0, 'password_policy' => 'min:10,max:255,upper,last:1']; $response = $this->actingAs($ned)->post("/api/v4/users/{$john->id}/config", $post); $response->assertStatus(200); $json = $response->json(); $this->assertCount(2, $json); $this->assertSame('success', $json['status']); $this->assertSame('User settings updated successfully.', $json['message']); $this->assertSame('false', $john->fresh()->getSetting('greylist_enabled')); $this->assertSame(null, $john->fresh()->getSetting('guam_enabled')); $this->assertSame('min:10,max:255,upper,last:1', $john->fresh()->getSetting('password_policy')); } /** * Test user creation (POST /api/v4/users) */ public function testStore(): void { Queue::fake(); $jack = $this->getTestUser('jack@kolab.org'); $john = $this->getTestUser('john@kolab.org'); $john->setSetting('password_policy', 'min:8,max:100,digit'); $deleted_priv = $this->getTestUser('deleted@kolab.org'); $deleted_priv->delete(); // Test empty request $response = $this->actingAs($john)->post("/api/v4/users", []); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame("The email field is required.", $json['errors']['email']); $this->assertSame("The password field is required.", $json['errors']['password'][0]); $this->assertCount(2, $json); // Test access by user not being a wallet controller $post = ['first_name' => 'Test']; $response = $this->actingAs($jack)->post("/api/v4/users", $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 = ['password' => '12345678', 'email' => 'invalid']; $response = $this->actingAs($john)->post("/api/v4/users", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json); $this->assertSame('The password confirmation does not match.', $json['errors']['password'][0]); $this->assertSame('The specified email is invalid.', $json['errors']['email']); // Test existing user email $post = [ 'password' => 'simple123', 'password_confirmation' => 'simple123', 'first_name' => 'John2', 'last_name' => 'Doe2', 'email' => 'jack.daniels@kolab.org', ]; $response = $this->actingAs($john)->post("/api/v4/users", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json); $this->assertSame('The specified email is not available.', $json['errors']['email']); $package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $post = [ 'password' => 'simple123', 'password_confirmation' => 'simple123', 'first_name' => 'John2', 'last_name' => 'Doe2', 'email' => 'john2.doe2@kolab.org', 'organization' => 'TestOrg', 'aliases' => ['useralias1@kolab.org', 'deleted@kolab.org'], ]; // Missing package $response = $this->actingAs($john)->post("/api/v4/users", $post); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertSame("Package is required.", $json['errors']['package']); $this->assertCount(2, $json); // Invalid package $post['package'] = $package_domain->id; $response = $this->actingAs($john)->post("/api/v4/users", $post); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertSame("Invalid package selected.", $json['errors']['package']); $this->assertCount(2, $json); // Test password policy checking $post['package'] = $package_kolab->id; $post['password'] = 'password'; $response = $this->actingAs($john)->post("/api/v4/users", $post); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertSame("The password confirmation does not match.", $json['errors']['password'][0]); $this->assertSame("Specified password does not comply with the policy.", $json['errors']['password'][1]); $this->assertCount(2, $json); // Test password confirmation $post['password_confirmation'] = 'password'; $response = $this->actingAs($john)->post("/api/v4/users", $post); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertSame("Specified password does not comply with the policy.", $json['errors']['password'][0]); $this->assertCount(2, $json); // Test full and valid data $post['password'] = 'password123'; $post['password_confirmation'] = 'password123'; $response = $this->actingAs($john)->post("/api/v4/users", $post); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertSame("User created successfully.", $json['message']); $this->assertCount(2, $json); $user = User::where('email', 'john2.doe2@kolab.org')->first(); $this->assertInstanceOf(User::class, $user); $this->assertSame('John2', $user->getSetting('first_name')); $this->assertSame('Doe2', $user->getSetting('last_name')); $this->assertSame('TestOrg', $user->getSetting('organization')); /** @var \App\UserAlias[] $aliases */ $aliases = $user->aliases()->orderBy('alias')->get(); $this->assertCount(2, $aliases); $this->assertSame('deleted@kolab.org', $aliases[0]->alias); $this->assertSame('useralias1@kolab.org', $aliases[1]->alias); // Assert the new user entitlements $this->assertEntitlements($user, ['groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage']); // Assert the wallet to which the new user should be assigned to $wallet = $user->wallet(); $this->assertSame($john->wallets->first()->id, $wallet->id); // Attempt to create a user previously deleted $user->delete(); $post['package'] = $package_kolab->id; $post['aliases'] = []; $response = $this->actingAs($john)->post("/api/v4/users", $post); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertSame("User created successfully.", $json['message']); $this->assertCount(2, $json); $user = User::where('email', 'john2.doe2@kolab.org')->first(); $this->assertInstanceOf(User::class, $user); $this->assertSame('John2', $user->getSetting('first_name')); $this->assertSame('Doe2', $user->getSetting('last_name')); $this->assertSame('TestOrg', $user->getSetting('organization')); $this->assertCount(0, $user->aliases()->get()); $this->assertEntitlements($user, ['groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage']); // Test password reset link "mode" $code = new \App\VerificationCode(['mode' => 'password-reset', 'active' => false]); $john->verificationcodes()->save($code); $post = [ 'first_name' => 'John2', 'last_name' => 'Doe2', 'email' => 'deleted@kolab.org', 'organization' => '', 'aliases' => [], 'passwordLinkCode' => $code->short_code . '-' . $code->code, 'package' => $package_kolab->id, ]; $response = $this->actingAs($john)->post("/api/v4/users", $post); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertSame("User created successfully.", $json['message']); $this->assertCount(2, $json); $user = $this->getTestUser('deleted@kolab.org'); $code->refresh(); $this->assertSame($user->id, $code->user_id); $this->assertTrue($code->active); $this->assertTrue(is_string($user->password) && strlen($user->password) >= 60); // Test acting as account controller not owner, which is not yet supported $john->wallets->first()->addController($user); $response = $this->actingAs($user)->post("/api/v4/users", []); $response->assertStatus(403); } /** * Test user update (PUT /api/v4/users/) */ public function testUpdate(): void { $userA = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $userA->setSetting('password_policy', 'min:8,digit'); $jack = $this->getTestUser('jack@kolab.org'); $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $domain = $this->getTestDomain( 'userscontroller.com', ['status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL] ); // Test unauthorized update of other user profile $response = $this->actingAs($jack)->get("/api/v4/users/{$userA->id}", []); $response->assertStatus(403); // Test authorized update of account owner by account controller $response = $this->actingAs($ned)->get("/api/v4/users/{$john->id}", []); $response->assertStatus(200); // Test updating of self (empty request) $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", []); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("User data updated successfully.", $json['message']); $this->assertTrue(!empty($json['statusInfo'])); $this->assertCount(3, $json); // Test some invalid data $post = ['password' => '1234567', 'currency' => 'invalid']; $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json); $this->assertSame("The password confirmation does not match.", $json['errors']['password'][0]); $this->assertSame("Specified password does not comply with the policy.", $json['errors']['password'][1]); $this->assertSame("The currency must be 3 characters.", $json['errors']['currency'][0]); // Test full profile update including password $post = [ 'password' => 'simple123', 'password_confirmation' => 'simple123', 'first_name' => 'John2', 'last_name' => 'Doe2', 'organization' => 'TestOrg', 'phone' => '+123 123 123', 'external_email' => 'external@gmail.com', 'billing_address' => 'billing', 'country' => 'CH', 'currency' => 'CHF', 'aliases' => ['useralias1@' . \config('app.domain'), 'useralias2@' . \config('app.domain')] ]; $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertSame("User data updated successfully.", $json['message']); $this->assertTrue(!empty($json['statusInfo'])); $this->assertCount(3, $json); $this->assertTrue($userA->password != $userA->fresh()->password); unset($post['password'], $post['password_confirmation'], $post['aliases']); foreach ($post as $key => $value) { $this->assertSame($value, $userA->getSetting($key)); } $aliases = $userA->aliases()->orderBy('alias')->get(); $this->assertCount(2, $aliases); $this->assertSame('useralias1@' . \config('app.domain'), $aliases[0]->alias); $this->assertSame('useralias2@' . \config('app.domain'), $aliases[1]->alias); // Test unsetting values $post = [ 'first_name' => '', 'last_name' => '', 'organization' => '', 'phone' => '', 'external_email' => '', 'billing_address' => '', 'country' => '', 'currency' => '', 'aliases' => ['useralias2@' . \config('app.domain')] ]; $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertSame("User data updated successfully.", $json['message']); $this->assertTrue(!empty($json['statusInfo'])); $this->assertCount(3, $json); unset($post['aliases']); foreach ($post as $key => $value) { $this->assertNull($userA->getSetting($key)); } $aliases = $userA->aliases()->get(); $this->assertCount(1, $aliases); $this->assertSame('useralias2@' . \config('app.domain'), $aliases[0]->alias); // Test error on some invalid aliases missing password confirmation $post = [ 'password' => 'simple123', 'aliases' => [ 'useralias2@' . \config('app.domain'), 'useralias1@kolab.org', '@kolab.org', ] ]; $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertCount(2, $json['errors']['aliases']); $this->assertSame("The specified domain is not available.", $json['errors']['aliases'][1]); $this->assertSame("The specified alias is invalid.", $json['errors']['aliases'][2]); $this->assertSame("The password confirmation does not match.", $json['errors']['password'][0]); // Test authorized update of other user $response = $this->actingAs($ned)->put("/api/v4/users/{$jack->id}", []); $response->assertStatus(200); $json = $response->json(); $this->assertTrue(empty($json['statusInfo'])); // TODO: Test error on aliases with invalid/non-existing/other-user's domain // Create entitlements and additional user for following tests $owner = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $user = $this->getTestUser('UsersControllerTest2@userscontroller.com'); $package_domain = Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $package_kolab = Package::withEnvTenantContext()->where('title', 'kolab')->first(); $package_lite = Package::withEnvTenantContext()->where('title', 'lite')->first(); $sku_mailbox = Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); $sku_storage = Sku::withEnvTenantContext()->where('title', 'storage')->first(); $sku_groupware = Sku::withEnvTenantContext()->where('title', 'groupware')->first(); $domain = $this->getTestDomain( 'userscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ] ); $domain->assignPackage($package_domain, $owner); $owner->assignPackage($package_kolab); $owner->assignPackage($package_lite, $user); // Non-controller cannot update his own entitlements $post = ['skus' => []]; $response = $this->actingAs($user)->put("/api/v4/users/{$user->id}", $post); $response->assertStatus(422); // Test updating entitlements $post = [ 'skus' => [ $sku_mailbox->id => 1, $sku_storage->id => 6, $sku_groupware->id => 1, ], ]; $response = $this->actingAs($owner)->put("/api/v4/users/{$user->id}", $post); $response->assertStatus(200); $json = $response->json(); $storage_cost = $user->entitlements() ->where('sku_id', $sku_storage->id) ->orderBy('cost') ->pluck('cost')->all(); $this->assertEntitlements( $user, ['groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage'] ); $this->assertSame([0, 0, 0, 0, 0, 25], $storage_cost); $this->assertTrue(empty($json['statusInfo'])); // Test password reset link "mode" $code = new \App\VerificationCode(['mode' => 'password-reset', 'active' => false]); $owner->verificationcodes()->save($code); $post = ['passwordLinkCode' => $code->short_code . '-' . $code->code]; $response = $this->actingAs($owner)->put("/api/v4/users/{$user->id}", $post); $json = $response->json(); $response->assertStatus(200); $code->refresh(); $this->assertSame($user->id, $code->user_id); $this->assertTrue($code->active); $this->assertSame($user->password, $user->fresh()->password); } /** * Test UsersController::updateEntitlements() */ public function testUpdateEntitlements(): void { $jane = $this->getTestUser('jane@kolabnow.com'); $kolab = Package::withEnvTenantContext()->where('title', 'kolab')->first(); $storage = Sku::withEnvTenantContext()->where('title', 'storage')->first(); $activesync = Sku::withEnvTenantContext()->where('title', 'activesync')->first(); $groupware = Sku::withEnvTenantContext()->where('title', 'groupware')->first(); $mailbox = Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); // standard package, 1 mailbox, 1 groupware, 2 storage $jane->assignPackage($kolab); // add 2 storage, 1 activesync $post = [ 'skus' => [ $mailbox->id => 1, $groupware->id => 1, $storage->id => 7, $activesync->id => 1 ] ]; $response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post); $response->assertStatus(200); $this->assertEntitlements( $jane, [ 'activesync', 'groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage' ] ); // add 2 storage, remove 1 activesync $post = [ 'skus' => [ $mailbox->id => 1, $groupware->id => 1, $storage->id => 9, $activesync->id => 0 ] ]; $response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post); $response->assertStatus(200); $this->assertEntitlements( $jane, [ 'groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage' ] ); // add mailbox $post = [ 'skus' => [ $mailbox->id => 2, $groupware->id => 1, $storage->id => 9, $activesync->id => 0 ] ]; $response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post); $response->assertStatus(500); $this->assertEntitlements( $jane, [ 'groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage' ] ); // remove mailbox $post = [ 'skus' => [ $mailbox->id => 0, $groupware->id => 1, $storage->id => 9, $activesync->id => 0 ] ]; $response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post); $response->assertStatus(500); $this->assertEntitlements( $jane, [ 'groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage' ] ); // less than free storage $post = [ 'skus' => [ $mailbox->id => 1, $groupware->id => 1, $storage->id => 1, $activesync->id => 0 ] ]; $response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post); $response->assertStatus(200); $this->assertEntitlements( $jane, [ 'groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage' ] ); } /** * Test user data response used in show and info actions */ public function testUserResponse(): void { $provider = \config('services.payment_provider') ?: 'mollie'; $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); $wallet->setSettings(['mollie_id' => null, 'stripe_id' => null]); $result = $this->invokeMethod(new UsersController(), 'userResponse', [$user]); $this->assertEquals($user->id, $result['id']); $this->assertEquals($user->email, $result['email']); $this->assertEquals($user->status, $result['status']); $this->assertTrue(is_array($result['statusInfo'])); $this->assertTrue(is_array($result['settings'])); $this->assertSame('US', $result['settings']['country']); $this->assertSame('USD', $result['settings']['currency']); $this->assertTrue(is_array($result['accounts'])); $this->assertTrue(is_array($result['wallets'])); $this->assertCount(0, $result['accounts']); $this->assertCount(1, $result['wallets']); $this->assertSame($wallet->id, $result['wallet']['id']); $this->assertArrayNotHasKey('discount', $result['wallet']); $this->assertTrue($result['statusInfo']['enableDomains']); $this->assertTrue($result['statusInfo']['enableWallets']); $this->assertTrue($result['statusInfo']['enableUsers']); $this->assertTrue($result['statusInfo']['enableSettings']); // Ned is John's wallet controller $ned = $this->getTestUser('ned@kolab.org'); $ned_wallet = $ned->wallets()->first(); $result = $this->invokeMethod(new UsersController(), 'userResponse', [$ned]); $this->assertEquals($ned->id, $result['id']); $this->assertEquals($ned->email, $result['email']); $this->assertTrue(is_array($result['accounts'])); $this->assertTrue(is_array($result['wallets'])); $this->assertCount(1, $result['accounts']); $this->assertCount(1, $result['wallets']); $this->assertSame($wallet->id, $result['wallet']['id']); $this->assertSame($wallet->id, $result['accounts'][0]['id']); $this->assertSame($ned_wallet->id, $result['wallets'][0]['id']); $this->assertSame($provider, $result['wallet']['provider']); $this->assertSame($provider, $result['wallets'][0]['provider']); $this->assertTrue($result['statusInfo']['enableDomains']); $this->assertTrue($result['statusInfo']['enableWallets']); $this->assertTrue($result['statusInfo']['enableUsers']); $this->assertTrue($result['statusInfo']['enableSettings']); // Test discount in a response $discount = Discount::where('code', 'TEST')->first(); $wallet->discount()->associate($discount); $wallet->save(); $mod_provider = $provider == 'mollie' ? 'stripe' : 'mollie'; $wallet->setSetting($mod_provider . '_id', 123); $user->refresh(); $result = $this->invokeMethod(new UsersController(), 'userResponse', [$user]); $this->assertEquals($user->id, $result['id']); $this->assertSame($discount->id, $result['wallet']['discount_id']); $this->assertSame($discount->discount, $result['wallet']['discount']); $this->assertSame($discount->description, $result['wallet']['discount_description']); $this->assertSame($mod_provider, $result['wallet']['provider']); $this->assertSame($discount->id, $result['wallets'][0]['discount_id']); $this->assertSame($discount->discount, $result['wallets'][0]['discount']); $this->assertSame($discount->description, $result['wallets'][0]['discount_description']); $this->assertSame($mod_provider, $result['wallets'][0]['provider']); // Jack is not a John's wallet controller $jack = $this->getTestUser('jack@kolab.org'); $result = $this->invokeMethod(new UsersController(), 'userResponse', [$jack]); $this->assertFalse($result['statusInfo']['enableDomains']); $this->assertFalse($result['statusInfo']['enableWallets']); $this->assertFalse($result['statusInfo']['enableUsers']); $this->assertFalse($result['statusInfo']['enableSettings']); } /** * User email address validation. * * Note: Technically these include unit tests, but let's keep it here for now. * FIXME: Shall we do a http request for each case? */ public function testValidateEmail(): void { Queue::fake(); $public_domains = Domain::getPublicDomains(); $domain = reset($public_domains); $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $folder = $this->getTestSharedFolder('folder-event@kolab.org'); $folder->setAliases(['folder-alias1@kolab.org']); $folder_del = $this->getTestSharedFolder('folder-test@kolabnow.com'); $folder_del->setAliases(['folder-alias2@kolabnow.com']); $folder_del->delete(); $pub_group = $this->getTestGroup('group-test@kolabnow.com'); $pub_group->delete(); $priv_group = $this->getTestGroup('group-test@kolab.org'); $resource = $this->getTestResource('resource-test@kolabnow.com'); $resource->delete(); $cases = [ // valid (user domain) ["admin@kolab.org", $john, null], // valid (public domain) ["test.test@$domain", $john, null], // Invalid format ["$domain", $john, 'The specified email is invalid.'], [".@$domain", $john, 'The specified email is invalid.'], ["test123456@localhost", $john, 'The specified domain is invalid.'], ["test123456@unknown-domain.org", $john, 'The specified domain is invalid.'], ["$domain", $john, 'The specified email is invalid.'], [".@$domain", $john, 'The specified email is invalid.'], // forbidden local part on public domains ["admin@$domain", $john, 'The specified email is not available.'], ["administrator@$domain", $john, 'The specified email is not available.'], // forbidden (other user's domain) ["testtest@kolab.org", $user, 'The specified domain is not available.'], // existing alias of other user ["jack.daniels@kolab.org", $john, 'The specified email is not available.'], // An existing shared folder or folder alias ["folder-event@kolab.org", $john, 'The specified email is not available.'], ["folder-alias1@kolab.org", $john, 'The specified email is not available.'], // A soft-deleted shared folder or folder alias ["folder-test@kolabnow.com", $john, 'The specified email is not available.'], ["folder-alias2@kolabnow.com", $john, 'The specified email is not available.'], // A group ["group-test@kolab.org", $john, 'The specified email is not available.'], // A soft-deleted group ["group-test@kolabnow.com", $john, 'The specified email is not available.'], // A resource ["resource-test1@kolab.org", $john, 'The specified email is not available.'], // A soft-deleted resource ["resource-test@kolabnow.com", $john, 'The specified email is not available.'], ]; foreach ($cases as $idx => $case) { list($email, $user, $expected) = $case; $deleted = null; $result = UsersController::validateEmail($email, $user, $deleted); $this->assertSame($expected, $result, "Case {$email}"); $this->assertNull($deleted, "Case {$email}"); } } /** * User email validation - tests for $deleted argument * * Note: Technically these include unit tests, but let's keep it here for now. * FIXME: Shall we do a http request for each case? */ public function testValidateEmailDeleted(): void { Queue::fake(); $john = $this->getTestUser('john@kolab.org'); $deleted_priv = $this->getTestUser('deleted@kolab.org'); $deleted_priv->delete(); $deleted_pub = $this->getTestUser('deleted@kolabnow.com'); $deleted_pub->delete(); $result = UsersController::validateEmail('deleted@kolab.org', $john, $deleted); $this->assertSame(null, $result); $this->assertSame($deleted_priv->id, $deleted->id); $result = UsersController::validateEmail('deleted@kolabnow.com', $john, $deleted); $this->assertSame('The specified email is not available.', $result); $this->assertSame(null, $deleted); $result = UsersController::validateEmail('jack@kolab.org', $john, $deleted); $this->assertSame('The specified email is not available.', $result); $this->assertSame(null, $deleted); $pub_group = $this->getTestGroup('group-test@kolabnow.com'); $priv_group = $this->getTestGroup('group-test@kolab.org'); // A group in a public domain, existing $result = UsersController::validateEmail($pub_group->email, $john, $deleted); $this->assertSame('The specified email is not available.', $result); $this->assertNull($deleted); $pub_group->delete(); // A group in a public domain, deleted $result = UsersController::validateEmail($pub_group->email, $john, $deleted); $this->assertSame('The specified email is not available.', $result); $this->assertNull($deleted); // A group in a private domain, existing $result = UsersController::validateEmail($priv_group->email, $john, $deleted); $this->assertSame('The specified email is not available.', $result); $this->assertNull($deleted); $priv_group->delete(); // A group in a private domain, deleted $result = UsersController::validateEmail($priv_group->email, $john, $deleted); $this->assertSame(null, $result); $this->assertSame($priv_group->id, $deleted->id); // TODO: Test the same with a resource and shared folder } /** * User email alias validation. * * Note: Technically these include unit tests, but let's keep it here for now. * FIXME: Shall we do a http request for each case? */ public function testValidateAlias(): void { Queue::fake(); $public_domains = Domain::getPublicDomains(); $domain = reset($public_domains); $john = $this->getTestUser('john@kolab.org'); $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $deleted_priv = $this->getTestUser('deleted@kolab.org'); $deleted_priv->setAliases(['deleted-alias@kolab.org']); $deleted_priv->delete(); $deleted_pub = $this->getTestUser('deleted@kolabnow.com'); $deleted_pub->setAliases(['deleted-alias@kolabnow.com']); $deleted_pub->delete(); $folder = $this->getTestSharedFolder('folder-event@kolab.org'); $folder->setAliases(['folder-alias1@kolab.org']); $folder_del = $this->getTestSharedFolder('folder-test@kolabnow.com'); $folder_del->setAliases(['folder-alias2@kolabnow.com']); $folder_del->delete(); $group_priv = $this->getTestGroup('group-test@kolab.org'); $group = $this->getTestGroup('group-test@kolabnow.com'); $group->delete(); $resource = $this->getTestResource('resource-test@kolabnow.com'); $resource->delete(); $cases = [ // Invalid format ["$domain", $john, 'The specified alias is invalid.'], [".@$domain", $john, 'The specified alias is invalid.'], ["test123456@localhost", $john, 'The specified domain is invalid.'], ["test123456@unknown-domain.org", $john, 'The specified domain is invalid.'], ["$domain", $john, 'The specified alias is invalid.'], [".@$domain", $john, 'The specified alias is invalid.'], // forbidden local part on public domains ["admin@$domain", $john, 'The specified alias is not available.'], ["administrator@$domain", $john, 'The specified alias is not available.'], // forbidden (other user's domain) ["testtest@kolab.org", $user, 'The specified domain is not available.'], // existing alias of other user, to be an alias, user in the same group account ["jack.daniels@kolab.org", $john, null], // existing user ["jack@kolab.org", $john, 'The specified alias is not available.'], // valid (user domain) ["admin@kolab.org", $john, null], // valid (public domain) ["test.test@$domain", $john, null], // An alias that was a user email before is allowed, but only for custom domains ["deleted@kolab.org", $john, null], ["deleted-alias@kolab.org", $john, null], ["deleted@kolabnow.com", $john, 'The specified alias is not available.'], ["deleted-alias@kolabnow.com", $john, 'The specified alias is not available.'], // An existing shared folder or folder alias ["folder-event@kolab.org", $john, 'The specified alias is not available.'], ["folder-alias1@kolab.org", $john, null], // A soft-deleted shared folder or folder alias ["folder-test@kolabnow.com", $john, 'The specified alias is not available.'], ["folder-alias2@kolabnow.com", $john, 'The specified alias is not available.'], // A group with the same email address exists ["group-test@kolab.org", $john, 'The specified alias is not available.'], // A soft-deleted group ["group-test@kolabnow.com", $john, 'The specified alias is not available.'], // A resource ["resource-test1@kolab.org", $john, 'The specified alias is not available.'], // A soft-deleted resource ["resource-test@kolabnow.com", $john, 'The specified alias is not available.'], ]; foreach ($cases as $idx => $case) { list($alias, $user, $expected) = $case; $result = UsersController::validateAlias($alias, $user); $this->assertSame($expected, $result, "Case {$alias}"); } } } diff --git a/src/tests/Feature/EntitlementTest.php b/src/tests/Feature/EntitlementTest.php index 3a918829..1fa07e1f 100644 --- a/src/tests/Feature/EntitlementTest.php +++ b/src/tests/Feature/EntitlementTest.php @@ -1,203 +1,245 @@ deleteTestUser('entitlement-test@kolabnow.com'); $this->deleteTestUser('entitled-user@custom-domain.com'); $this->deleteTestGroup('test-group@custom-domain.com'); $this->deleteTestDomain('custom-domain.com'); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('entitlement-test@kolabnow.com'); $this->deleteTestUser('entitled-user@custom-domain.com'); $this->deleteTestGroup('test-group@custom-domain.com'); $this->deleteTestDomain('custom-domain.com'); 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 */ public function testEntitlements(): void { $packageDomain = Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $packageKolab = Package::withEnvTenantContext()->where('title', 'kolab')->first(); $skuDomain = Sku::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $skuMailbox = Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); $owner = $this->getTestUser('entitlement-test@kolabnow.com'); $user = $this->getTestUser('entitled-user@custom-domain.com'); $domain = $this->getTestDomain( 'custom-domain.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ] ); $domain->assignPackage($packageDomain, $owner); $owner->assignPackage($packageKolab); $owner->assignPackage($packageKolab, $user); $wallet = $owner->wallets->first(); $this->assertCount(7, $owner->entitlements()->get()); $this->assertCount(1, $skuDomain->entitlements()->where('wallet_id', $wallet->id)->get()); $this->assertCount(2, $skuMailbox->entitlements()->where('wallet_id', $wallet->id)->get()); $this->assertCount(15, $wallet->entitlements); $this->backdateEntitlements( $owner->entitlements, Carbon::now()->subMonthsWithoutOverflow(1) ); $wallet->chargeEntitlements(); $this->assertTrue($wallet->fresh()->balance < 0); } /** * @todo This really should be in User or Wallet tests file */ public function testBillDeletedEntitlement(): void { $user = $this->getTestUser('entitlement-test@kolabnow.com'); $package = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $storage = \App\Sku::withEnvTenantContext()->where('title', 'storage')->first(); $user->assignPackage($package); // some additional SKUs so we have something to delete. $user->assignSku($storage, 4); // the mailbox, the groupware, the 5 original storage and the additional 4 $this->assertCount(11, $user->fresh()->entitlements); $wallet = $user->wallets()->first(); $backdate = Carbon::now()->subWeeks(7); $this->backdateEntitlements($user->entitlements, $backdate); $charge = $wallet->chargeEntitlements(); $this->assertSame(-1090, $wallet->balance); $balance = $wallet->balance; $discount = \App\Discount::withEnvTenantContext()->where('discount', 30)->first(); $wallet->discount()->associate($discount); $wallet->save(); $user->removeSku($storage, 4); // we expect the wallet to have been charged for ~3 weeks of use of // 4 deleted storage entitlements, it should also take discount into account $backdate->addMonthsWithoutOverflow(1); $diffInDays = $backdate->diffInDays(Carbon::now()); // entitlements-num * cost * discount * days-in-month $max = intval(4 * 25 * 0.7 * $diffInDays / 28); $min = intval(4 * 25 * 0.7 * $diffInDays / 31); $wallet->refresh(); $this->assertTrue($wallet->balance >= $balance - $max); $this->assertTrue($wallet->balance <= $balance - $min); $transactions = \App\Transaction::where('object_id', $wallet->id) ->where('object_type', \App\Wallet::class)->get(); // one round of the monthly invoicing, four sku deletions getting invoiced $this->assertCount(5, $transactions); // Test that deleting an entitlement on a degraded account costs nothing $balance = $wallet->balance; User::where('id', $user->id)->update(['status' => $user->status | User::STATUS_DEGRADED]); $backdate = Carbon::now()->subWeeks(7); $this->backdateEntitlements($user->entitlements()->get(), $backdate); $groupware = \App\Sku::withEnvTenantContext()->where('title', 'groupware')->first(); $entitlement = $wallet->entitlements()->where('sku_id', $groupware->id)->first(); $entitlement->delete(); $this->assertSame($wallet->refresh()->balance, $balance); } /** * Test EntitleableTrait::toString() */ public function testEntitleableTitle(): void { Queue::fake(); $packageDomain = Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $packageKolab = Package::withEnvTenantContext()->where('title', 'kolab')->first(); $user = $this->getTestUser('entitled-user@custom-domain.com'); $group = $this->getTestGroup('test-group@custom-domain.com'); $domain = $this->getTestDomain( 'custom-domain.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ] ); $wallet = $user->wallets->first(); $domain->assignPackage($packageDomain, $user); $user->assignPackage($packageKolab); $group->assignToWallet($wallet); $sku_mailbox = \App\Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); $sku_group = \App\Sku::withEnvTenantContext()->where('title', 'group')->first(); $sku_domain = \App\Sku::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $entitlement = Entitlement::where('wallet_id', $wallet->id) ->where('sku_id', $sku_mailbox->id)->first(); $this->assertSame($user->email, $entitlement->entitleable->toString()); $entitlement = Entitlement::where('wallet_id', $wallet->id) ->where('sku_id', $sku_group->id)->first(); $this->assertSame($group->email, $entitlement->entitleable->toString()); $entitlement = Entitlement::where('wallet_id', $wallet->id) ->where('sku_id', $sku_domain->id)->first(); $this->assertSame($domain->namespace, $entitlement->entitleable->toString()); // Make sure it still works if the entitleable is deleted $domain->delete(); $entitlement->refresh(); $this->assertSame($domain->namespace, $entitlement->entitleable->toString()); $this->assertNotNull($entitlement->entitleable); } } diff --git a/src/tests/Feature/GroupTest.php b/src/tests/Feature/GroupTest.php index 044acc80..aba12a6d 100644 --- a/src/tests/Feature/GroupTest.php +++ b/src/tests/Feature/GroupTest.php @@ -1,398 +1,398 @@ deleteTestUser('user-test@kolabnow.com'); $this->deleteTestGroup('group-test@kolabnow.com'); } public function tearDown(): void { $this->deleteTestUser('user-test@kolabnow.com'); $this->deleteTestGroup('group-test@kolabnow.com'); parent::tearDown(); } /** * Tests for Group::assignToWallet() */ public function testAssignToWallet(): void { $user = $this->getTestUser('user-test@kolabnow.com'); $group = $this->getTestGroup('group-test@kolabnow.com'); $result = $group->assignToWallet($user->wallets->first()); $this->assertSame($group, $result); $this->assertSame(1, $group->entitlements()->count()); // Can't be done twice on the same group $this->expectException(\Exception::class); $result->assignToWallet($user->wallets->first()); } /** * Test Group::getConfig() and setConfig() methods */ public function testConfigTrait(): void { $group = $this->getTestGroup('group-test@kolabnow.com'); $group->setSetting('sender_policy', '["test","-"]'); $this->assertSame(['sender_policy' => ['test']], $group->getConfig()); $result = $group->setConfig(['sender_policy' => [], 'unknown' => false]); $this->assertSame(['sender_policy' => []], $group->getConfig()); $this->assertSame('[]', $group->getSetting('sender_policy')); $this->assertSame(['unknown' => "The requested configuration parameter is not supported."], $result); $result = $group->setConfig(['sender_policy' => ['test']]); $this->assertSame(['sender_policy' => ['test']], $group->getConfig()); $this->assertSame('["test","-"]', $group->getSetting('sender_policy')); $this->assertSame([], $result); } /** * Test creating a group */ public function testCreate(): void { Queue::fake(); $group = Group::create(['email' => 'GROUP-test@kolabnow.com']); $this->assertSame('group-test@kolabnow.com', $group->email); $this->assertSame('group-test', $group->name); $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, function ($job) use ($group) { $groupEmail = TestCase::getObjectProperty($job, 'groupEmail'); $groupId = TestCase::getObjectProperty($job, 'groupId'); return $groupEmail === $group->email && $groupId === $group->id; } ); } /** * Test group deletion and force-deletion */ public function testDelete(): void { Queue::fake(); $user = $this->getTestUser('user-test@kolabnow.com'); $group = $this->getTestGroup('group-test@kolabnow.com'); $group->assignToWallet($user->wallets->first()); $entitlements = \App\Entitlement::where('entitleable_id', $group->id); $this->assertSame(1, $entitlements->count()); $group->delete(); $this->assertTrue($group->fresh()->trashed()); $this->assertSame(0, $entitlements->count()); $this->assertSame(1, $entitlements->withTrashed()->count()); $group->forceDelete(); $this->assertSame(0, $entitlements->withTrashed()->count()); $this->assertCount(0, Group::withTrashed()->where('id', $group->id)->get()); Queue::assertPushed(\App\Jobs\Group\DeleteJob::class, 1); Queue::assertPushed( \App\Jobs\Group\DeleteJob::class, function ($job) use ($group) { $groupEmail = TestCase::getObjectProperty($job, 'groupEmail'); $groupId = TestCase::getObjectProperty($job, 'groupId'); return $groupEmail === $group->email && $groupId === $group->id; } ); } /** * Tests for Group::emailExists() */ public function testEmailExists(): void { Queue::fake(); $group = $this->getTestGroup('group-test@kolabnow.com'); $this->assertFalse(Group::emailExists('unknown@domain.tld')); $this->assertTrue(Group::emailExists($group->email)); $result = Group::emailExists($group->email, true); $this->assertSame($result->id, $group->id); $group->delete(); $this->assertTrue(Group::emailExists($group->email)); $result = Group::emailExists($group->email, true); $this->assertSame($result->id, $group->id); } /* * Test group restoring */ public function testRestore(): void { Queue::fake(); $user = $this->getTestUser('user-test@kolabnow.com'); $group = $this->getTestGroup('group-test@kolabnow.com'); $group->assignToWallet($user->wallets->first()); $entitlements = \App\Entitlement::where('entitleable_id', $group->id); $this->assertSame(1, $entitlements->count()); $group->delete(); $this->assertTrue($group->fresh()->trashed()); $this->assertSame(0, $entitlements->count()); $this->assertSame(1, $entitlements->withTrashed()->count()); Queue::fake(); $group->restore(); $group->refresh(); $this->assertFalse($group->trashed()); $this->assertFalse($group->isDeleted()); $this->assertFalse($group->isSuspended()); $this->assertFalse($group->isLdapReady()); $this->assertTrue($group->isActive()); $this->assertSame(1, $entitlements->count()); $entitlements->get()->each(function ($ent) { $this->assertTrue($ent->updated_at->greaterThan(\Carbon\Carbon::now()->subSeconds(5))); }); Queue::assertPushed(\App\Jobs\Group\CreateJob::class, 1); Queue::assertPushed( \App\Jobs\Group\CreateJob::class, function ($job) use ($group) { $groupEmail = TestCase::getObjectProperty($job, 'groupEmail'); $groupId = TestCase::getObjectProperty($job, 'groupId'); return $groupEmail === $group->email && $groupId === $group->id; } ); } /** * Tests for GroupSettingsTrait functionality and GroupSettingObserver */ public function testSettings(): void { Queue::fake(); Queue::assertNothingPushed(); $group = $this->getTestGroup('group-test@kolabnow.com'); Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 0); // Add a setting $group->setSetting('unknown', 'test'); Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 0); // Add a setting that is synced to LDAP $group->setSetting('sender_policy', '[]'); Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 1); // Note: We test both current group as well as fresh group object // to make sure cache works as expected $this->assertSame('test', $group->getSetting('unknown')); $this->assertSame('[]', $group->fresh()->getSetting('sender_policy')); Queue::fake(); // Update a setting $group->setSetting('unknown', 'test1'); Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 0); // Update a setting that is synced to LDAP $group->setSetting('sender_policy', '["-"]'); Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 1); $this->assertSame('test1', $group->getSetting('unknown')); $this->assertSame('["-"]', $group->fresh()->getSetting('sender_policy')); Queue::fake(); // Delete a setting (null) $group->setSetting('unknown', null); Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 0); // Delete a setting that is synced to LDAP $group->setSetting('sender_policy', null); Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 1); $this->assertSame(null, $group->getSetting('unknown')); $this->assertSame(null, $group->fresh()->getSetting('sender_policy')); } /** * Test group status assignment and is*() methods */ public function testStatus(): void { $group = new Group(); $this->assertSame(false, $group->isNew()); $this->assertSame(false, $group->isActive()); $this->assertSame(false, $group->isDeleted()); $this->assertSame(false, $group->isLdapReady()); $this->assertSame(false, $group->isSuspended()); $group->status = Group::STATUS_NEW; $this->assertSame(true, $group->isNew()); $this->assertSame(false, $group->isActive()); $this->assertSame(false, $group->isDeleted()); $this->assertSame(false, $group->isLdapReady()); $this->assertSame(false, $group->isSuspended()); $group->status |= Group::STATUS_ACTIVE; $this->assertSame(true, $group->isNew()); $this->assertSame(true, $group->isActive()); $this->assertSame(false, $group->isDeleted()); $this->assertSame(false, $group->isLdapReady()); $this->assertSame(false, $group->isSuspended()); $group->status |= Group::STATUS_LDAP_READY; $this->assertSame(true, $group->isNew()); $this->assertSame(true, $group->isActive()); $this->assertSame(false, $group->isDeleted()); $this->assertSame(true, $group->isLdapReady()); $this->assertSame(false, $group->isSuspended()); $group->status |= Group::STATUS_DELETED; $this->assertSame(true, $group->isNew()); $this->assertSame(true, $group->isActive()); $this->assertSame(true, $group->isDeleted()); $this->assertSame(true, $group->isLdapReady()); $this->assertSame(false, $group->isSuspended()); $group->status |= Group::STATUS_SUSPENDED; $this->assertSame(true, $group->isNew()); $this->assertSame(true, $group->isActive()); $this->assertSame(true, $group->isDeleted()); $this->assertSame(true, $group->isLdapReady()); $this->assertSame(true, $group->isSuspended()); // Unknown status value $this->expectException(\Exception::class); $group->status = 111; } /** * Tests for Group::suspend() */ public function testSuspend(): void { Queue::fake(); $group = $this->getTestGroup('group-test@kolabnow.com'); $group->suspend(); $this->assertTrue($group->isSuspended()); Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 1); Queue::assertPushed( \App\Jobs\Group\UpdateJob::class, function ($job) use ($group) { $groupEmail = TestCase::getObjectProperty($job, 'groupEmail'); $groupId = TestCase::getObjectProperty($job, 'groupId'); return $groupEmail === $group->email && $groupId === $group->id; } ); } /** * Test updating a group */ public function testUpdate(): void { Queue::fake(); $group = $this->getTestGroup('group-test@kolabnow.com'); $group->status |= Group::STATUS_DELETED; $group->save(); Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 1); Queue::assertPushed( \App\Jobs\Group\UpdateJob::class, function ($job) use ($group) { $groupEmail = TestCase::getObjectProperty($job, 'groupEmail'); $groupId = TestCase::getObjectProperty($job, 'groupId'); return $groupEmail === $group->email && $groupId === $group->id; } ); } /** * Tests for Group::unsuspend() */ public function testUnsuspend(): void { Queue::fake(); $group = $this->getTestGroup('group-test@kolabnow.com'); $group->status = Group::STATUS_SUSPENDED; $group->unsuspend(); $this->assertFalse($group->isSuspended()); Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 1); Queue::assertPushed( \App\Jobs\Group\UpdateJob::class, function ($job) use ($group) { $groupEmail = TestCase::getObjectProperty($job, 'groupEmail'); $groupId = TestCase::getObjectProperty($job, 'groupId'); return $groupEmail === $group->email && $groupId === $group->id; } ); } } diff --git a/src/tests/Feature/Jobs/Group/DeleteTest.php b/src/tests/Feature/Jobs/Group/DeleteTest.php index 1a716173..1e8af992 100644 --- a/src/tests/Feature/Jobs/Group/DeleteTest.php +++ b/src/tests/Feature/Jobs/Group/DeleteTest.php @@ -1,60 +1,73 @@ deleteTestGroup('group@kolab.org'); } public function tearDown(): void { $this->deleteTestGroup('group@kolab.org'); parent::tearDown(); } /** * Test job handle * * @group ldap */ public function testHandle(): void { $group = $this->getTestGroup('group@kolab.org', [ 'members' => [], 'status' => Group::STATUS_NEW ]); // create to domain first $job = new \App\Jobs\Group\CreateJob($group->id); $job->handle(); $this->assertTrue($group->fresh()->isLdapReady()); + Queue::fake(); + $job = new \App\Jobs\Group\DeleteJob($group->id); $job->handle(); $group->refresh(); $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(); $this->assertTrue($job->hasFailed()); $this->assertSame("Group 123 could not be found in the database.", $job->failureMessage); } } diff --git a/src/tests/Feature/Jobs/Resource/CreateTest.php b/src/tests/Feature/Jobs/Resource/CreateTest.php index b7ebfe9e..3da1cd8b 100644 --- a/src/tests/Feature/Jobs/Resource/CreateTest.php +++ b/src/tests/Feature/Jobs/Resource/CreateTest.php @@ -1,84 +1,89 @@ deleteTestResource('resource-test@' . \config('app.domain')); } public function tearDown(): void { $this->deleteTestResource('resource-test@' . \config('app.domain')); parent::tearDown(); } /** * Test job handle * * @group ldap + * @group imap */ public function testHandle(): void { Queue::fake(); // Test unknown resource $this->expectException(\Exception::class); $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')); + $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(); $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 + // 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 index 6051af32..a4c950a3 100644 --- a/src/tests/Feature/Jobs/Resource/DeleteTest.php +++ b/src/tests/Feature/Jobs/Resource/DeleteTest.php @@ -1,76 +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 + * @group imap */ 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()); + $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(); $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 index 8b509a86..a2b8a4fd 100644 --- a/src/tests/Feature/Jobs/Resource/UpdateTest.php +++ b/src/tests/Feature/Jobs/Resource/UpdateTest.php @@ -1,82 +1,80 @@ 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 + * @group imap */ 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')); + $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); $job->handle(); $ldap_resource = LDAP::getResource($resource->email); $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); $job->handle(); $this->assertTrue($job->isDeleted()); } } diff --git a/src/tests/Feature/Jobs/SharedFolder/CreateTest.php b/src/tests/Feature/Jobs/SharedFolder/CreateTest.php index 389f5ab4..38bc1ed2 100644 --- a/src/tests/Feature/Jobs/SharedFolder/CreateTest.php +++ b/src/tests/Feature/Jobs/SharedFolder/CreateTest.php @@ -1,84 +1,89 @@ deleteTestSharedFolder('folder-test@' . \config('app.domain')); } public function tearDown(): void { $this->deleteTestSharedFolder('folder-test@' . \config('app.domain')); parent::tearDown(); } /** * Test job handle * * @group ldap + * @group imap */ public function testHandle(): void { Queue::fake(); // Test unknown folder $this->expectException(\Exception::class); $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')); + $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(); $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 + // 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 index 66f5d0c4..6901f3b7 100644 --- a/src/tests/Feature/Jobs/SharedFolder/DeleteTest.php +++ b/src/tests/Feature/Jobs/SharedFolder/DeleteTest.php @@ -1,76 +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 + * @group imap */ 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()); + $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(); $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 index 292726b5..8e9a70fc 100644 --- a/src/tests/Feature/Jobs/SharedFolder/UpdateTest.php +++ b/src/tests/Feature/Jobs/SharedFolder/UpdateTest.php @@ -1,78 +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 + * @group imap */ 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')); + $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); $job->handle(); $this->assertTrue($job->isDeleted()); } } diff --git a/src/tests/Feature/Jobs/User/CreateTest.php b/src/tests/Feature/Jobs/User/CreateTest.php index 5dc8412c..1da8999c 100644 --- a/src/tests/Feature/Jobs/User/CreateTest.php +++ b/src/tests/Feature/Jobs/User/CreateTest.php @@ -1,79 +1,84 @@ deleteTestUser('new-job-user@' . \config('app.domain')); } 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 { - $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(); $job = new \App\Jobs\User\CreateJob($user->id); $job->handle(); $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(); $job = new \App\Jobs\User\CreateJob($user->id); $job->handle(); $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 index 00000000..bea10b7e --- /dev/null +++ b/src/tests/Feature/Jobs/User/DeleteTest.php @@ -0,0 +1,98 @@ +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 + * @group roundcube + */ + public function testHandle(): void + { + Queue::fake(); + + $rcdb = Roundcube::dbh(); + + $user = $this->getTestUser('new-job-user@' . \config('app.domain')); + $rcuser = Roundcube::userId($user->email); + + // 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()); + $this->assertNotNull($rcdb->table('users')->where('username', $user->email)->first()); + + // 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, IMAP and Roundcube + $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()); + $this->assertNull($rcdb->table('users')->where('username', $user->email)->first()); + + 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 index 72776b67..0a5a7c45 100644 --- a/src/tests/Feature/Jobs/User/UpdateTest.php +++ b/src/tests/Feature/Jobs/User/UpdateTest.php @@ -1,94 +1,97 @@ 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 { // Ignore any jobs created here (e.g. on setAliases() use) Queue::fake(); $user = $this->getTestUser('new-job-user@' . \config('app.domain')); // Create the user in LDAP $job = new \App\Jobs\User\CreateJob($user->id); $job->handle(); // Test setting two aliases $aliases = [ 'new-job-user1@' . \config('app.domain'), 'new-job-user2@' . \config('app.domain'), ]; $user->setAliases($aliases); $job = new \App\Jobs\User\UpdateJob($user->id); $job->handle(); $ldap_user = LDAP::getUser('new-job-user@' . \config('app.domain')); $this->assertSame($aliases, $ldap_user['alias']); // Test updating aliases list $aliases = [ 'new-job-user1@' . \config('app.domain'), ]; $user->setAliases($aliases); $job = new \App\Jobs\User\UpdateJob($user->id); $job->handle(); $ldap_user = LDAP::getUser('new-job-user@' . \config('app.domain')); $this->assertSame($aliases, (array) $ldap_user['alias']); // Test unsetting aliases list $aliases = []; $user->setAliases($aliases); $job = new \App\Jobs\User\UpdateJob($user->id); $job->handle(); $ldap_user = LDAP::getUser('new-job-user@' . \config('app.domain')); $this->assertTrue(empty($ldap_user['alias'])); // Test non-existing user ID $job = new \App\Jobs\User\UpdateJob(123); $job->handle(); $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 index b5e71ec3..343018d2 100644 --- a/src/tests/Feature/ResourceTest.php +++ b/src/tests/Feature/ResourceTest.php @@ -1,352 +1,366 @@ deleteTestUser('user-test@kolabnow.com'); Resource::withTrashed()->where('email', 'like', '%@kolabnow.com')->each(function ($resource) { $this->deleteTestResource($resource->email); }); } public function tearDown(): void { $this->deleteTestUser('user-test@kolabnow.com'); Resource::withTrashed()->where('email', 'like', '%@kolabnow.com')->each(function ($resource) { $this->deleteTestResource($resource->email); }); parent::tearDown(); } /** * Tests for Resource::assignToWallet() */ public function testAssignToWallet(): void { $user = $this->getTestUser('user-test@kolabnow.com'); $resource = $this->getTestResource('resource-test@kolabnow.com'); $result = $resource->assignToWallet($user->wallets->first()); $this->assertSame($resource, $result); $this->assertSame(1, $resource->entitlements()->count()); // Can't be done twice on the same resource $this->expectException(\Exception::class); $result->assignToWallet($user->wallets->first()); } /** * Test Resource::getConfig() and setConfig() methods */ public function testConfigTrait(): void { Queue::fake(); $resource = new Resource(); $resource->email = 'resource-test@kolabnow.com'; $resource->name = 'Test'; $resource->save(); $john = $this->getTestUser('john@kolab.org'); $resource->assignToWallet($john->wallets->first()); $this->assertSame(['invitation_policy' => 'accept'], $resource->getConfig()); $result = $resource->setConfig(['invitation_policy' => 'reject', 'unknown' => false]); $this->assertSame(['invitation_policy' => 'reject'], $resource->getConfig()); $this->assertSame('reject', $resource->getSetting('invitation_policy')); $this->assertSame(['unknown' => "The requested configuration parameter is not supported."], $result); $result = $resource->setConfig(['invitation_policy' => 'unknown']); $this->assertSame(['invitation_policy' => 'reject'], $resource->getConfig()); $this->assertSame('reject', $resource->getSetting('invitation_policy')); $this->assertSame(['invitation_policy' => "The specified invitation policy is invalid."], $result); // Test valid user for manual invitation policy $result = $resource->setConfig(['invitation_policy' => 'manual:john@kolab.org']); $this->assertSame(['invitation_policy' => 'manual:john@kolab.org'], $resource->getConfig()); $this->assertSame('manual:john@kolab.org', $resource->getSetting('invitation_policy')); $this->assertSame([], $result); // Test invalid user email for manual invitation policy $result = $resource->setConfig(['invitation_policy' => 'manual:john']); $this->assertSame(['invitation_policy' => 'manual:john@kolab.org'], $resource->getConfig()); $this->assertSame('manual:john@kolab.org', $resource->getSetting('invitation_policy')); $this->assertSame(['invitation_policy' => "The specified email address is invalid."], $result); // Test non-existing user for manual invitation policy $result = $resource->setConfig(['invitation_policy' => 'manual:unknown@kolab.org']); $this->assertSame(['invitation_policy' => "The specified email address does not exist."], $result); // Test existing user from a different wallet, for manual invitation policy $result = $resource->setConfig(['invitation_policy' => 'manual:user@sample-tenant.dev-local']); $this->assertSame(['invitation_policy' => "The specified email address does not exist."], $result); } /** * Test creating a resource */ public function testCreate(): void { Queue::fake(); $resource = new Resource(); $resource->name = 'Reśo'; $resource->domainName = 'kolabnow.com'; $resource->save(); $this->assertMatchesRegularExpression('/^[0-9]{1,20}$/', $resource->id); $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()); $settings = $resource->settings()->get(); $this->assertCount(1, $settings); $this->assertSame('folder', $settings[0]->key); $this->assertSame('shared/Resources/Reśo@kolabnow.com', $settings[0]->value); Queue::assertPushed( \App\Jobs\Resource\CreateJob::class, function ($job) use ($resource) { $resourceEmail = TestCase::getObjectProperty($job, 'resourceEmail'); $resourceId = TestCase::getObjectProperty($job, 'resourceId'); return $resourceEmail === $resource->email && $resourceId === $resource->id; } ); - - Queue::assertPushedWithChain( - \App\Jobs\Resource\CreateJob::class, - [ - \App\Jobs\Resource\VerifyJob::class, - ] - ); } /** * Test resource deletion and force-deletion */ public function testDelete(): void { Queue::fake(); $user = $this->getTestUser('user-test@kolabnow.com'); $resource = $this->getTestResource('resource-test@kolabnow.com'); $resource->assignToWallet($user->wallets->first()); $entitlements = \App\Entitlement::where('entitleable_id', $resource->id); $this->assertSame(1, $entitlements->count()); $resource->delete(); $this->assertTrue($resource->fresh()->trashed()); $this->assertSame(0, $entitlements->count()); $this->assertSame(1, $entitlements->withTrashed()->count()); $resource->forceDelete(); $this->assertSame(0, $entitlements->withTrashed()->count()); $this->assertCount(0, Resource::withTrashed()->where('id', $resource->id)->get()); Queue::assertPushed(\App\Jobs\Resource\DeleteJob::class, 1); Queue::assertPushed( \App\Jobs\Resource\DeleteJob::class, function ($job) use ($resource) { $resourceEmail = TestCase::getObjectProperty($job, 'resourceEmail'); $resourceId = TestCase::getObjectProperty($job, 'resourceId'); return $resourceEmail === $resource->email && $resourceId === $resource->id; } ); } /** * Tests for Resource::emailExists() */ public function testEmailExists(): void { Queue::fake(); $resource = $this->getTestResource('resource-test@kolabnow.com'); $this->assertFalse(Resource::emailExists('unknown@domain.tld')); $this->assertTrue(Resource::emailExists($resource->email)); $result = Resource::emailExists($resource->email, true); $this->assertSame($result->id, $resource->id); $resource->delete(); $this->assertTrue(Resource::emailExists($resource->email)); $result = Resource::emailExists($resource->email, true); $this->assertSame($result->id, $resource->id); } /** * Tests for SettingsTrait functionality and ResourceSettingObserver */ public function testSettings(): void { Queue::fake(); Queue::assertNothingPushed(); $resource = $this->getTestResource('resource-test@kolabnow.com'); Queue::assertPushed(\App\Jobs\Resource\UpdateJob::class, 0); // Add a setting $resource->setSetting('unknown', 'test'); Queue::assertPushed(\App\Jobs\Resource\UpdateJob::class, 0); // Add a setting that is synced to LDAP $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 $this->assertSame('test', $resource->getSetting('unknown')); $this->assertSame('accept', $resource->fresh()->getSetting('invitation_policy')); Queue::fake(); // Update a setting $resource->setSetting('unknown', 'test1'); Queue::assertPushed(\App\Jobs\Resource\UpdateJob::class, 0); // Update a setting that is synced to LDAP $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')); Queue::fake(); // Delete a setting (null) $resource->setSetting('unknown', null); Queue::assertPushed(\App\Jobs\Resource\UpdateJob::class, 0); // Delete a setting that is synced to LDAP $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')); } /** * Test resource status assignment and is*() methods */ public function testStatus(): void { $resource = new Resource(); $this->assertSame(false, $resource->isNew()); $this->assertSame(false, $resource->isActive()); $this->assertSame(false, $resource->isDeleted()); $this->assertSame(false, $resource->isLdapReady()); $this->assertSame(false, $resource->isImapReady()); $resource->status = Resource::STATUS_NEW; $this->assertSame(true, $resource->isNew()); $this->assertSame(false, $resource->isActive()); $this->assertSame(false, $resource->isDeleted()); $this->assertSame(false, $resource->isLdapReady()); $this->assertSame(false, $resource->isImapReady()); $resource->status |= Resource::STATUS_ACTIVE; $this->assertSame(true, $resource->isNew()); $this->assertSame(true, $resource->isActive()); $this->assertSame(false, $resource->isDeleted()); $this->assertSame(false, $resource->isLdapReady()); $this->assertSame(false, $resource->isImapReady()); $resource->status |= Resource::STATUS_LDAP_READY; $this->assertSame(true, $resource->isNew()); $this->assertSame(true, $resource->isActive()); $this->assertSame(false, $resource->isDeleted()); $this->assertSame(true, $resource->isLdapReady()); $this->assertSame(false, $resource->isImapReady()); $resource->status |= Resource::STATUS_DELETED; $this->assertSame(true, $resource->isNew()); $this->assertSame(true, $resource->isActive()); $this->assertSame(true, $resource->isDeleted()); $this->assertSame(true, $resource->isLdapReady()); $this->assertSame(false, $resource->isImapReady()); $resource->status |= Resource::STATUS_IMAP_READY; $this->assertSame(true, $resource->isNew()); $this->assertSame(true, $resource->isActive()); $this->assertSame(true, $resource->isDeleted()); $this->assertSame(true, $resource->isLdapReady()); $this->assertSame(true, $resource->isImapReady()); // Unknown status value $this->expectException(\Exception::class); $resource->status = 111; } /** * Test updating a resource */ public function testUpdate(): void { Queue::fake(); $resource = $this->getTestResource('resource-test@kolabnow.com'); $resource->name = 'New'; $resource->save(); // Assert the folder changes on a resource name change $settings = $resource->settings()->where('key', 'folder')->get(); $this->assertCount(1, $settings); $this->assertSame('shared/Resources/New@kolabnow.com', $settings[0]->value); Queue::assertPushed(\App\Jobs\Resource\UpdateJob::class, 1); Queue::assertPushed( \App\Jobs\Resource\UpdateJob::class, function ($job) use ($resource) { $resourceEmail = TestCase::getObjectProperty($job, 'resourceEmail'); $resourceId = TestCase::getObjectProperty($job, 'resourceId'); return $resourceEmail === $resource->email && $resourceId === $resource->id; } ); } } diff --git a/src/tests/Feature/SharedFolderTest.php b/src/tests/Feature/SharedFolderTest.php index e520aff0..e5dde92a 100644 --- a/src/tests/Feature/SharedFolderTest.php +++ b/src/tests/Feature/SharedFolderTest.php @@ -1,358 +1,372 @@ 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 AliasesTrait methods */ public function testAliases(): void { Queue::fake(); Queue::assertNothingPushed(); $folder = $this->getTestSharedFolder('folder-test@kolabnow.com'); $this->assertCount(0, $folder->aliases->all()); // Add an alias $folder->setAliases(['FolderAlias1@kolabnow.com']); Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 1); $aliases = $folder->aliases()->get(); $this->assertCount(1, $aliases); $this->assertSame('folderalias1@kolabnow.com', $aliases[0]->alias); $this->assertTrue(SharedFolder::aliasExists('folderalias1@kolabnow.com')); // Add another alias $folder->setAliases(['FolderAlias1@kolabnow.com', 'FolderAlias2@kolabnow.com']); Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 2); $aliases = $folder->aliases()->orderBy('alias')->get(); $this->assertCount(2, $aliases); $this->assertSame('folderalias1@kolabnow.com', $aliases[0]->alias); $this->assertSame('folderalias2@kolabnow.com', $aliases[1]->alias); // Remove an alias $folder->setAliases(['FolderAlias1@kolabnow.com']); Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 3); $aliases = $folder->aliases()->get(); $this->assertCount(1, $aliases); $this->assertSame('folderalias1@kolabnow.com', $aliases[0]->alias); $this->assertFalse(SharedFolder::aliasExists('folderalias2@kolabnow.com')); // Remove all aliases $folder->setAliases([]); Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 4); $this->assertCount(0, $folder->aliases()->get()); $this->assertFalse(SharedFolder::aliasExists('folderalias1@kolabnow.com')); $this->assertFalse(SharedFolder::aliasExists('folderalias2@kolabnow.com')); } /** * 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->domainName = '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->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); + 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 $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); + 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')); 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); + 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')); } /** * 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 index 4424be4b..ecf3f9cf 100644 --- a/src/tests/Feature/UserTest.php +++ b/src/tests/Feature/UserTest.php @@ -1,1404 +1,1381 @@ deleteTestUser('user-test@' . \config('app.domain')); $this->deleteTestUser('UserAccountA@UserAccount.com'); $this->deleteTestUser('UserAccountB@UserAccount.com'); $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'); } /** * {@inheritDoc} */ public function tearDown(): void { \App\TenantSetting::truncate(); $this->deleteTestUser('user-test@' . \config('app.domain')); $this->deleteTestUser('UserAccountA@UserAccount.com'); $this->deleteTestUser('UserAccountB@UserAccount.com'); $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'); parent::tearDown(); } /** * Tests for User::assignPackage() */ public function testAssignPackage(): void { $user = $this->getTestUser('user-test@' . \config('app.domain')); $wallet = $user->wallets()->first(); $package = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $user->assignPackage($package); $sku = \App\Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); $entitlement = \App\Entitlement::where('wallet_id', $wallet->id) ->where('sku_id', $sku->id)->first(); $this->assertNotNull($entitlement); $this->assertSame($sku->id, $entitlement->sku->id); $this->assertSame($wallet->id, $entitlement->wallet->id); $this->assertEquals($user->id, $entitlement->entitleable->id); $this->assertTrue($entitlement->entitleable instanceof \App\User); $this->assertCount(7, $user->entitlements()->get()); } /** * Tests for User::assignPlan() */ public function testAssignPlan(): void { $this->markTestIncomplete(); } /** * Tests for User::assignSku() */ public function testAssignSku(): void { $this->markTestIncomplete(); } /** * Verify a wallet assigned a controller is among the accounts of the assignee. */ public function testAccounts(): void { $userA = $this->getTestUser('UserAccountA@UserAccount.com'); $userB = $this->getTestUser('UserAccountB@UserAccount.com'); $this->assertTrue($userA->wallets()->count() == 1); $userA->wallets()->each( function ($wallet) use ($userB) { $wallet->addController($userB); } ); $this->assertTrue($userB->accounts()->get()[0]->id === $userA->wallets()->get()[0]->id); } /** * Test User::canDelete() method */ public function testCanDelete(): void { $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $domain = $this->getTestDomain('kolab.org'); // Admin $this->assertTrue($admin->canDelete($admin)); $this->assertFalse($admin->canDelete($john)); $this->assertFalse($admin->canDelete($jack)); $this->assertFalse($admin->canDelete($reseller1)); $this->assertFalse($admin->canDelete($domain)); $this->assertFalse($admin->canDelete($domain->wallet())); // Reseller - kolabnow $this->assertFalse($reseller1->canDelete($john)); $this->assertFalse($reseller1->canDelete($jack)); $this->assertTrue($reseller1->canDelete($reseller1)); $this->assertFalse($reseller1->canDelete($domain)); $this->assertFalse($reseller1->canDelete($domain->wallet())); $this->assertFalse($reseller1->canDelete($admin)); // Normal user - account owner $this->assertTrue($john->canDelete($john)); $this->assertTrue($john->canDelete($ned)); $this->assertTrue($john->canDelete($jack)); $this->assertTrue($john->canDelete($domain)); $this->assertFalse($john->canDelete($domain->wallet())); $this->assertFalse($john->canDelete($reseller1)); $this->assertFalse($john->canDelete($admin)); // Normal user - a non-owner and non-controller $this->assertFalse($jack->canDelete($jack)); $this->assertFalse($jack->canDelete($john)); $this->assertFalse($jack->canDelete($domain)); $this->assertFalse($jack->canDelete($domain->wallet())); $this->assertFalse($jack->canDelete($reseller1)); $this->assertFalse($jack->canDelete($admin)); // Normal user - John's wallet controller $this->assertTrue($ned->canDelete($ned)); $this->assertTrue($ned->canDelete($john)); $this->assertTrue($ned->canDelete($jack)); $this->assertTrue($ned->canDelete($domain)); $this->assertFalse($ned->canDelete($domain->wallet())); $this->assertFalse($ned->canDelete($reseller1)); $this->assertFalse($ned->canDelete($admin)); } /** * Test User::canRead() method */ public function testCanRead(): void { $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $domain = $this->getTestDomain('kolab.org'); // Admin $this->assertTrue($admin->canRead($admin)); $this->assertTrue($admin->canRead($john)); $this->assertTrue($admin->canRead($jack)); $this->assertTrue($admin->canRead($reseller1)); $this->assertTrue($admin->canRead($reseller2)); $this->assertTrue($admin->canRead($domain)); $this->assertTrue($admin->canRead($domain->wallet())); // Reseller - kolabnow $this->assertTrue($reseller1->canRead($john)); $this->assertTrue($reseller1->canRead($jack)); $this->assertTrue($reseller1->canRead($reseller1)); $this->assertTrue($reseller1->canRead($domain)); $this->assertTrue($reseller1->canRead($domain->wallet())); $this->assertFalse($reseller1->canRead($reseller2)); $this->assertFalse($reseller1->canRead($admin)); // Reseller - different tenant $this->assertTrue($reseller2->canRead($reseller2)); $this->assertFalse($reseller2->canRead($john)); $this->assertFalse($reseller2->canRead($jack)); $this->assertFalse($reseller2->canRead($reseller1)); $this->assertFalse($reseller2->canRead($domain)); $this->assertFalse($reseller2->canRead($domain->wallet())); $this->assertFalse($reseller2->canRead($admin)); // Normal user - account owner $this->assertTrue($john->canRead($john)); $this->assertTrue($john->canRead($ned)); $this->assertTrue($john->canRead($jack)); $this->assertTrue($john->canRead($domain)); $this->assertTrue($john->canRead($domain->wallet())); $this->assertFalse($john->canRead($reseller1)); $this->assertFalse($john->canRead($reseller2)); $this->assertFalse($john->canRead($admin)); // Normal user - a non-owner and non-controller $this->assertTrue($jack->canRead($jack)); $this->assertFalse($jack->canRead($john)); $this->assertFalse($jack->canRead($domain)); $this->assertFalse($jack->canRead($domain->wallet())); $this->assertFalse($jack->canRead($reseller1)); $this->assertFalse($jack->canRead($reseller2)); $this->assertFalse($jack->canRead($admin)); // Normal user - John's wallet controller $this->assertTrue($ned->canRead($ned)); $this->assertTrue($ned->canRead($john)); $this->assertTrue($ned->canRead($jack)); $this->assertTrue($ned->canRead($domain)); $this->assertTrue($ned->canRead($domain->wallet())); $this->assertFalse($ned->canRead($reseller1)); $this->assertFalse($ned->canRead($reseller2)); $this->assertFalse($ned->canRead($admin)); } /** * Test User::canUpdate() method */ public function testCanUpdate(): void { $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $domain = $this->getTestDomain('kolab.org'); // Admin $this->assertTrue($admin->canUpdate($admin)); $this->assertTrue($admin->canUpdate($john)); $this->assertTrue($admin->canUpdate($jack)); $this->assertTrue($admin->canUpdate($reseller1)); $this->assertTrue($admin->canUpdate($reseller2)); $this->assertTrue($admin->canUpdate($domain)); $this->assertTrue($admin->canUpdate($domain->wallet())); // Reseller - kolabnow $this->assertTrue($reseller1->canUpdate($john)); $this->assertTrue($reseller1->canUpdate($jack)); $this->assertTrue($reseller1->canUpdate($reseller1)); $this->assertTrue($reseller1->canUpdate($domain)); $this->assertTrue($reseller1->canUpdate($domain->wallet())); $this->assertFalse($reseller1->canUpdate($reseller2)); $this->assertFalse($reseller1->canUpdate($admin)); // Reseller - different tenant $this->assertTrue($reseller2->canUpdate($reseller2)); $this->assertFalse($reseller2->canUpdate($john)); $this->assertFalse($reseller2->canUpdate($jack)); $this->assertFalse($reseller2->canUpdate($reseller1)); $this->assertFalse($reseller2->canUpdate($domain)); $this->assertFalse($reseller2->canUpdate($domain->wallet())); $this->assertFalse($reseller2->canUpdate($admin)); // Normal user - account owner $this->assertTrue($john->canUpdate($john)); $this->assertTrue($john->canUpdate($ned)); $this->assertTrue($john->canUpdate($jack)); $this->assertTrue($john->canUpdate($domain)); $this->assertFalse($john->canUpdate($domain->wallet())); $this->assertFalse($john->canUpdate($reseller1)); $this->assertFalse($john->canUpdate($reseller2)); $this->assertFalse($john->canUpdate($admin)); // Normal user - a non-owner and non-controller $this->assertTrue($jack->canUpdate($jack)); $this->assertFalse($jack->canUpdate($john)); $this->assertFalse($jack->canUpdate($domain)); $this->assertFalse($jack->canUpdate($domain->wallet())); $this->assertFalse($jack->canUpdate($reseller1)); $this->assertFalse($jack->canUpdate($reseller2)); $this->assertFalse($jack->canUpdate($admin)); // Normal user - John's wallet controller $this->assertTrue($ned->canUpdate($ned)); $this->assertTrue($ned->canUpdate($john)); $this->assertTrue($ned->canUpdate($jack)); $this->assertTrue($ned->canUpdate($domain)); $this->assertFalse($ned->canUpdate($domain->wallet())); $this->assertFalse($ned->canUpdate($reseller1)); $this->assertFalse($ned->canUpdate($reseller2)); $this->assertFalse($ned->canUpdate($admin)); } /** * Test user created/creating/updated observers */ public function testCreateAndUpdate(): void { Queue::fake(); $domain = \config('app.domain'); $user = User::create([ 'email' => 'USER-test@' . \strtoupper($domain), 'password' => 'test', ]); $result = User::where('email', "user-test@$domain")->first(); $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); Queue::assertPushed(\App\Jobs\PGP\KeyCreateJob::class, 0); Queue::assertPushed( \App\Jobs\User\CreateJob::class, function ($job) use ($user) { $userEmail = TestCase::getObjectProperty($job, 'userEmail'); $userId = TestCase::getObjectProperty($job, 'userId'); return $userEmail === $user->email && $userId === $user->id; } ); - 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"); \App\Tenant::find(\config('app.tenant_id'))->setSetting('pgp.enable', 1); $user = User::create(['email' => "user-test@$domain", 'password' => 'test']); Queue::assertPushed(\App\Jobs\PGP\KeyCreateJob::class, 1); Queue::assertPushed( \App\Jobs\PGP\KeyCreateJob::class, function ($job) use ($user) { $userEmail = TestCase::getObjectProperty($job, 'userEmail'); $userId = TestCase::getObjectProperty($job, 'userId'); return $userEmail === $user->email && $userId === $user->id; } ); // Update the user, test the password change $user->setSetting('password_expiration_warning', '2020-10-10 10:10:10'); $oldPassword = $user->password; $user->password = 'test123'; $user->save(); $this->assertNotEquals($oldPassword, $user->password); $this->assertSame(0, $user->passwords()->count()); $this->assertNull($user->getSetting('password_expiration_warning')); $this->assertMatchesRegularExpression( '/^' . now()->format('Y-m-d') . ' [0-9]{2}:[0-9]{2}:[0-9]{2}$/', $user->getSetting('password_update') ); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1); Queue::assertPushed( \App\Jobs\User\UpdateJob::class, function ($job) use ($user) { $userEmail = TestCase::getObjectProperty($job, 'userEmail'); $userId = TestCase::getObjectProperty($job, 'userId'); return $userEmail === $user->email && $userId === $user->id; } ); // Update the user, test the password history $user->setSetting('password_policy', 'last:3'); $oldPassword = $user->password; $user->password = 'test1234'; $user->save(); $this->assertSame(1, $user->passwords()->count()); $this->assertSame($oldPassword, $user->passwords()->first()->password); $user->password = 'test12345'; $user->save(); $oldPassword = $user->password; $user->password = 'test123456'; $user->save(); $this->assertSame(2, $user->passwords()->count()); $this->assertSame($oldPassword, $user->passwords()->latest()->first()->password); } /** * Tests for User::domains() */ public function testDomains(): void { $user = $this->getTestUser('john@kolab.org'); $domain = $this->getTestDomain('useraccount.com', [ 'status' => Domain::STATUS_NEW | Domain::STATUS_ACTIVE, 'type' => Domain::TYPE_PUBLIC, ]); $domains = $user->domains()->pluck('namespace')->all(); $this->assertContains($domain->namespace, $domains); $this->assertContains('kolab.org', $domains); // Jack is not the wallet controller, so for him the list should not // include John's domains, kolab.org specifically $user = $this->getTestUser('jack@kolab.org'); $domains = $user->domains()->pluck('namespace')->all(); $this->assertContains($domain->namespace, $domains); $this->assertNotContains('kolab.org', $domains); // Public domains of other tenants should not be returned $tenant = \App\Tenant::where('id', '!=', \config('app.tenant_id'))->first(); $domain->tenant_id = $tenant->id; $domain->save(); $domains = $user->domains()->pluck('namespace')->all(); $this->assertNotContains($domain->namespace, $domains); } /** * Test User::getConfig() and setConfig() methods */ public function testConfigTrait(): void { $user = $this->getTestUser('UserAccountA@UserAccount.com'); $user->setSetting('greylist_enabled', null); $user->setSetting('guam_enabled', null); $user->setSetting('password_policy', null); $user->setSetting('max_password_age', null); $user->setSetting('limit_geo', null); // greylist_enabled $this->assertSame(true, $user->getConfig()['greylist_enabled']); $result = $user->setConfig(['greylist_enabled' => false, 'unknown' => false]); $this->assertSame(['unknown' => "The requested configuration parameter is not supported."], $result); $this->assertSame(false, $user->getConfig()['greylist_enabled']); $this->assertSame('false', $user->getSetting('greylist_enabled')); $result = $user->setConfig(['greylist_enabled' => true]); $this->assertSame([], $result); $this->assertSame(true, $user->getConfig()['greylist_enabled']); $this->assertSame('true', $user->getSetting('greylist_enabled')); // guam_enabled $this->assertSame(false, $user->getConfig()['guam_enabled']); $result = $user->setConfig(['guam_enabled' => false]); $this->assertSame([], $result); $this->assertSame(false, $user->getConfig()['guam_enabled']); $this->assertSame(null, $user->getSetting('guam_enabled')); $result = $user->setConfig(['guam_enabled' => true]); $this->assertSame([], $result); $this->assertSame(true, $user->getConfig()['guam_enabled']); $this->assertSame('true', $user->getSetting('guam_enabled')); // max_apssword_age $this->assertSame(null, $user->getConfig()['max_password_age']); $result = $user->setConfig(['max_password_age' => -1]); $this->assertSame([], $result); $this->assertSame(null, $user->getConfig()['max_password_age']); $this->assertSame(null, $user->getSetting('max_password_age')); $result = $user->setConfig(['max_password_age' => 12]); $this->assertSame([], $result); $this->assertSame('12', $user->getConfig()['max_password_age']); $this->assertSame('12', $user->getSetting('max_password_age')); // password_policy $result = $user->setConfig(['password_policy' => true]); $this->assertSame(['password_policy' => "Specified password policy is invalid."], $result); $this->assertSame(null, $user->getConfig()['password_policy']); $this->assertSame(null, $user->getSetting('password_policy')); $result = $user->setConfig(['password_policy' => 'min:-1']); $this->assertSame(['password_policy' => "Specified password policy is invalid."], $result); $result = $user->setConfig(['password_policy' => 'min:-1']); $this->assertSame(['password_policy' => "Specified password policy is invalid."], $result); $result = $user->setConfig(['password_policy' => 'min:10,unknown']); $this->assertSame(['password_policy' => "Specified password policy is invalid."], $result); \config(['app.password_policy' => 'min:5,max:100']); $result = $user->setConfig(['password_policy' => 'min:4,max:255']); $this->assertSame(['password_policy' => "Minimum password length cannot be less than 5."], $result); \config(['app.password_policy' => 'min:5,max:100']); $result = $user->setConfig(['password_policy' => 'min:10,max:255']); $this->assertSame(['password_policy' => "Maximum password length cannot be more than 100."], $result); \config(['app.password_policy' => 'min:5,max:255']); $result = $user->setConfig(['password_policy' => 'min:10,max:255']); $this->assertSame([], $result); $this->assertSame('min:10,max:255', $user->getConfig()['password_policy']); $this->assertSame('min:10,max:255', $user->getSetting('password_policy')); // limit_geo $this->assertSame([], $user->getConfig()['limit_geo']); $result = $user->setConfig(['limit_geo' => '']); $err = "Specified configuration is invalid. Expected a list of two-letter country codes."; $this->assertSame(['limit_geo' => $err], $result); $this->assertSame(null, $user->getSetting('limit_geo')); $result = $user->setConfig(['limit_geo' => ['usa']]); $this->assertSame(['limit_geo' => $err], $result); $this->assertSame(null, $user->getSetting('limit_geo')); $result = $user->setConfig(['limit_geo' => []]); $this->assertSame([], $result); $this->assertSame(null, $user->getSetting('limit_geo')); $result = $user->setConfig(['limit_geo' => ['US', 'ru']]); $this->assertSame([], $result); $this->assertSame(['US', 'RU'], $user->getConfig()['limit_geo']); $this->assertSame('["US","RU"]', $user->getSetting('limit_geo')); } /** * Test user account degradation and un-degradation */ public function testDegradeAndUndegrade(): void { Queue::fake(); // Test an account with users, domain $userA = $this->getTestUser('UserAccountA@UserAccount.com'); $userB = $this->getTestUser('UserAccountB@UserAccount.com'); $package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $domain = $this->getTestDomain('UserAccount.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_HOSTED, ]); $userA->assignPackage($package_kolab); $domain->assignPackage($package_domain, $userA); $userA->assignPackage($package_kolab, $userB); $entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id); $entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id); $entitlementsDomain = \App\Entitlement::where('entitleable_id', $domain->id); $yesterday = Carbon::now()->subDays(1); $this->backdateEntitlements($entitlementsA->get(), $yesterday, Carbon::now()->subMonthsWithoutOverflow(1)); $this->backdateEntitlements($entitlementsB->get(), $yesterday, Carbon::now()->subMonthsWithoutOverflow(1)); $wallet = $userA->wallets->first(); $this->assertSame(7, $entitlementsA->count()); $this->assertSame(7, $entitlementsB->count()); $this->assertSame(7, $entitlementsA->whereDate('updated_at', $yesterday->toDateString())->count()); $this->assertSame(7, $entitlementsB->whereDate('updated_at', $yesterday->toDateString())->count()); $this->assertSame(0, $wallet->balance); Queue::fake(); // reset queue state // Degrade the account/wallet owner $userA->degrade(); $entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id); $entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id); $this->assertTrue($userA->fresh()->isDegraded()); $this->assertTrue($userA->fresh()->isDegraded(true)); $this->assertFalse($userB->fresh()->isDegraded()); $this->assertTrue($userB->fresh()->isDegraded(true)); $balance = $wallet->fresh()->balance; $this->assertTrue($balance <= -64); $this->assertSame(7, $entitlementsA->whereDate('updated_at', Carbon::now()->toDateString())->count()); $this->assertSame(7, $entitlementsB->whereDate('updated_at', Carbon::now()->toDateString())->count()); // Expect one update job for every user // @phpstan-ignore-next-line $userIds = Queue::pushed(\App\Jobs\User\UpdateJob::class)->map(function ($job) { return TestCase::getObjectProperty($job, 'userId'); })->all(); $this->assertSame([$userA->id, $userB->id], $userIds); // Un-Degrade the account/wallet owner $entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id); $entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id); $yesterday = Carbon::now()->subDays(1); $this->backdateEntitlements($entitlementsA->get(), $yesterday, Carbon::now()->subMonthsWithoutOverflow(1)); $this->backdateEntitlements($entitlementsB->get(), $yesterday, Carbon::now()->subMonthsWithoutOverflow(1)); Queue::fake(); // reset queue state $userA->undegrade(); $this->assertFalse($userA->fresh()->isDegraded()); $this->assertFalse($userA->fresh()->isDegraded(true)); $this->assertFalse($userB->fresh()->isDegraded()); $this->assertFalse($userB->fresh()->isDegraded(true)); // Expect no balance change, degraded account entitlements are free $this->assertSame($balance, $wallet->fresh()->balance); $this->assertSame(7, $entitlementsA->whereDate('updated_at', Carbon::now()->toDateString())->count()); $this->assertSame(7, $entitlementsB->whereDate('updated_at', Carbon::now()->toDateString())->count()); // Expect one update job for every user // @phpstan-ignore-next-line $userIds = Queue::pushed(\App\Jobs\User\UpdateJob::class)->map(function ($job) { return TestCase::getObjectProperty($job, 'userId'); })->all(); $this->assertSame([$userA->id, $userB->id], $userIds); } /** * Test user deletion */ public function testDelete(): void { Queue::fake(); $user = $this->getTestUser('user-test@' . \config('app.domain')); $package = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $user->assignPackage($package); $id = $user->id; $this->assertCount(7, $user->entitlements()->get()); $user->delete(); $this->assertCount(0, $user->entitlements()->get()); $this->assertTrue($user->fresh()->trashed()); $this->assertFalse($user->fresh()->isDeleted()); // Delete the user for real $job = new \App\Jobs\User\DeleteJob($id); $job->handle(); $this->assertTrue(User::withTrashed()->where('id', $id)->first()->isDeleted()); $user->forceDelete(); $this->assertCount(0, User::withTrashed()->where('id', $id)->get()); // Test an account with users, domain, and group, and resource $userA = $this->getTestUser('UserAccountA@UserAccount.com'); $userB = $this->getTestUser('UserAccountB@UserAccount.com'); $userC = $this->getTestUser('UserAccountC@UserAccount.com'); $package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $domain = $this->getTestDomain('UserAccount.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_HOSTED, ]); $userA->assignPackage($package_kolab); $domain->assignPackage($package_domain, $userA); $userA->assignPackage($package_kolab, $userB); $userA->assignPackage($package_kolab, $userC); $group = $this->getTestGroup('test-group@UserAccount.com'); $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); $entitlementsC = \App\Entitlement::where('entitleable_id', $userC->id); $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()); $this->assertSame(7, $entitlementsC->count()); $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(); $this->assertTrue($userC->fresh()->trashed()); $this->assertFalse($userC->fresh()->isDeleted()); $this->assertSame(0, $entitlementsC->count()); // Delete the controller (and expect "sub"-users to be deleted too) $userA->delete(); $this->assertSame(0, $entitlementsA->count()); $this->assertSame(0, $entitlementsB->count()); $this->assertSame(0, $entitlementsDomain->count()); $this->assertSame(0, $entitlementsGroup->count()); $this->assertSame(0, $entitlementsResource->count()); $this->assertSame(0, $entitlementsFolder->count()); $this->assertSame(7, $entitlementsA->withTrashed()->count()); $this->assertSame(7, $entitlementsB->withTrashed()->count()); $this->assertSame(7, $entitlementsC->withTrashed()->count()); $this->assertSame(1, $entitlementsDomain->withTrashed()->count()); $this->assertSame(1, $entitlementsGroup->withTrashed()->count()); $this->assertSame(1, $entitlementsResource->withTrashed()->count()); $this->assertSame(1, $entitlementsFolder->withTrashed()->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(); $all_entitlements = \App\Entitlement::where('wallet_id', $userA->wallets->first()->id); $transactions = \App\Transaction::where('object_id', $userA->wallets->first()->id); $this->assertSame(0, $all_entitlements->withTrashed()->count()); $this->assertSame(0, $transactions->count()); $this->assertCount(0, User::withTrashed()->where('id', $userA->id)->get()); $this->assertCount(0, User::withTrashed()->where('id', $userB->id)->get()); $this->assertCount(0, User::withTrashed()->where('id', $userC->id)->get()); $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()); } /** * Test user deletion vs. group membership */ public function testDeleteAndGroups(): void { Queue::fake(); $package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $userA = $this->getTestUser('UserAccountA@UserAccount.com'); $userB = $this->getTestUser('UserAccountB@UserAccount.com'); $userA->assignPackage($package_kolab, $userB); $group = $this->getTestGroup('test-group@UserAccount.com'); $group->members = ['test@gmail.com', $userB->email]; $group->assignToWallet($userA->wallets->first()); $group->save(); Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 1); $userGroups = $userA->groups()->get(); $this->assertSame(1, $userGroups->count()); $this->assertSame($group->id, $userGroups->first()->id); $userB->delete(); $this->assertSame(['test@gmail.com'], $group->fresh()->members); // Twice, one for save() and one for delete() above Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 2); } /** * Test handling negative balance on user deletion */ public function testDeleteWithNegativeBalance(): void { $user = $this->getTestUser('user-test@' . \config('app.domain')); $wallet = $user->wallets()->first(); $wallet->balance = -1000; $wallet->save(); $reseller_wallet = $user->tenant->wallet(); $reseller_wallet->balance = 0; $reseller_wallet->save(); \App\Transaction::where('object_id', $reseller_wallet->id)->where('object_type', \App\Wallet::class)->delete(); $user->delete(); $reseller_transactions = \App\Transaction::where('object_id', $reseller_wallet->id) ->where('object_type', \App\Wallet::class)->get(); $this->assertSame(-1000, $reseller_wallet->fresh()->balance); $this->assertCount(1, $reseller_transactions); $trans = $reseller_transactions[0]; $this->assertSame("Deleted user {$user->email}", $trans->description); $this->assertSame(-1000, $trans->amount); $this->assertSame(\App\Transaction::WALLET_DEBIT, $trans->type); } /** * Test handling positive balance on user deletion */ public function testDeleteWithPositiveBalance(): void { $user = $this->getTestUser('user-test@' . \config('app.domain')); $wallet = $user->wallets()->first(); $wallet->balance = 1000; $wallet->save(); $reseller_wallet = $user->tenant->wallet(); $reseller_wallet->balance = 0; $reseller_wallet->save(); $user->delete(); $this->assertSame(0, $reseller_wallet->fresh()->balance); } /** * Test user deletion with PGP/WOAT enabled */ public function testDeleteWithPGP(): void { Queue::fake(); // Test with PGP disabled $user = $this->getTestUser('user-test@' . \config('app.domain')); $user->tenant->setSetting('pgp.enable', 0); $user->delete(); Queue::assertPushed(\App\Jobs\PGP\KeyDeleteJob::class, 0); // Test with PGP enabled $this->deleteTestUser('user-test@' . \config('app.domain')); $user = $this->getTestUser('user-test@' . \config('app.domain')); $user->tenant->setSetting('pgp.enable', 1); $user->delete(); $user->tenant->setSetting('pgp.enable', 0); Queue::assertPushed(\App\Jobs\PGP\KeyDeleteJob::class, 1); Queue::assertPushed( \App\Jobs\PGP\KeyDeleteJob::class, function ($job) use ($user) { $userId = TestCase::getObjectProperty($job, 'userId'); $userEmail = TestCase::getObjectProperty($job, 'userEmail'); return $userId == $user->id && $userEmail === $user->email; } ); } /** * Test user deletion vs. rooms */ public function testDeleteWithRooms(): void { $this->markTestIncomplete(); } /** * Tests for User::aliasExists() */ public function testAliasExists(): void { $this->assertTrue(User::aliasExists('jack.daniels@kolab.org')); $this->assertFalse(User::aliasExists('j.daniels@kolab.org')); $this->assertFalse(User::aliasExists('john@kolab.org')); } /** * Tests for User::emailExists() */ public function testEmailExists(): void { $this->assertFalse(User::emailExists('jack.daniels@kolab.org')); $this->assertFalse(User::emailExists('j.daniels@kolab.org')); $this->assertTrue(User::emailExists('john@kolab.org')); $user = User::emailExists('john@kolab.org', true); $this->assertSame('john@kolab.org', $user->email); } /** * Tests for User::findByEmail() */ public function testFindByEmail(): void { $user = $this->getTestUser('john@kolab.org'); $result = User::findByEmail('john'); $this->assertNull($result); $result = User::findByEmail('non-existing@email.com'); $this->assertNull($result); $result = User::findByEmail('john@kolab.org'); $this->assertInstanceOf(User::class, $result); $this->assertSame($user->id, $result->id); // Use an alias $result = User::findByEmail('john.doe@kolab.org'); $this->assertInstanceOf(User::class, $result); $this->assertSame($user->id, $result->id); Queue::fake(); // A case where two users have the same alias $ned = $this->getTestUser('ned@kolab.org'); $ned->setAliases(['joe.monster@kolab.org']); $result = User::findByEmail('joe.monster@kolab.org'); $this->assertNull($result); $ned->setAliases([]); // TODO: searching by external email (setting) $this->markTestIncomplete(); } /** - * Test User::hasSku() method + * Test User::hasSku() and countEntitlementsBySku() methods */ public function testHasSku(): void { $john = $this->getTestUser('john@kolab.org'); $this->assertTrue($john->hasSku('mailbox')); $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')); } /** * Test User::name() */ public function testName(): void { Queue::fake(); $user = $this->getTestUser('user-test@' . \config('app.domain')); $this->assertSame('', $user->name()); $this->assertSame($user->tenant->title . ' User', $user->name(true)); $user->setSetting('first_name', 'First'); $this->assertSame('First', $user->name()); $this->assertSame('First', $user->name(true)); $user->setSetting('last_name', 'Last'); $this->assertSame('First Last', $user->name()); $this->assertSame('First Last', $user->name(true)); } /** * Test resources() method */ public function testResources(): void { $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $resources = $john->resources()->orderBy('email')->get(); $this->assertSame(2, $resources->count()); $this->assertSame('resource-test1@kolab.org', $resources[0]->email); $this->assertSame('resource-test2@kolab.org', $resources[1]->email); $resources = $ned->resources()->orderBy('email')->get(); $this->assertSame(2, $resources->count()); $this->assertSame('resource-test1@kolab.org', $resources[0]->email); $this->assertSame('resource-test2@kolab.org', $resources[1]->email); $resources = $jack->resources()->get(); $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 */ public function testRestore(): void { Queue::fake(); // Test an account with users and domain $userA = $this->getTestUser('UserAccountA@UserAccount.com', [ 'status' => User::STATUS_LDAP_READY | User::STATUS_IMAP_READY | User::STATUS_SUSPENDED, ]); $userB = $this->getTestUser('UserAccountB@UserAccount.com'); $package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $domainA = $this->getTestDomain('UserAccount.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_HOSTED, ]); $domainB = $this->getTestDomain('UserAccountAdd.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_HOSTED, ]); $userA->assignPackage($package_kolab); $domainA->assignPackage($package_domain, $userA); $domainB->assignPackage($package_domain, $userA); $userA->assignPackage($package_kolab, $userB); $storage_sku = \App\Sku::withEnvTenantContext()->where('title', 'storage')->first(); $now = \Carbon\Carbon::now(); $wallet_id = $userA->wallets->first()->id; // add an extra storage entitlement $ent1 = \App\Entitlement::create([ 'wallet_id' => $wallet_id, 'sku_id' => $storage_sku->id, 'cost' => 0, 'entitleable_id' => $userA->id, 'entitleable_type' => User::class, ]); $entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id); $entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id); $entitlementsDomain = \App\Entitlement::where('entitleable_id', $domainA->id); // First delete the user $userA->delete(); $this->assertSame(0, $entitlementsA->count()); $this->assertSame(0, $entitlementsB->count()); $this->assertSame(0, $entitlementsDomain->count()); $this->assertTrue($userA->fresh()->trashed()); $this->assertTrue($userB->fresh()->trashed()); $this->assertTrue($domainA->fresh()->trashed()); $this->assertTrue($domainB->fresh()->trashed()); $this->assertFalse($userA->isDeleted()); $this->assertFalse($userB->isDeleted()); $this->assertFalse($domainA->isDeleted()); // Backdate one storage entitlement (it's not expected to be restored) \App\Entitlement::withTrashed()->where('id', $ent1->id) ->update(['deleted_at' => $now->copy()->subMinutes(2)]); // Backdate entitlements to assert that they were restored with proper updated_at timestamp \App\Entitlement::withTrashed()->where('wallet_id', $wallet_id) ->update(['updated_at' => $now->subMinutes(10)]); Queue::fake(); // Then restore it $userA->restore(); $userA->refresh(); $this->assertFalse($userA->trashed()); $this->assertFalse($userA->isDeleted()); $this->assertFalse($userA->isSuspended()); $this->assertFalse($userA->isLdapReady()); $this->assertFalse($userA->isImapReady()); $this->assertTrue($userA->isActive()); $this->assertTrue($userB->fresh()->trashed()); $this->assertTrue($domainB->fresh()->trashed()); $this->assertFalse($domainA->fresh()->trashed()); // Assert entitlements $this->assertSame(7, $entitlementsA->count()); // mailbox + groupware + 5 x storage $this->assertTrue($ent1->fresh()->trashed()); $entitlementsA->get()->each(function ($ent) { $this->assertTrue($ent->updated_at->greaterThan(\Carbon\Carbon::now()->subSeconds(5))); }); // We expect only CreateJob + UpdateJob pair for both user and domain. // Because how Illuminate/Database/Eloquent/SoftDeletes::restore() method // is implemented we cannot skip the UpdateJob in any way. // I don't want to overwrite this method, the extra job shouldn't do any harm. $this->assertCount(4, Queue::pushedJobs()); // @phpstan-ignore-line Queue::assertPushed(\App\Jobs\Domain\UpdateJob::class, 1); Queue::assertPushed(\App\Jobs\Domain\CreateJob::class, 1); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1); Queue::assertPushed(\App\Jobs\User\CreateJob::class, 1); Queue::assertPushed( \App\Jobs\User\CreateJob::class, function ($job) use ($userA) { return $userA->id === TestCase::getObjectProperty($job, 'userId'); } ); - Queue::assertPushedWithChain( - \App\Jobs\User\CreateJob::class, - [ - \App\Jobs\User\VerifyJob::class, - ] - ); } /** * Tests for AliasesTrait::setAliases() */ public function testSetAliases(): void { Queue::fake(); Queue::assertNothingPushed(); $user = $this->getTestUser('UserAccountA@UserAccount.com'); $domain = $this->getTestDomain('UserAccount.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_HOSTED, ]); $this->assertCount(0, $user->aliases->all()); $user->tenant->setSetting('pgp.enable', 1); // Add an alias $user->setAliases(['UserAlias1@UserAccount.com']); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1); Queue::assertPushed(\App\Jobs\PGP\KeyCreateJob::class, 1); $user->tenant->setSetting('pgp.enable', 0); $aliases = $user->aliases()->get(); $this->assertCount(1, $aliases); $this->assertSame('useralias1@useraccount.com', $aliases[0]['alias']); // Add another alias $user->setAliases(['UserAlias1@UserAccount.com', 'UserAlias2@UserAccount.com']); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 2); Queue::assertPushed(\App\Jobs\PGP\KeyCreateJob::class, 1); $aliases = $user->aliases()->orderBy('alias')->get(); $this->assertCount(2, $aliases); $this->assertSame('useralias1@useraccount.com', $aliases[0]->alias); $this->assertSame('useralias2@useraccount.com', $aliases[1]->alias); $user->tenant->setSetting('pgp.enable', 1); // Remove an alias $user->setAliases(['UserAlias1@UserAccount.com']); $user->tenant->setSetting('pgp.enable', 0); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 3); Queue::assertPushed(\App\Jobs\PGP\KeyDeleteJob::class, 1); Queue::assertPushed( \App\Jobs\PGP\KeyDeleteJob::class, function ($job) use ($user) { $userId = TestCase::getObjectProperty($job, 'userId'); $userEmail = TestCase::getObjectProperty($job, 'userEmail'); return $userId == $user->id && $userEmail === 'useralias2@useraccount.com'; } ); $aliases = $user->aliases()->get(); $this->assertCount(1, $aliases); $this->assertSame('useralias1@useraccount.com', $aliases[0]['alias']); // Remove all aliases $user->setAliases([]); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 4); $this->assertCount(0, $user->aliases()->get()); } /** * Tests for UserSettingsTrait::setSettings() and getSetting() and getSettings() */ public function testUserSettings(): void { Queue::fake(); Queue::assertNothingPushed(); $user = $this->getTestUser('UserAccountA@UserAccount.com'); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 0); // Test default settings // Note: Technicly this tests UserObserver::created() behavior $all_settings = $user->settings()->orderBy('key')->get(); $this->assertCount(2, $all_settings); $this->assertSame('country', $all_settings[0]->key); $this->assertSame('CH', $all_settings[0]->value); $this->assertSame('currency', $all_settings[1]->key); $this->assertSame('CHF', $all_settings[1]->value); // Add a setting $user->setSetting('first_name', 'Firstname'); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1); // Note: We test both current user as well as fresh user object // to make sure cache works as expected $this->assertSame('Firstname', $user->getSetting('first_name')); $this->assertSame('Firstname', $user->fresh()->getSetting('first_name')); // Update a setting $user->setSetting('first_name', 'Firstname1'); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 2); // Note: We test both current user as well as fresh user object // to make sure cache works as expected $this->assertSame('Firstname1', $user->getSetting('first_name')); $this->assertSame('Firstname1', $user->fresh()->getSetting('first_name')); // Delete a setting (null) $user->setSetting('first_name', null); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 3); // Note: We test both current user as well as fresh user object // to make sure cache works as expected $this->assertSame(null, $user->getSetting('first_name')); $this->assertSame(null, $user->fresh()->getSetting('first_name')); // Delete a setting (empty string) $user->setSetting('first_name', 'Firstname1'); $user->setSetting('first_name', ''); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 5); // Note: We test both current user as well as fresh user object // to make sure cache works as expected $this->assertSame(null, $user->getSetting('first_name')); $this->assertSame(null, $user->fresh()->getSetting('first_name')); // Set multiple settings at once $user->setSettings([ 'first_name' => 'Firstname2', 'last_name' => 'Lastname2', 'country' => null, ]); // TODO: This really should create a single UserUpdate job, not 3 Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 7); // Note: We test both current user as well as fresh user object // to make sure cache works as expected $this->assertSame('Firstname2', $user->getSetting('first_name')); $this->assertSame('Firstname2', $user->fresh()->getSetting('first_name')); $this->assertSame('Lastname2', $user->getSetting('last_name')); $this->assertSame('Lastname2', $user->fresh()->getSetting('last_name')); $this->assertSame(null, $user->getSetting('country')); $this->assertSame(null, $user->fresh()->getSetting('country')); $all_settings = $user->settings()->orderBy('key')->get(); $this->assertCount(3, $all_settings); // Test getSettings() method $this->assertSame( [ 'first_name' => 'Firstname2', 'last_name' => 'Lastname2', 'unknown' => null, ], $user->getSettings(['first_name', 'last_name', 'unknown']) ); } /** * Tests for User::users() */ public function testUsers(): void { $jack = $this->getTestUser('jack@kolab.org'); $joe = $this->getTestUser('joe@kolab.org'); $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $wallet = $john->wallets()->first(); $users = $john->users()->orderBy('email')->get(); $this->assertCount(4, $users); $this->assertEquals($jack->id, $users[0]->id); $this->assertEquals($joe->id, $users[1]->id); $this->assertEquals($john->id, $users[2]->id); $this->assertEquals($ned->id, $users[3]->id); $users = $jack->users()->orderBy('email')->get(); $this->assertCount(0, $users); $users = $ned->users()->orderBy('email')->get(); $this->assertCount(4, $users); } /** * Tests for User::walletOwner() (from EntitleableTrait) */ public function testWalletOwner(): void { $jack = $this->getTestUser('jack@kolab.org'); $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $this->assertSame($john->id, $john->walletOwner()->id); $this->assertSame($john->id, $jack->walletOwner()->id); $this->assertSame($john->id, $ned->walletOwner()->id); // User with no entitlements $user = $this->getTestUser('UserAccountA@UserAccount.com'); $this->assertSame($user->id, $user->walletOwner()->id); } /** * Tests for User::wallets() */ public function testWallets(): void { $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $this->assertSame(1, $john->wallets()->count()); $this->assertCount(1, $john->wallets); $this->assertInstanceOf(\App\Wallet::class, $john->wallets->first()); $this->assertSame(1, $ned->wallets()->count()); $this->assertCount(1, $ned->wallets); $this->assertInstanceOf(\App\Wallet::class, $ned->wallets->first()); } }