diff --git a/src/app/Backends/IMAP.php b/src/app/Backends/IMAP.php index 3069309..5f5bbd9 100644 --- a/src/app/Backends/IMAP.php +++ b/src/app/Backends/IMAP.php @@ -1,652 +1,650 @@ 'lrs', 'read-write' => 'lrswitedn', 'full' => 'lrswipkxtecdn', ]; /** * Delete a group. * * @param \App\Group $group Group * * @return bool True if a group was deleted successfully, False otherwise * @throws \Exception */ 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); $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(); 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]); } $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 = 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); - - # List the mailbox so we don't verify if shared folders are existing. - $folders = $imap->listMailboxes('', "INBOX"); - - \Log::debug("Verify account output" . var_export($folders, true)); + $imap = self::initIMAP($config); - $imap->closeConnection(); + $mailbox = self::toUTF7('user/' . $username); - if (!is_array($folders)) { - return false; + // Mailbox already exists + if (self::folderExists($imap, $mailbox)) { + $imap->closeConnection(); + return true; } - return count($folders) > 0; + $imap->closeConnection(); + return false; } /** * Check if we can connect to the imap server * * @return bool True on success */ public static function healthcheck(): bool { $config = self::getConfig(); $imap = self::initIMAP($config); $imap->closeConnection(); return true; } /** * 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/LDAP.php b/src/app/Backends/LDAP.php index d0883ca..a8bbf42 100644 --- a/src/app/Backends/LDAP.php +++ b/src/app/Backends/LDAP.php @@ -1,1410 +1,1410 @@ close(); self::$ldap = null; } } /** * Validates that ldap is available as configured. * * @throws \Exception */ public static function healthcheck(): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $mgmtRootDN = \config('ldap.admin.root_dn'); $hostedRootDN = \config('ldap.hosted.root_dn'); $result = $ldap->search($mgmtRootDN, '', 'base'); if (!$result || $result->count() != 1) { self::throwException($ldap, "Failed to find the configured management domain $mgmtRootDN"); } $result = $ldap->search($hostedRootDN, '', 'base'); if (!$result || $result->count() != 1) { self::throwException($ldap, "Failed to find the configured hosted domain $hostedRootDN"); } } /** * Create a domain in LDAP. * * @param \App\Domain $domain The domain to create. * * @throws \Exception */ public static function createDomain(Domain $domain): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $mgmtRootDN = \config('ldap.admin.root_dn'); $hostedRootDN = \config('ldap.hosted.root_dn'); $domainBaseDN = "ou={$domain->namespace},{$hostedRootDN}"; $aci = [ '(targetattr = "*")' . '(version 3.0; acl "Deny Unauthorized"; deny (all)' . '(userdn != "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN . ' || ldap:///ou=People,' . $domainBaseDN . '??sub?(objectclass=inetorgperson)") ' . 'AND NOT roledn = "ldap:///cn=kolab-admin,' . $mgmtRootDN . '";)', '(targetattr != "userPassword")' . '(version 3.0;acl "Search Access";allow (read,compare,search)' . '(userdn = "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN . ' || ldap:///ou=People,' . $domainBaseDN . '??sub?(objectclass=inetorgperson)");)', '(targetattr = "*")' . '(version 3.0;acl "Kolab Administrators";allow (all)' . '(roledn = "ldap:///cn=kolab-admin,' . $domainBaseDN . ' || ldap:///cn=kolab-admin,' . $mgmtRootDN . '");)' ]; $entry = [ 'aci' => $aci, 'associateddomain' => $domain->namespace, 'inetdomainbasedn' => $domainBaseDN, 'objectclass' => [ 'top', 'domainrelatedobject', 'inetdomain' ], ]; $dn = "associateddomain={$domain->namespace},{$config['domain_base_dn']}"; self::setDomainAttributes($domain, $entry); if (!$ldap->get_entry($dn)) { self::addEntry( $ldap, $dn, $entry, "Failed to create domain {$domain->namespace} in LDAP (" . __LINE__ . ")" ); } // create ou, roles, ous $entry = [ 'description' => $domain->namespace, 'objectclass' => [ 'top', 'organizationalunit' ], 'ou' => $domain->namespace, ]; $entry['aci'] = array( '(targetattr = "*")' . '(version 3.0;acl "Deny Unauthorized"; deny (all)' . '(userdn != "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN . ' || ldap:///ou=People,' . $domainBaseDN . '??sub?(objectclass=inetorgperson)") ' . 'AND NOT roledn = "ldap:///cn=kolab-admin,' . $mgmtRootDN . '";)', '(targetattr != "userPassword")' . '(version 3.0;acl "Search Access";allow (read,compare,search,write)' . '(userdn = "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN . ' || ldap:///ou=People,' . $domainBaseDN . '??sub?(objectclass=inetorgperson)");)', '(targetattr = "*")' . '(version 3.0;acl "Kolab Administrators";allow (all)' . '(roledn = "ldap:///cn=kolab-admin,' . $domainBaseDN . ' || ldap:///cn=kolab-admin,' . $mgmtRootDN . '");)', '(target = "ldap:///ou=*,' . $domainBaseDN . '")' . '(targetattr="objectclass || aci || ou")' . '(version 3.0;acl "Allow Domain sub-OU Registration"; allow (add)' . '(userdn = "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN . '");)', '(target = "ldap:///uid=*,ou=People,' . $domainBaseDN . '")(targetattr="*")' . '(version 3.0;acl "Allow Domain First User Registration"; allow (add)' . '(userdn = "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN . '");)', '(target = "ldap:///cn=*,' . $domainBaseDN . '")(targetattr="objectclass || cn")' . '(version 3.0;acl "Allow Domain Role Registration"; allow (add)' . '(userdn = "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN . '");)', ); if (!$ldap->get_entry($domainBaseDN)) { self::addEntry( $ldap, $domainBaseDN, $entry, "Failed to create domain {$domain->namespace} in LDAP (" . __LINE__ . ")" ); } foreach (['Groups', 'People', 'Resources', 'Shared Folders'] as $item) { $itemDN = "ou={$item},{$domainBaseDN}"; if (!$ldap->get_entry($itemDN)) { $itemEntry = [ 'ou' => $item, 'description' => $item, 'objectclass' => [ 'top', 'organizationalunit' ] ]; self::addEntry( $ldap, $itemDN, $itemEntry, "Failed to create domain {$domain->namespace} in LDAP (" . __LINE__ . ")" ); } } foreach (['kolab-admin'] as $item) { $itemDN = "cn={$item},{$domainBaseDN}"; if (!$ldap->get_entry($itemDN)) { $itemEntry = [ 'cn' => $item, 'description' => "{$item} role", 'objectclass' => [ 'top', 'ldapsubentry', 'nsmanagedroledefinition', 'nsroledefinition', 'nssimpleroledefinition' ] ]; self::addEntry( $ldap, $itemDN, $itemEntry, "Failed to create domain {$domain->namespace} in LDAP (" . __LINE__ . ")" ); } } // TODO: Assign kolab-admin role to the owner? if (empty(self::$ldap)) { $ldap->close(); } } /** * Create a group in LDAP. * * @param \App\Group $group The group to create. * * @throws \Exception */ public static function createGroup(Group $group): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $domainName = explode('@', $group->email, 2)[1]; $cn = $ldap->quote_string($group->name); $dn = "cn={$cn}," . self::baseDN($ldap, $domainName, 'Groups'); $entry = [ 'mail' => $group->email, 'objectclass' => [ 'top', 'groupofuniquenames', 'kolabgroupofuniquenames' ], ]; self::setGroupAttributes($ldap, $group, $entry); self::addEntry( $ldap, $dn, $entry, "Failed to create group {$group->email} in LDAP (" . __LINE__ . ")" ); if (empty(self::$ldap)) { $ldap->close(); } } /** * Create a resource in LDAP. * * @param \App\Resource $resource The resource to create. * * @throws \Exception */ public static function createResource(Resource $resource): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $domainName = explode('@', $resource->email, 2)[1]; $cn = $ldap->quote_string($resource->name); $dn = "cn={$cn}," . self::baseDN($ldap, $domainName, 'Resources'); $entry = [ 'mail' => $resource->email, 'objectclass' => [ 'top', 'kolabresource', 'kolabsharedfolder', 'mailrecipient', ], 'kolabfoldertype' => 'event', ]; self::setResourceAttributes($ldap, $resource, $entry); self::addEntry( $ldap, $dn, $entry, "Failed to create resource {$resource->email} in LDAP (" . __LINE__ . ")" ); if (empty(self::$ldap)) { $ldap->close(); } } /** * Create a shared folder in LDAP. * * @param \App\SharedFolder $folder The shared folder to create. * * @throws \Exception */ public static function createSharedFolder(SharedFolder $folder): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $domainName = explode('@', $folder->email, 2)[1]; $cn = $ldap->quote_string($folder->name); $dn = "cn={$cn}," . self::baseDN($ldap, $domainName, 'Shared Folders'); $entry = [ 'mail' => $folder->email, 'objectclass' => [ 'top', 'kolabsharedfolder', 'mailrecipient', ], ]; self::setSharedFolderAttributes($ldap, $folder, $entry); self::addEntry( $ldap, $dn, $entry, "Failed to create shared folder {$folder->id} in LDAP (" . __LINE__ . ")" ); if (empty(self::$ldap)) { $ldap->close(); } } /** * Create a user in LDAP. * * Only need to add user if in any of the local domains? Figure that out here for now. Should * have Context-Based Access Controls before the job is queued though, probably. * * Use one of three modes; * * 1) The authenticated user account. * * * Only valid if the authenticated user is a domain admin. * * We don't know the originating user here. * * We certainly don't have its password anymore. * * 2) The hosted kolab account. * * 3) The Directory Manager account. * * @param \App\User $user The user account to create. * * @throws \Exception */ public static function createUser(User $user): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $entry = [ 'objectclass' => [ 'top', 'inetorgperson', 'inetuser', 'kolabinetorgperson', 'mailrecipient', 'person' ], 'mail' => $user->email, 'uid' => $user->email, 'nsroledn' => [] ]; if (!self::getUserEntry($ldap, $user->email, $dn)) { if (empty($dn)) { self::throwException($ldap, "Failed to create user {$user->email} in LDAP (" . __LINE__ . ")"); } self::setUserAttributes($user, $entry); self::addEntry( $ldap, $dn, $entry, "Failed to create user {$user->email} in LDAP (" . __LINE__ . ")" ); } if (empty(self::$ldap)) { $ldap->close(); } } /** * Delete a domain from LDAP. * * @param \App\Domain $domain The domain to delete * * @throws \Exception */ public static function deleteDomain(Domain $domain): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $domainBaseDN = self::baseDN($ldap, $domain->namespace); if ($ldap->get_entry($domainBaseDN)) { $result = $ldap->delete_entry_recursive($domainBaseDN); if (!$result) { self::throwException( $ldap, "Failed to delete domain {$domain->namespace} from LDAP (" . __LINE__ . ")" ); } } if ($ldap_domain = $ldap->find_domain($domain->namespace)) { if ($ldap->get_entry($ldap_domain['dn'])) { $result = $ldap->delete_entry($ldap_domain['dn']); if (!$result) { self::throwException( $ldap, "Failed to delete domain {$domain->namespace} from LDAP (" . __LINE__ . ")" ); } } } if (empty(self::$ldap)) { $ldap->close(); } } /** * Delete a group from LDAP. * * @param \App\Group $group The group to delete. * * @throws \Exception */ public static function deleteGroup(Group $group): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); if (self::getGroupEntry($ldap, $group->email, $dn)) { $result = $ldap->delete_entry($dn); if (!$result) { self::throwException( $ldap, "Failed to delete group {$group->email} from LDAP (" . __LINE__ . ")" ); } } if (empty(self::$ldap)) { $ldap->close(); } } /** * Delete a resource from LDAP. * * @param \App\Resource $resource The resource to delete. * * @throws \Exception */ public static function deleteResource(Resource $resource): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); if (self::getResourceEntry($ldap, $resource->email, $dn)) { $result = $ldap->delete_entry($dn); if (!$result) { self::throwException( $ldap, "Failed to delete resource {$resource->email} from LDAP (" . __LINE__ . ")" ); } } if (empty(self::$ldap)) { $ldap->close(); } } /** * Delete a shared folder from LDAP. * * @param \App\SharedFolder $folder The shared folder to delete. * * @throws \Exception */ public static function deleteSharedFolder(SharedFolder $folder): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); if (self::getSharedFolderEntry($ldap, $folder->email, $dn)) { $result = $ldap->delete_entry($dn); if (!$result) { self::throwException( $ldap, "Failed to delete shared folder {$folder->id} from LDAP (" . __LINE__ . ")" ); } } if (empty(self::$ldap)) { $ldap->close(); } } /** * Delete a user from LDAP. * * @param \App\User $user The user account to delete. * * @throws \Exception */ public static function deleteUser(User $user): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); if (self::getUserEntry($ldap, $user->email, $dn)) { $result = $ldap->delete_entry($dn); if (!$result) { self::throwException( $ldap, "Failed to delete user {$user->email} from LDAP (" . __LINE__ . ")" ); } } if (empty(self::$ldap)) { $ldap->close(); } } /** * Get a domain data from LDAP. * * @param string $namespace The domain name * * @return array|false|null * @throws \Exception */ public static function getDomain(string $namespace) { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $ldapDomain = $ldap->find_domain($namespace); if ($ldapDomain) { $domain = $ldap->get_entry($ldapDomain['dn']); } if (empty(self::$ldap)) { $ldap->close(); } return $domain ?? null; } /** * Get a group data from LDAP. * * @param string $email The group email. * * @return array|false|null * @throws \Exception */ public static function getGroup(string $email) { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $group = self::getGroupEntry($ldap, $email, $dn); if (empty(self::$ldap)) { $ldap->close(); } return $group; } /** * Get a resource data from LDAP. * * @param string $email The resource email. * * @return array|false|null * @throws \Exception */ public static function getResource(string $email) { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $resource = self::getResourceEntry($ldap, $email, $dn); if (empty(self::$ldap)) { $ldap->close(); } return $resource; } /** * Get a shared folder data from LDAP. * * @param string $email The resource email. * * @return array|false|null * @throws \Exception */ public static function getSharedFolder(string $email) { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $folder = self::getSharedFolderEntry($ldap, $email, $dn); if (empty(self::$ldap)) { $ldap->close(); } return $folder; } /** * Get a user data from LDAP. * * @param string $email The user email. * * @return array|false|null * @throws \Exception */ public static function getUser(string $email) { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $user = self::getUserEntry($ldap, $email, $dn, true); if (empty(self::$ldap)) { $ldap->close(); } return $user; } /** * Update a domain in LDAP. * * @param \App\Domain $domain The domain to update. * * @throws \Exception */ public static function updateDomain(Domain $domain): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $ldapDomain = $ldap->find_domain($domain->namespace); if (!$ldapDomain) { self::throwException( $ldap, "Failed to update domain {$domain->namespace} in LDAP (domain not found)" ); } $oldEntry = $ldap->get_entry($ldapDomain['dn']); $newEntry = $oldEntry; self::setDomainAttributes($domain, $newEntry); if (array_key_exists('inetdomainstatus', $newEntry)) { $newEntry['inetdomainstatus'] = (string) $newEntry['inetdomainstatus']; } $result = $ldap->modify_entry($ldapDomain['dn'], $oldEntry, $newEntry); if (!is_array($result)) { self::throwException( $ldap, "Failed to update domain {$domain->namespace} in LDAP (" . __LINE__ . ")" ); } if (empty(self::$ldap)) { $ldap->close(); } } /** * Update a group in LDAP. * * @param \App\Group $group The group to update * * @throws \Exception */ public static function updateGroup(Group $group): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $newEntry = $oldEntry = self::getGroupEntry($ldap, $group->email, $dn); if (empty($oldEntry)) { self::throwException( $ldap, "Failed to update group {$group->email} in LDAP (group not found)" ); } self::setGroupAttributes($ldap, $group, $newEntry); $result = $ldap->modify_entry($dn, $oldEntry, $newEntry); if (!is_array($result)) { self::throwException( $ldap, "Failed to update group {$group->email} in LDAP (" . __LINE__ . ")" ); } if (empty(self::$ldap)) { $ldap->close(); } } /** * Update a resource in LDAP. * * @param \App\Resource $resource The resource to update * * @throws \Exception */ public static function updateResource(Resource $resource): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $newEntry = $oldEntry = self::getResourceEntry($ldap, $resource->email, $dn); if (empty($oldEntry)) { self::throwException( $ldap, "Failed to update resource {$resource->email} in LDAP (resource not found)" ); } self::setResourceAttributes($ldap, $resource, $newEntry); $result = $ldap->modify_entry($dn, $oldEntry, $newEntry); if (!is_array($result)) { self::throwException( $ldap, "Failed to update resource {$resource->email} in LDAP (" . __LINE__ . ")" ); } if (empty(self::$ldap)) { $ldap->close(); } } /** * Update a shared folder in LDAP. * * @param \App\SharedFolder $folder The shared folder to update * * @throws \Exception */ public static function updateSharedFolder(SharedFolder $folder): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $newEntry = $oldEntry = self::getSharedFolderEntry($ldap, $folder->email, $dn); if (empty($oldEntry)) { self::throwException( $ldap, "Failed to update shared folder {$folder->id} in LDAP (folder not found)" ); } self::setSharedFolderAttributes($ldap, $folder, $newEntry); $result = $ldap->modify_entry($dn, $oldEntry, $newEntry); if (!is_array($result)) { self::throwException( $ldap, "Failed to update shared folder {$folder->id} in LDAP (" . __LINE__ . ")" ); } if (empty(self::$ldap)) { $ldap->close(); } } /** * Update a user in LDAP. * * @param \App\User $user The user account to update. * * @throws \Exception */ public static function updateUser(User $user): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $newEntry = $oldEntry = self::getUserEntry($ldap, $user->email, $dn, true); if (!$oldEntry) { self::throwException( $ldap, "Failed to update user {$user->email} in LDAP (user not found)" ); } self::setUserAttributes($user, $newEntry); if (array_key_exists('objectclass', $newEntry)) { if (!in_array('inetuser', $newEntry['objectclass'])) { $newEntry['objectclass'][] = 'inetuser'; } } if (array_key_exists('inetuserstatus', $newEntry)) { $newEntry['inetuserstatus'] = (string) $newEntry['inetuserstatus']; } if (array_key_exists('mailquota', $newEntry)) { $newEntry['mailquota'] = (string) $newEntry['mailquota']; } $result = $ldap->modify_entry($dn, $oldEntry, $newEntry); if (!is_array($result)) { self::throwException( $ldap, "Failed to update user {$user->email} in LDAP (" . __LINE__ . ")" ); } if (empty(self::$ldap)) { $ldap->close(); } } /** * Initialize connection to LDAP */ private static function initLDAP(array $config, string $privilege = 'admin') { if (self::$ldap) { return self::$ldap; } $ldap = new \Net_LDAP3($config); $connected = $ldap->connect(); if (!$connected) { throw new \Exception("Failed to connect to LDAP"); } $bound = $ldap->bind( \config("ldap.{$privilege}.bind_dn"), \config("ldap.{$privilege}.bind_pw") ); if (!$bound) { throw new \Exception("Failed to bind to LDAP"); } return $ldap; } /** * Set domain attributes */ private static function setDomainAttributes(Domain $domain, array &$entry) { $entry['inetdomainstatus'] = $domain->status; } /** * Convert group member addresses in to valid entries. */ private static function setGroupAttributes($ldap, Group $group, &$entry) { $settings = $group->getSettings(['sender_policy']); $entry['kolaballowsmtpsender'] = json_decode($settings['sender_policy'] ?: '[]', true); $entry['cn'] = $group->name; $entry['uniquemember'] = []; $groupDomain = explode('@', $group->email, 2)[1]; $domainBaseDN = self::baseDN($ldap, $groupDomain); $validMembers = []; foreach ($group->members as $member) { list($local, $domainName) = explode('@', $member); $memberDN = "uid={$member},ou=People,{$domainBaseDN}"; $memberEntry = $ldap->get_entry($memberDN); // if the member is in the local domain but doesn't exist, drop it if ($domainName == $groupDomain && !$memberEntry) { continue; } // add the member if not in the local domain if (!$memberEntry) { $memberEntry = [ 'cn' => $member, 'mail' => $member, 'objectclass' => [ 'top', 'inetorgperson', 'organizationalperson', 'person' ], 'sn' => 'unknown' ]; $ldap->add_entry($memberDN, $memberEntry); } $entry['uniquemember'][] = $memberDN; $validMembers[] = $member; } // Update members in sql (some might have been removed), // skip model events to not invoke another update job if ($group->members !== $validMembers) { $group->members = $validMembers; Group::withoutEvents(function () use ($group) { $group->save(); }); } } /** * Set common resource attributes */ private static function setResourceAttributes($ldap, Resource $resource, &$entry) { $entry['cn'] = $resource->name; $entry['owner'] = null; $entry['kolabinvitationpolicy'] = null; $entry['acl'] = ''; $settings = $resource->getSettings(['invitation_policy', 'folder']); $entry['kolabtargetfolder'] = $settings['folder'] ?? ''; // Here's how Wallace's resources module works: // - if policy is ACT_MANUAL and owner mail specified: a tentative response is sent, event saved, // and mail sent to the owner to accept/decline the request. // - if policy is ACT_ACCEPT_AND_NOTIFY and owner mail specified: an accept response is sent, // event saved, and notification (not confirmation) mail sent to the owner. // - if there's no owner (policy irrelevant): an accept response is sent, event saved. // - if policy is ACT_REJECT: a decline response is sent // - note that the notification email is being send if COND_NOTIFY policy is set or saving failed. // - all above assume there's no conflict, if there's a conflict the decline response is sent automatically // (notification is sent if policy = ACT_ACCEPT_AND_NOTIFY). // - the only supported policies are: 'ACT_MANUAL', 'ACT_ACCEPT' (defined but not used anywhere), // 'ACT_REJECT', 'ACT_ACCEPT_AND_NOTIFY'. // For now we ignore the notifications feature if (!empty($settings['invitation_policy'])) { if ($settings['invitation_policy'] === 'accept') { $entry['kolabinvitationpolicy'] = 'ACT_ACCEPT'; } elseif ($settings['invitation_policy'] === 'reject') { $entry['kolabinvitationpolicy'] = 'ACT_REJECT'; } elseif (preg_match('/^manual:(\S+@\S+)$/', $settings['invitation_policy'], $m)) { if (self::getUserEntry($ldap, $m[1], $userDN)) { $entry['owner'] = $userDN; $entry['acl'] = $m[1] . ', full'; $entry['kolabinvitationpolicy'] = 'ACT_MANUAL'; } else { $entry['kolabinvitationpolicy'] = 'ACT_ACCEPT'; } } } } /** * Set common shared folder attributes */ private static function setSharedFolderAttributes($ldap, SharedFolder $folder, &$entry) { $settings = $folder->getSettings(['acl', 'folder']); $entry['cn'] = $folder->name; $entry['kolabfoldertype'] = $folder->type; $entry['kolabtargetfolder'] = $settings['folder'] ?? ''; $entry['acl'] = !empty($settings['acl']) ? json_decode($settings['acl'], true) : ''; $entry['alias'] = $folder->aliases()->pluck('alias')->all(); } /** * Set common user attributes */ private static function setUserAttributes(User $user, array &$entry) { $isDegraded = $user->isDegraded(true); $settings = $user->getSettings(['first_name', 'last_name', 'organization']); $firstName = $settings['first_name']; $lastName = $settings['last_name']; $cn = "unknown"; $displayname = ""; if ($firstName) { if ($lastName) { $cn = "{$firstName} {$lastName}"; $displayname = "{$lastName}, {$firstName}"; } else { $lastName = "unknown"; $cn = "{$firstName}"; $displayname = "{$firstName}"; } } else { $firstName = ""; if ($lastName) { $cn = "{$lastName}"; $displayname = "{$lastName}"; } else { $lastName = "unknown"; } } $entry['cn'] = $cn; $entry['displayname'] = $displayname; $entry['givenname'] = $firstName; $entry['sn'] = $lastName; $entry['userpassword'] = $user->password_ldap; $entry['inetuserstatus'] = $user->status; $entry['o'] = $settings['organization']; $entry['mailquota'] = 0; $entry['alias'] = $user->aliases()->pluck('alias')->all(); $roles = []; foreach ($user->entitlements as $entitlement) { \Log::debug("Examining {$entitlement->sku->title}"); switch ($entitlement->sku->title) { case "mailbox": break; case "storage": $entry['mailquota'] += 1048576; break; default: $roles[] = $entitlement->sku->title; break; } } $hostedRootDN = \config('ldap.hosted.root_dn'); $entry['nsroledn'] = []; if (in_array("2fa", $roles)) { $entry['nsroledn'][] = "cn=2fa-user,{$hostedRootDN}"; } if ($isDegraded) { $entry['nsroledn'][] = "cn=degraded-user,{$hostedRootDN}"; $entry['mailquota'] = \config('app.storage.min_qty') * 1048576; } else { if (in_array("activesync", $roles)) { $entry['nsroledn'][] = "cn=activesync-user,{$hostedRootDN}"; } if (!in_array("groupware", $roles)) { $entry['nsroledn'][] = "cn=imap-user,{$hostedRootDN}"; } } } /** * Get LDAP configuration for specified access level */ private static function getConfig(string $privilege) { $config = [ 'domain_base_dn' => \config('ldap.domain_base_dn'), 'domain_filter' => \config('ldap.domain_filter'), 'domain_name_attribute' => \config('ldap.domain_name_attribute'), 'hosts' => \config('ldap.hosts'), 'sort' => false, 'vlv' => false, 'log_hook' => 'App\Backends\LDAP::logHook', ]; return $config; } /** * Get group entry from LDAP. * * @param \Net_LDAP3 $ldap Ldap connection * @param string $email Group email (mail) * @param string $dn Reference to group DN * - * @return null|array Group entry, False on error, NULL if not found + * @return null|array Group entry, NULL if not found */ private static function getGroupEntry($ldap, $email, &$dn = null) { $domainName = explode('@', $email, 2)[1]; $base_dn = self::baseDN($ldap, $domainName, 'Groups'); $attrs = ['dn', 'cn', 'mail', 'uniquemember', 'objectclass', 'kolaballowsmtpsender']; // For groups we're using search() instead of get_entry() because // a group name is not constant, so e.g. on update we might have // the new name, but not the old one. Email address is constant. return self::searchEntry($ldap, $base_dn, "(mail=$email)", $attrs, $dn); } /** * Get a resource entry from LDAP. * * @param \Net_LDAP3 $ldap Ldap connection * @param string $email Resource email (mail) * @param string $dn Reference to the resource DN * * @return null|array Resource entry, NULL if not found */ private static function getResourceEntry($ldap, $email, &$dn = null) { $domainName = explode('@', $email, 2)[1]; $base_dn = self::baseDN($ldap, $domainName, 'Resources'); $attrs = ['dn', 'cn', 'mail', 'objectclass', 'kolabtargetfolder', 'kolabfoldertype', 'kolabinvitationpolicy', 'owner', 'acl']; // For resources we're using search() instead of get_entry() because // a resource name is not constant, so e.g. on update we might have // the new name, but not the old one. Email address is constant. return self::searchEntry($ldap, $base_dn, "(mail=$email)", $attrs, $dn); } /** * Get a shared folder entry from LDAP. * * @param \Net_LDAP3 $ldap Ldap connection * @param string $email Resource email (mail) * @param string $dn Reference to the shared folder DN * * @return null|array Shared folder entry, NULL if not found */ private static function getSharedFolderEntry($ldap, $email, &$dn = null) { $domainName = explode('@', $email, 2)[1]; $base_dn = self::baseDN($ldap, $domainName, 'Shared Folders'); $attrs = ['dn', 'cn', 'mail', 'objectclass', 'kolabtargetfolder', 'kolabfoldertype', 'acl', 'alias']; // For shared folders we're using search() instead of get_entry() because // a folder name is not constant, so e.g. on update we might have // the new name, but not the old one. Email address is constant. return self::searchEntry($ldap, $base_dn, "(mail=$email)", $attrs, $dn); } /** * Get user entry from LDAP. * * @param \Net_LDAP3 $ldap Ldap connection * @param string $email User email (uid) * @param string $dn Reference to user DN * @param bool $full Get extra attributes, e.g. nsroledn * - * @return ?array User entry, NULL if not found + * @return null|array User entry, NULL if not found */ private static function getUserEntry($ldap, $email, &$dn = null, $full = false) { $domainName = explode('@', $email, 2)[1]; $dn = "uid={$email}," . self::baseDN($ldap, $domainName, 'People'); $entry = $ldap->get_entry($dn); if ($entry && $full) { if (!array_key_exists('nsroledn', $entry)) { $roles = $ldap->get_entry_attributes($dn, ['nsroledn']); if (!empty($roles)) { $entry['nsroledn'] = (array) $roles['nsroledn']; } } } return $entry ?: null; } /** * Logging callback */ public static function logHook($level, $msg): void { if ( ( $level == LOG_INFO || $level == LOG_DEBUG || $level == LOG_NOTICE ) && !\config('app.debug') ) { return; } switch ($level) { case LOG_CRIT: $function = 'critical'; break; case LOG_EMERG: $function = 'emergency'; break; case LOG_ERR: $function = 'error'; break; case LOG_ALERT: $function = 'alert'; break; case LOG_WARNING: $function = 'warning'; break; case LOG_INFO: $function = 'info'; break; case LOG_DEBUG: $function = 'debug'; break; case LOG_NOTICE: $function = 'notice'; break; default: $function = 'info'; } if (is_array($msg)) { $msg = implode("\n", $msg); } $msg = '[LDAP] ' . $msg; \Log::{$function}($msg); } /** * A wrapper for Net_LDAP3::add_entry() with error handler * * @param \Net_LDAP3 $ldap Ldap connection * @param string $dn Entry DN * @param array $entry Entry attributes * @param ?string $errorMsg A message to throw as an exception on error * * @throws \Exception */ private static function addEntry($ldap, string $dn, array $entry, $errorMsg = null) { // try/catch because Laravel converts warnings into exceptions // and we want more human-friendly error message than that try { $result = $ldap->add_entry($dn, $entry); } catch (\Exception $e) { $result = false; } if (!$result) { if (!$errorMsg) { $errorMsg = "LDAP Error (" . __LINE__ . ")"; } if (isset($e)) { $errorMsg .= ": " . $e->getMessage(); } self::throwException($ldap, $errorMsg); } } /** * Find a single entry in LDAP by using search. * * @param \Net_LDAP3 $ldap Ldap connection * @param string $base_dn Base DN * @param string $filter Search filter * @param array $attrs Result attributes * @param string $dn Reference to a DN of the found entry * * @return null|array LDAP entry, NULL if not found */ private static function searchEntry($ldap, $base_dn, $filter, $attrs, &$dn = null) { $result = $ldap->search($base_dn, $filter, 'sub', $attrs); if ($result && $result->count() == 1) { $entries = $result->entries(true); $dn = key($entries); $entry = $entries[$dn]; $entry['dn'] = $dn; return $entry; } return null; } /** * Throw exception and close the connection when needed * * @param \Net_LDAP3 $ldap Ldap connection * @param string $message Exception message * * @throws \Exception */ private static function throwException($ldap, string $message): void { if (empty(self::$ldap)) { $ldap->close(); } throw new \Exception($message); } /** * Create a base DN string for a specified object. * Note: It makes sense with an existing domain only. * * @param \Net_LDAP3 $ldap Ldap connection * @param string $domainName Domain namespace * @param ?string $ouName Optional name of the sub-tree (OU) * * @return string Full base DN */ private static function baseDN($ldap, string $domainName, string $ouName = null): string { $dn = $ldap->domain_root_dn($domainName); if ($ouName) { $dn = "ou={$ouName},{$dn}"; } return $dn; } } diff --git a/src/app/Utils.php b/src/app/Utils.php index e3284a0..6e553fe 100644 --- a/src/app/Utils.php +++ b/src/app/Utils.php @@ -1,578 +1,578 @@ country ? $net->country : $fallback; } /** * Return the country ISO code for the current request. */ public static function countryForRequest() { $request = \request(); $ip = $request->ip(); return self::countryForIP($ip); } /** * Return the number of days in the month prior to this one. * * @return int */ public static function daysInLastMonth() { $start = new Carbon('first day of last month'); $end = new Carbon('last day of last month'); return $start->diffInDays($end) + 1; } /** * Download a file from the interwebz and store it locally. * * @param string $source The source location * @param string $target The target location * @param bool $force Force the download (and overwrite target) * * @return void */ public static function downloadFile($source, $target, $force = false) { if (is_file($target) && !$force) { return; } \Log::info("Retrieving {$source}"); $fp = fopen($target, 'w'); $curl = curl_init(); curl_setopt($curl, CURLOPT_URL, $source); curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1); curl_setopt($curl, CURLOPT_FILE, $fp); curl_exec($curl); if (curl_errno($curl)) { \Log::error("Request error on {$source}: " . curl_error($curl)); curl_close($curl); fclose($fp); unlink($target); return; } curl_close($curl); fclose($fp); } /** * Converts an email address to lower case. Keeps the LMTP shared folder * addresses character case intact. * * @param string $email Email address * * @return string Email address */ public static function emailToLower(string $email): string { // For LMTP shared folder address lower case the domain part only if (str_starts_with($email, 'shared+shared/')) { $pos = strrpos($email, '@'); $domain = substr($email, $pos + 1); $local = substr($email, 0, strlen($email) - strlen($domain) - 1); return $local . '@' . strtolower($domain); } return strtolower($email); } /** * Generate a passphrase. Not intended for use in production, so limited to environments that are not production. * * @return string */ public static function generatePassphrase() { if (\config('app.env') == 'production') { throw new \Exception("Thou shall not pass!"); } if (\config('app.passphrase')) { return \config('app.passphrase'); } $alphaLow = 'abcdefghijklmnopqrstuvwxyz'; $alphaUp = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; $num = '0123456789'; $stdSpecial = '~`!@#$%^&*()-_+=[{]}\\|\'";:/?.>,<'; $source = $alphaLow . $alphaUp . $num . $stdSpecial; $result = ''; for ($x = 0; $x < 16; $x++) { $result .= substr($source, rand(0, (strlen($source) - 1)), 1); } return $result; } /** * Find an object that is the recipient for the specified address. * * @param string $address * * @return array */ public static function findObjectsByRecipientAddress($address) { $address = \App\Utils::normalizeAddress($address); list($local, $domainName) = explode('@', $address); $domain = \App\Domain::where('namespace', $domainName)->first(); if (!$domain) { return []; } $user = \App\User::where('email', $address)->first(); if ($user) { return [$user]; } $userAliases = \App\UserAlias::where('alias', $address)->get(); if (count($userAliases) > 0) { $users = []; foreach ($userAliases as $userAlias) { $users[] = $userAlias->user; } return $users; } $userAliases = \App\UserAlias::where('alias', "catchall@{$domain->namespace}")->get(); if (count($userAliases) > 0) { $users = []; foreach ($userAliases as $userAlias) { $users[] = $userAlias->user; } return $users; } return []; } /** * Retrieve the network ID and Type from a client address * * @param string $clientAddress The IPv4 or IPv6 address. * * @return array An array of ID and class or null and null. */ public static function getNetFromAddress($clientAddress) { if (strpos($clientAddress, ':') === false) { $net = \App\IP4Net::getNet($clientAddress); if ($net) { return [$net->id, \App\IP4Net::class]; } } else { $net = \App\IP6Net::getNet($clientAddress); if ($net) { return [$net->id, \App\IP6Net::class]; } } return [null, null]; } /** * Calculate the broadcast address provided a net number and a prefix. * * @param string $net A valid IPv6 network number. * @param int $prefix The network prefix. * * @return string */ public static function ip6Broadcast($net, $prefix) { $netHex = bin2hex(inet_pton($net)); // Overwriting first address string to make sure notation is optimal $net = inet_ntop(hex2bin($netHex)); // Calculate the number of 'flexible' bits $flexbits = 128 - $prefix; // Build the hexadecimal string of the last address $lastAddrHex = $netHex; // We start at the end of the string (which is always 32 characters long) $pos = 31; while ($flexbits > 0) { // Get the character at this position $orig = substr($lastAddrHex, $pos, 1); // Convert it to an integer $origval = hexdec($orig); // OR it with (2^flexbits)-1, with flexbits limited to 4 at a time $newval = $origval | (pow(2, min(4, $flexbits)) - 1); // Convert it back to a hexadecimal character $new = dechex($newval); // And put that character back in the string $lastAddrHex = substr_replace($lastAddrHex, $new, $pos, 1); // We processed one nibble, move to previous position $flexbits -= 4; $pos -= 1; } // Convert the hexadecimal string to a binary string $lastaddrbin = hex2bin($lastAddrHex); // And create an IPv6 address from the binary string $lastaddrstr = inet_ntop($lastaddrbin); return $lastaddrstr; } /** * Normalize an email address. * * This means to lowercase and strip components separated with recipient delimiters. * * @param ?string $address The address to normalize * @param bool $asArray Return an array with local and domain part * * @return string|array Normalized email address as string or array */ public static function normalizeAddress(?string $address, bool $asArray = false) { if ($address === null || $address === '') { return $asArray ? ['', ''] : ''; } $address = self::emailToLower($address); if (strpos($address, '@') === false) { return $asArray ? [$address, ''] : $address; } list($local, $domain) = explode('@', $address); if (strpos($local, '+') !== false) { $local = explode('+', $local)[0]; } return $asArray ? [$local, $domain] : "{$local}@{$domain}"; } /** * Provide all unique combinations of elements in $input, with order and duplicates irrelevant. * * @param array $input The input array of elements. * * @return array[] */ public static function powerSet(array $input): array { $output = []; for ($x = 0; $x < count($input); $x++) { self::combine($input, $x + 1, 0, [], 0, $output); } return $output; } /** * Returns the current user's email address or null. * * @return string */ public static function userEmailOrNull(): ?string { $user = Auth::user(); if (!$user) { return null; } return $user->email; } /** * Returns a random string consisting of a quantity of segments of a certain length joined. * * Example: * * ```php * $roomName = strtolower(\App\Utils::randStr(3, 3, '-'); * // $roomName == '3qb-7cs-cjj' * ``` * * @param int $length The length of each segment * @param int $qty The quantity of segments * @param string $join The string to use to join the segments * * @return string */ public static function randStr($length, $qty = 1, $join = '') { $chars = env('SHORTCODE_CHARS', self::CHARS); $randStrs = []; for ($x = 0; $x < $qty; $x++) { $randStrs[$x] = []; for ($y = 0; $y < $length; $y++) { $randStrs[$x][] = $chars[rand(0, strlen($chars) - 1)]; } shuffle($randStrs[$x]); $randStrs[$x] = implode('', $randStrs[$x]); } return implode($join, $randStrs); } /** * Returns a UUID in the form of an integer. * - * @return integer + * @return int */ public static function uuidInt(): int { - $hex = Uuid::uuid4(); + $hex = self::uuidStr(); $bin = pack('h*', str_replace('-', '', $hex)); $ids = unpack('L', $bin); $id = array_shift($ids); return $id; } /** * Returns a UUID in the form of a string. * * @return string */ public static function uuidStr(): string { - return Uuid::uuid4()->toString(); + return (string) Str::uuid(); } private static function combine($input, $r, $index, $data, $i, &$output): void { $n = count($input); // Current cobination is ready if ($index == $r) { $output[] = array_slice($data, 0, $r); return; } // When no more elements are there to put in data[] if ($i >= $n) { return; } // current is included, put next at next location $data[$index] = $input[$i]; self::combine($input, $r, $index + 1, $data, $i + 1, $output); // current is excluded, replace it with next (Note that i+1 // is passed, but index is not changed) self::combine($input, $r, $index, $data, $i + 1, $output); } /** * Create self URL * * @param string $route Route/Path/URL * @param int|null $tenantId Current tenant * * @todo Move this to App\Http\Controllers\Controller * * @return string Full URL */ public static function serviceUrl(string $route, $tenantId = null): string { if (preg_match('|^https?://|i', $route)) { return $route; } $url = \App\Tenant::getConfig($tenantId, 'app.public_url'); if (!$url) { $url = \App\Tenant::getConfig($tenantId, 'app.url'); } return rtrim(trim($url, '/') . '/' . ltrim($route, '/'), '/'); } /** * Create a configuration/environment data to be passed to * the UI * * @todo Move this to App\Http\Controllers\Controller * * @return array Configuration data */ public static function uiEnv(): array { $countries = include resource_path('countries.php'); $req_domain = preg_replace('/:[0-9]+$/', '', request()->getHttpHost()); $sys_domain = \config('app.domain'); $opts = [ 'app.name', 'app.url', 'app.domain', 'app.theme', 'app.webmail_url', 'app.support_email', 'app.company.copyright', 'mail.from.address' ]; $env = \app('config')->getMany($opts); $env['countries'] = $countries ?: []; $env['view'] = 'root'; $env['jsapp'] = 'user.js'; if ($req_domain == "admin.$sys_domain") { $env['jsapp'] = 'admin.js'; } elseif ($req_domain == "reseller.$sys_domain") { $env['jsapp'] = 'reseller.js'; } $env['paymentProvider'] = \config('services.payment_provider'); $env['stripePK'] = \config('services.stripe.public_key'); $env['languages'] = \App\Http\Controllers\ContentController::locales(); $env['menu'] = \App\Http\Controllers\ContentController::menu(); return $env; } /** * Set test exchange rates. * * @param array $rates: Exchange rates */ public static function setTestExchangeRates(array $rates): void { self::$testRates = $rates; } /** * Retrieve an exchange rate. * * @param string $sourceCurrency: Currency from which to convert * @param string $targetCurrency: Currency to convert to * * @return float Exchange rate */ public static function exchangeRate(string $sourceCurrency, string $targetCurrency): float { if (strcasecmp($sourceCurrency, $targetCurrency) == 0) { return 1.0; } if (isset(self::$testRates[$targetCurrency])) { return floatval(self::$testRates[$targetCurrency]); } $currencyFile = resource_path("exchangerates-$sourceCurrency.php"); //Attempt to find the reverse exchange rate, if we don't have the file for the source currency if (!file_exists($currencyFile)) { $rates = include resource_path("exchangerates-$targetCurrency.php"); if (!isset($rates[$sourceCurrency])) { throw new \Exception("Failed to find the reverse exchange rate for " . $sourceCurrency); } return 1.0 / floatval($rates[$sourceCurrency]); } $rates = include $currencyFile; if (!isset($rates[$targetCurrency])) { throw new \Exception("Failed to find exchange rate for " . $targetCurrency); } return floatval($rates[$targetCurrency]); } } diff --git a/src/config/app.php b/src/config/app.php index 615fc5b..37b706a 100644 --- a/src/config/app.php +++ b/src/config/app.php @@ -1,281 +1,279 @@ 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')), 'services_domain' => env( 'APP_SERVICES_DOMAIN', "services." . 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')), 'copyright' => env('COMPANY_COPYRIGHT', env('COMPANY_NAME', 'Apheleia IT AG')), ], '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_imap' => (bool) env('APP_IMAP', false), '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/phpstan.neon b/src/phpstan.neon index a2dc644..2d21275 100644 --- a/src/phpstan.neon +++ b/src/phpstan.neon @@ -1,21 +1,20 @@ includes: - ./vendor/nunomaduro/larastan/extension.neon parameters: ignoreErrors: - '#Access to an undefined property [a-zA-Z\\]+::\$pivot#' - - '#Access to undefined constant static\(App\\[a-zA-Z]+\)::STATUS_[A-Z_]+#' - '#Call to an undefined [a-zA-Z0-9<>\\ ]+::withEnvTenantContext\(\)#' - '#Call to an undefined [a-zA-Z0-9<>\\ ]+::withObjectTenantContext\(\)#' - '#Call to an undefined [a-zA-Z0-9<>\\ ]+::withSubjectTenantContext\(\)#' - '#Call to an undefined method Tests\\Browser::#' level: 4 parallel: processTimeout: 300.0 paths: - app/ - config/ - database/ - resources/ - routes/ - tests/ - resources/ diff --git a/src/tests/Feature/Backends/IMAPTest.php b/src/tests/Feature/Backends/IMAPTest.php index c82caf4..e2596b1 100644 --- a/src/tests/Feature/Backends/IMAPTest.php +++ b/src/tests/Feature/Backends/IMAPTest.php @@ -1,277 +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)); + $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); $result = IMAP::verifyAccount($user->email); $this->assertFalse($result); } /** * 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 $result = IMAP::verifyAccount('non-existing@domain.tld'); $this->assertFalse($result); } /** * 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/Controller/ResourcesTest.php b/src/tests/Feature/Controller/ResourcesTest.php index d629098..0d89ea5 100644 --- a/src/tests/Feature/Controller/ResourcesTest.php +++ b/src/tests/Feature/Controller/ResourcesTest.php @@ -1,560 +1,564 @@ 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]); + if (\config('app.with_imap')) { + $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); + if (\config('app.with_imap')) { + $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']); if (\config('app.with_imap')) { + $this->assertFalse($json['isImapReady']); $this->assertCount(7, $json['process']); } else { $this->assertCount(6, $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 Queue::fake(); $response = $this->actingAs($john)->get("/api/v4/resources/{$resource->id}/status?refresh=1"); $response->assertStatus(200); $json = $response->json(); $this->assertFalse($json['isLdapReady']); - $this->assertTrue($json['isImapReady']); $this->assertFalse($json['isReady']); if (\config('app.with_imap')) { + $this->assertTrue($json['isImapReady']); $this->assertCount(7, $json['process']); } else { $this->assertCount(6, $json['process']); } $this->assertSame('resource-ldap-ready', $json['process'][1]['label']); $this->assertSame(false, $json['process'][1]['state']); if (\config('app.with_imap')) { $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 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->assertFalse($json['isLdapReady']); $this->assertFalse($json['isReady']); if (\config('app.with_imap')) { $this->assertCount(7, $json['process']); } else { $this->assertCount(6, $json['process']); } $this->assertSame('resource-ldap-ready', $json['process'][1]['label']); $this->assertSame(false, $json['process'][1]['state']); $this->assertSame('success', $json['status']); $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']); if (\config('app.with_imap')) { $this->assertCount(7, $result['process']); } else { $this->assertCount(6, $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']); if (\config('app.with_imap')) { $this->assertCount(7, $result['process']); } else { $this->assertCount(6, $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 4fa4e99..7038861 100644 --- a/src/tests/Feature/Controller/SharedFoldersTest.php +++ b/src/tests/Feature/Controller/SharedFoldersTest.php @@ -1,646 +1,650 @@ 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]); + if (\config('app.with_imap')) { + $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); + if (\config('app.with_imap')) { + $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']); if (\config('app.with_imap')) { + $this->assertFalse($json['isImapReady']); $this->assertCount(7, $json['process']); } else { $this->assertCount(6, $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 Queue::fake(); $response = $this->actingAs($john)->get("/api/v4/shared-folders/{$folder->id}/status?refresh=1"); $response->assertStatus(200); $json = $response->json(); $this->assertFalse($json['isLdapReady']); - $this->assertTrue($json['isImapReady']); $this->assertFalse($json['isReady']); if (\config('app.with_imap')) { + $this->assertTrue($json['isImapReady']); $this->assertCount(7, $json['process']); } else { $this->assertCount(6, $json['process']); } $this->assertSame('shared-folder-ldap-ready', $json['process'][1]['label']); $this->assertSame(false, $json['process'][1]['state']); if (\config('app.with_imap')) { $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 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->assertFalse($json['isLdapReady']); $this->assertFalse($json['isReady']); if (\config('app.with_imap')) { $this->assertCount(7, $json['process']); } else { $this->assertCount(6, $json['process']); } $this->assertSame('shared-folder-ldap-ready', $json['process'][1]['label']); $this->assertSame(false, $json['process'][1]['state']); $this->assertSame('success', $json['status']); $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']); if (\config('app.with_imap')) { $this->assertCount(7, $result['process']); } else { $this->assertCount(6, $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']); if (\config('app.with_imap')) { $this->assertCount(7, $result['process']); } else { $this->assertCount(6, $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 9e306d4..b5c85e4 100644 --- a/src/tests/Feature/Controller/UsersTest.php +++ b/src/tests/Feature/Controller/UsersTest.php @@ -1,1669 +1,1669 @@ 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('isReady', $json['list'][0]); if (\config('app.with_ldap')) { $this->assertArrayHasKey('isLdapReady', $json['list'][0]); } if (\config('app.with_imap')) { $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('isReady', $json); if (\config('app.with_ldap')) { $this->assertArrayHasKey('isLdapReady', $json); } if (\config('app.with_imap')) { $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); $john->status &= ~User::STATUS_IMAP_READY; $john->status &= ~User::STATUS_LDAP_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['isReady']); if (\config('app.with_ldap')) { $this->assertFalse($json['isLdapReady']); } else { $this->assertArrayNotHasKey('isLdapReady', $json); } if (\config('app.with_imap')) { $this->assertFalse($json['isImapReady']); $this->assertSame('user-imap-ready', $json['process'][2]['label']); $this->assertFalse($json['process'][2]['state']); } else { $this->assertArrayNotHasKey('isImapReady', $json); } $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 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['isLdapReady']); $this->assertFalse($json['isReady']); if (\config('app.with_imap')) { + $this->assertFalse($json['isImapReady']); $this->assertSame('user-imap-ready', $json['process'][2]['label']); $this->assertSame(false, $json['process'][2]['state']); } $this->assertSame('success', $json['status']); $this->assertSame('Setup process has been pushed. Please wait.', $json['message']); 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']); if (\config('app.with_imap')) { $this->assertCount(3, $result['process']); } else { $this->assertCount(2, $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']); if (\config('app.with_imap')) { $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']); if (\config('app.with_imap')) { $this->assertCount(3, $result['process']); } else { $this->assertCount(2, $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']); if (\config('app.with_imap')) { $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']); if (\config('app.with_imap')) { $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']); } else { $this->assertCount(6, $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('domain-new', $result['process'][2]['label']); $this->assertSame(true, $result['process'][2]['state']); $this->assertSame('domain-ldap-ready', $result['process'][3]['label']); $this->assertSame(false, $result['process'][3]['state']); $this->assertSame('domain-verified', $result['process'][4]['label']); $this->assertSame(true, $result['process'][4]['state']); $this->assertSame('domain-confirmed', $result['process'][5]['label']); $this->assertSame(false, $result['process'][5]['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/Jobs/Group/UpdateTest.php b/src/tests/Feature/Jobs/Group/UpdateTest.php index 14145bb..a391655 100644 --- a/src/tests/Feature/Jobs/Group/UpdateTest.php +++ b/src/tests/Feature/Jobs/Group/UpdateTest.php @@ -1,80 +1,81 @@ deleteTestGroup('group@kolab.org'); } public function tearDown(): void { $this->deleteTestGroup('group@kolab.org'); parent::tearDown(); } /** * Test job handle * * @group ldap */ public function testHandle(): void { Queue::fake(); // Test non-existing group ID $job = new \App\Jobs\Group\UpdateJob(123); $job->handle(); $this->assertTrue($job->hasFailed()); $this->assertSame("Group 123 could not be found in the database.", $job->failureMessage); // Create the group $group = $this->getTestGroup('group@kolab.org', ['members' => []]); LDAP::createGroup($group); // Test if group properties (members) actually changed in LDAP $group->members = ['test1@gmail.com']; $group->status |= Group::STATUS_LDAP_READY; $group->save(); $job = new \App\Jobs\Group\UpdateJob($group->id); $job->handle(); $ldapGroup = LDAP::getGroup($group->email); $root_dn = \config('ldap.hosted.root_dn'); $this->assertSame('uid=test1@gmail.com,ou=People,ou=kolab.org,' . $root_dn, $ldapGroup['uniquemember']); // Test that suspended group is removed from LDAP $group->suspend(); $job = new \App\Jobs\Group\UpdateJob($group->id); $job->handle(); $this->assertNull(LDAP::getGroup($group->email)); // Test that unsuspended group is added back to LDAP $group->unsuspend(); $job = new \App\Jobs\Group\UpdateJob($group->id); $job->handle(); + /** @var array */ $ldapGroup = LDAP::getGroup($group->email); $this->assertSame($group->email, $ldapGroup['mail']); $this->assertSame('uid=test1@gmail.com,ou=People,ou=kolab.org,' . $root_dn, $ldapGroup['uniquemember']); } }