diff --git a/src/app/Backends/IMAP.php b/src/app/Backends/IMAP.php index 41d4bfe0..1fc1ef04 100644 --- a/src/app/Backends/IMAP.php +++ b/src/app/Backends/IMAP.php @@ -1,148 +1,148 @@ listMailboxes('', '*'); $imap->closeConnection(); if (!is_array($folders)) { throw new \Exception("Failed to get IMAP folders"); } return count($folders) > 0; } /** * Check if a shared folder is set up. * - * @param string $folder Folder name, eg. shared/Resources/Name@domain.tld + * @param string $folder Folder name, e.g. shared/Resources/Name@domain.tld * * @return bool True if a folder exists and is set up, False otherwise */ 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/Resources/)(.*)(@[^@]+)$|', $folder, $matches)) { + if (\preg_match('#^(shared/|shared/Resources/)(.+)(@[^@]+)$#', $folder, $matches)) { $folderName = \mb_convert_encoding($matches[2], 'UTF7-IMAP', 'UTF8'); $folder = $matches[1] . $folderName . $matches[3]; } // 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; } /** * 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 24392fb3..16ede429 100644 --- a/src/app/Backends/LDAP.php +++ b/src/app/Backends/LDAP.php @@ -1,1211 +1,1379 @@ close(); self::$ldap = null; } } /** * 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'); $domainBaseDN = self::baseDN($domain->namespace); $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 = self::baseDN($domain->namespace, $item); 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($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($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($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($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($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; $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['kolabinvitationpolicy'] = 'ACT_MANUAL'; } else { $entry['kolabinvitationpolicy'] = 'ACT_ACCEPT'; } // TODO: Set folder ACL so the owner can write to it // TODO: Do we need to add lrs for anyone? } } } + /** + * Set common shared folder attributes + */ + private static function setSharedFolderAttributes($ldap, SharedFolder $folder, &$entry) + { + $settings = $folder->getSettings(['acl', 'folder']); + + $entry['cn'] = $folder->name; + $entry['kolabfoldertype'] = $folder->type; + $entry['kolabtargetfolder'] = $settings['folder'] ?? ''; + $entry['acl'] = !empty($settings['acl']) ? json_decode($settings['acl'], true) : ''; + } + /** * Set common user attributes */ private static function setUserAttributes(User $user, array &$entry) { $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')->toArray(); $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 (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 */ private static function getGroupEntry($ldap, $email, &$dn = null) { $domainName = explode('@', $email, 2)[1]; $base_dn = self::baseDN($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($domainName, 'Resources'); $attrs = ['dn', 'cn', 'mail', 'objectclass', 'kolabtargetfolder', 'kolabfoldertype', 'kolabinvitationpolicy', 'owner']; // 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($domainName, 'Shared Folders'); + + $attrs = ['dn', 'cn', 'mail', 'objectclass', 'kolabtargetfolder', 'kolabfoldertype', 'acl']; + + // For shared folders we're using search() instead of get_entry() because + // a folder name is not constant, so e.g. on update we might have + // the new name, but not the old one. Email address is constant. + return self::searchEntry($ldap, $base_dn, "(mail=$email)", $attrs, $dn); + } + /** * Get user entry from LDAP. * * @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 */ private static function getUserEntry($ldap, $email, &$dn = null, $full = false) { $domainName = explode('@', $email, 2)[1]; $dn = "uid={$email}," . self::baseDN($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) && !empty($ldap)) { $ldap->close(); } throw new \Exception($message); } /** * Create a base DN string for specified object * * @param string $domainName Domain namespace * @param ?string $ouName Optional name of the sub-tree (OU) * * @return string Full base DN */ private static function baseDN(string $domainName, string $ouName = null): string { $hostedRootDN = \config('ldap.hosted.root_dn'); $dn = "ou={$domainName},{$hostedRootDN}"; if ($ouName) { $dn = "ou={$ouName},{$dn}"; } return $dn; } } diff --git a/src/app/Console/Commands/SharedFoldersCommand.php b/src/app/Console/Commands/SharedFoldersCommand.php new file mode 100644 index 00000000..0df8cf3b --- /dev/null +++ b/src/app/Console/Commands/SharedFoldersCommand.php @@ -0,0 +1,12 @@ +isPublic()) { return $this; } // See if this domain is already owned by another user. $wallet = $this->wallet(); if ($wallet) { \Log::error( "Domain {$this->namespace} is already assigned to {$wallet->owner->email}" ); return $this; } $wallet_id = $user->wallets()->first()->id; foreach ($package->skus as $sku) { for ($i = $sku->pivot->qty; $i > 0; $i--) { \App\Entitlement::create( [ 'wallet_id' => $wallet_id, 'sku_id' => $sku->id, 'cost' => $sku->pivot->cost(), 'fee' => $sku->pivot->fee(), 'entitleable_id' => $this->id, 'entitleable_type' => Domain::class ] ); } } return $this; } /** * Return list of public+active domain names (for current tenant) */ public static function getPublicDomains(): array { return self::withEnvTenantContext() ->whereRaw(sprintf('(type & %s)', Domain::TYPE_PUBLIC)) ->get(['namespace'])->pluck('namespace')->toArray(); } /** * Returns whether this domain is active. * * @return bool */ public function isActive(): bool { return ($this->status & self::STATUS_ACTIVE) > 0; } /** * Returns whether this domain is confirmed the ownership of. * * @return bool */ public function isConfirmed(): bool { return ($this->status & self::STATUS_CONFIRMED) > 0; } /** * Returns whether this domain is deleted. * * @return bool */ public function isDeleted(): bool { return ($this->status & self::STATUS_DELETED) > 0; } /** * Returns whether this domain is registered with us. * * @return bool */ public function isExternal(): bool { return ($this->type & self::TYPE_EXTERNAL) > 0; } /** * Returns whether this domain is hosted with us. * * @return bool */ public function isHosted(): bool { return ($this->type & self::TYPE_HOSTED) > 0; } /** * Returns whether this domain is new. * * @return bool */ public function isNew(): bool { return ($this->status & self::STATUS_NEW) > 0; } /** * Returns whether this domain is public. * * @return bool */ public function isPublic(): bool { return ($this->type & self::TYPE_PUBLIC) > 0; } /** * Returns whether this domain is registered in LDAP. * * @return bool */ public function isLdapReady(): bool { return ($this->status & self::STATUS_LDAP_READY) > 0; } /** * Returns whether this domain is suspended. * * @return bool */ public function isSuspended(): bool { return ($this->status & self::STATUS_SUSPENDED) > 0; } /** * Returns whether this (external) domain has been verified * to exist in DNS. * * @return bool */ public function isVerified(): bool { return ($this->status & self::STATUS_VERIFIED) > 0; } /** * Ensure the namespace is appropriately cased. */ public function setNamespaceAttribute($namespace) { $this->attributes['namespace'] = strtolower($namespace); } /** * Domain status mutator * * @throws \Exception */ public function setStatusAttribute($status) { $new_status = 0; $allowed_values = [ self::STATUS_NEW, self::STATUS_ACTIVE, self::STATUS_SUSPENDED, self::STATUS_DELETED, self::STATUS_CONFIRMED, self::STATUS_VERIFIED, self::STATUS_LDAP_READY, ]; foreach ($allowed_values as $value) { if ($status & $value) { $new_status |= $value; $status ^= $value; } } if ($status > 0) { throw new \Exception("Invalid domain status: {$status}"); } if ($this->isPublic()) { $this->attributes['status'] = $new_status; return; } if ($new_status & self::STATUS_CONFIRMED) { // if we have confirmed ownership of or management access to the domain, then we have // also confirmed the domain exists in DNS. $new_status |= self::STATUS_VERIFIED; $new_status |= self::STATUS_ACTIVE; } if ($new_status & self::STATUS_DELETED && $new_status & self::STATUS_ACTIVE) { $new_status ^= self::STATUS_ACTIVE; } if ($new_status & self::STATUS_SUSPENDED && $new_status & self::STATUS_ACTIVE) { $new_status ^= self::STATUS_ACTIVE; } // if the domain is now active, it is not new anymore. if ($new_status & self::STATUS_ACTIVE && $new_status & self::STATUS_NEW) { $new_status ^= self::STATUS_NEW; } $this->attributes['status'] = $new_status; } /** * Ownership verification by checking for a TXT (or CNAME) record * in the domain's DNS (that matches the verification hash). * * @return bool True if verification was successful, false otherwise * @throws \Exception Throws exception on DNS or DB errors */ public function confirm(): bool { if ($this->isConfirmed()) { return true; } $hash = $this->hash(self::HASH_TEXT); $confirmed = false; // Get DNS records and find a matching TXT entry $records = \dns_get_record($this->namespace, DNS_TXT); if ($records === false) { throw new \Exception("Failed to get DNS record for {$this->namespace}"); } foreach ($records as $record) { if ($record['txt'] === $hash) { $confirmed = true; break; } } // Get DNS records and find a matching CNAME entry // Note: some servers resolve every non-existing name // so we need to define left and right side of the CNAME record // i.e.: kolab-verify IN CNAME .domain.tld. if (!$confirmed) { $cname = $this->hash(self::HASH_CODE) . '.' . $this->namespace; $records = \dns_get_record('kolab-verify.' . $this->namespace, DNS_CNAME); if ($records === false) { throw new \Exception("Failed to get DNS record for {$this->namespace}"); } foreach ($records as $records) { if ($records['target'] === $cname) { $confirmed = true; break; } } } if ($confirmed) { $this->status |= Domain::STATUS_CONFIRMED; $this->save(); } return $confirmed; } /** * Generate a verification hash for this domain * * @param int $mod One of: HASH_CNAME, HASH_CODE (Default), HASH_TEXT * * @return string Verification hash */ public function hash($mod = null): string { $cname = 'kolab-verify'; if ($mod === self::HASH_CNAME) { return $cname; } $hash = \md5('hkccp-verify-' . $this->namespace); return $mod === self::HASH_TEXT ? "$cname=$hash" : $hash; } /** * Checks if there are any objects (users/aliases/groups) in a domain. * Note: Public domains are always reported not empty. * * @return bool True if there are no objects assigned, False otherwise */ public function isEmpty(): bool { if ($this->isPublic()) { return false; } // FIXME: These queries will not use indexes, so maybe we should consider // wallet/entitlements to search in objects that belong to this domain account? $suffix = '@' . $this->namespace; $suffixLen = strlen($suffix); return !( \App\User::whereRaw('substr(email, ?) = ?', [-$suffixLen, $suffix])->exists() || \App\UserAlias::whereRaw('substr(alias, ?) = ?', [-$suffixLen, $suffix])->exists() || \App\Group::whereRaw('substr(email, ?) = ?', [-$suffixLen, $suffix])->exists() || \App\Resource::whereRaw('substr(email, ?) = ?', [-$suffixLen, $suffix])->exists() + || \App\SharedFolder::whereRaw('substr(email, ?) = ?', [-$suffixLen, $suffix])->exists() ); } /** * Suspend this domain. * * @return void */ public function suspend(): void { if ($this->isSuspended()) { return; } $this->status |= Domain::STATUS_SUSPENDED; $this->save(); } /** * Unsuspend this domain. * * The domain is unsuspended through either of the following courses of actions; * * * The account balance has been topped up, or * * a suspected spammer has resolved their issues, or * * the command-line is triggered. * * Therefore, we can also confidently set the domain status to 'active' should the ownership of or management * access to have been confirmed before. * * @return void */ public function unsuspend(): void { if (!$this->isSuspended()) { return; } $this->status ^= Domain::STATUS_SUSPENDED; if ($this->isConfirmed() && $this->isVerified()) { $this->status |= Domain::STATUS_ACTIVE; } $this->save(); } /** * List the users of a domain, so long as the domain is not a public registration domain. * Note: It returns only users with a mailbox. * * @return \App\User[] A list of users */ public function users(): array { if ($this->isPublic()) { return []; } $wallet = $this->wallet(); if (!$wallet) { return []; } $mailboxSKU = \App\Sku::withObjectTenantContext($this)->where('title', 'mailbox')->first(); if (!$mailboxSKU) { \Log::error("No mailbox SKU available."); return []; } $entitlements = $wallet->entitlements() ->where('entitleable_type', \App\User::class) ->where('sku_id', $mailboxSKU->id)->get(); $users = []; foreach ($entitlements as $entitlement) { $users[] = $entitlement->entitleable; } return $users; } /** * Verify if a domain exists in DNS * * @return bool True if registered, False otherwise * @throws \Exception Throws exception on DNS or DB errors */ public function verify(): bool { if ($this->isVerified()) { return true; } $records = \dns_get_record($this->namespace, DNS_ANY); if ($records === false) { throw new \Exception("Failed to get DNS record for {$this->namespace}"); } // It may happen that result contains other domains depending on the host DNS setup // that's why in_array() and not just !empty() if (in_array($this->namespace, array_column($records, 'host'))) { $this->status |= Domain::STATUS_VERIFIED; $this->save(); return true; } return false; } } diff --git a/src/app/Handlers/Beta/SharedFolders.php b/src/app/Handlers/Beta/SharedFolders.php new file mode 100644 index 00000000..93615186 --- /dev/null +++ b/src/app/Handlers/Beta/SharedFolders.php @@ -0,0 +1,49 @@ +wallet()->entitlements() + ->where('entitleable_type', \App\Domain::class)->count() > 0; + } + + return false; + } + + /** + * The priority that specifies the order of SKUs in UI. + * Higher number means higher on the list. + * + * @return int + */ + public static function priority(): int + { + return 10; + } +} diff --git a/src/app/Handlers/SharedFolder.php b/src/app/Handlers/SharedFolder.php index 52a8963e..e0582bac 100644 --- a/src/app/Handlers/SharedFolder.php +++ b/src/app/Handlers/SharedFolder.php @@ -1,17 +1,16 @@ input('search')); + $owner = trim(request()->input('owner')); + $result = collect([]); + + if ($owner) { + if ($owner = User::find($owner)) { + $result = $owner->sharedFolders(false)->orderBy('name')->get(); + } + } elseif (!empty($search)) { + if ($folder = SharedFolder::where('email', $search)->first()) { + $result->push($folder); + } + } + + // Process the result + $result = $result->map( + function ($folder) { + return $this->objectToClient($folder); + } + ); + + $result = [ + 'list' => $result, + 'count' => count($result), + 'message' => \trans('app.search-foundxsharedfolders', ['x' => count($result)]), + ]; + + return response()->json($result); + } + + /** + * Create a new shared folder. + * + * @param \Illuminate\Http\Request $request The API request. + * + * @return \Illuminate\Http\JsonResponse The response + */ + public function store(Request $request) + { + return $this->errorResponse(404); + } +} diff --git a/src/app/Http/Controllers/API/V4/Admin/UsersController.php b/src/app/Http/Controllers/API/V4/Admin/UsersController.php index cf5664bd..d6665bbb 100644 --- a/src/app/Http/Controllers/API/V4/Admin/UsersController.php +++ b/src/app/Http/Controllers/API/V4/Admin/UsersController.php @@ -1,380 +1,382 @@ errorResponse(404); } /** * Searching of user accounts. * * @return \Illuminate\Http\JsonResponse */ public function index() { $search = trim(request()->input('search')); $owner = trim(request()->input('owner')); $result = collect([]); if ($owner) { $owner = User::find($owner); if ($owner) { $result = $owner->users(false)->orderBy('email')->get(); } } elseif (strpos($search, '@')) { // Search by email $result = User::withTrashed()->where('email', $search) ->orderBy('email') ->get(); if ($result->isEmpty()) { // Search by an alias $user_ids = \App\UserAlias::where('alias', $search)->get()->pluck('user_id'); // Search by an external email $ext_user_ids = \App\UserSetting::where('key', 'external_email') ->where('value', $search) ->get() ->pluck('user_id'); $user_ids = $user_ids->merge($ext_user_ids)->unique(); // Search by a distribution list or resource email if ($group = \App\Group::withTrashed()->where('email', $search)->first()) { $user_ids = $user_ids->merge([$group->wallet()->user_id])->unique(); } elseif ($resource = \App\Resource::withTrashed()->where('email', $search)->first()) { $user_ids = $user_ids->merge([$resource->wallet()->user_id])->unique(); + } elseif ($folder = \App\SharedFolder::withTrashed()->where('email', $search)->first()) { + $user_ids = $user_ids->merge([$folder->wallet()->user_id])->unique(); } if (!$user_ids->isEmpty()) { $result = User::withTrashed()->whereIn('id', $user_ids) ->orderBy('email') ->get(); } } } elseif (is_numeric($search)) { // Search by user ID $user = User::withTrashed()->where('id', $search) ->first(); if ($user) { $result->push($user); } } elseif (strpos($search, '.') !== false) { // Search by domain $domain = Domain::withTrashed()->where('namespace', $search) ->first(); if ($domain) { if (($wallet = $domain->wallet()) && ($owner = $wallet->owner()->withTrashed()->first())) { $result->push($owner); } } // A mollie customer ID } elseif (substr($search, 0, 4) == 'cst_') { $setting = \App\WalletSetting::where( [ 'key' => 'mollie_id', 'value' => $search ] )->first(); if ($setting) { if ($wallet = $setting->wallet) { if ($owner = $wallet->owner()->withTrashed()->first()) { $result->push($owner); } } } // A mollie transaction ID } elseif (substr($search, 0, 3) == 'tr_') { $payment = \App\Payment::find($search); if ($payment) { if ($owner = $payment->wallet->owner()->withTrashed()->first()) { $result->push($owner); } } } elseif (!empty($search)) { $wallet = Wallet::find($search); if ($wallet) { if ($owner = $wallet->owner()->withTrashed()->first()) { $result->push($owner); } } } // Process the result $result = $result->map( function ($user) { return $this->objectToClient($user, true); } ); $result = [ 'list' => $result, 'count' => count($result), 'message' => \trans('app.search-foundxusers', ['x' => count($result)]), ]; return response()->json($result); } /** * Reset 2-Factor Authentication for the user * * @param \Illuminate\Http\Request $request The API request. * @param string $id User identifier * * @return \Illuminate\Http\JsonResponse The response */ public function reset2FA(Request $request, $id) { $user = User::find($id); if (!$this->checkTenant($user)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canUpdate($user)) { return $this->errorResponse(403); } $sku = Sku::withObjectTenantContext($user)->where('title', '2fa')->first(); // Note: we do select first, so the observer can delete // 2FA preferences from Roundcube database, so don't // be tempted to replace first() with delete() below $entitlement = $user->entitlements()->where('sku_id', $sku->id)->first(); $entitlement->delete(); return response()->json([ 'status' => 'success', 'message' => \trans('app.user-reset-2fa-success'), ]); } /** * Set/Add a SKU for the user * * @param \Illuminate\Http\Request $request The API request. * @param string $id User identifier * @param string $sku SKU title * * @return \Illuminate\Http\JsonResponse The response */ public function setSku(Request $request, $id, $sku) { // For now we allow adding the 'beta' SKU only if ($sku != 'beta') { return $this->errorResponse(404); } $user = User::find($id); if (!$this->checkTenant($user)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canUpdate($user)) { return $this->errorResponse(403); } $sku = Sku::withObjectTenantContext($user)->where('title', $sku)->first(); if (!$sku) { return $this->errorResponse(404); } if ($user->entitlements()->where('sku_id', $sku->id)->first()) { return $this->errorResponse(422, \trans('app.user-set-sku-already-exists')); } $user->assignSku($sku); $entitlement = $user->entitlements()->where('sku_id', $sku->id)->first(); return response()->json([ 'status' => 'success', 'message' => \trans('app.user-set-sku-success'), 'sku' => [ 'cost' => $entitlement->cost, 'name' => $sku->name, 'id' => $sku->id, ] ]); } /** * Display information on the user account specified by $id. * * @param int $id The account to show information for. * * @return \Illuminate\Http\JsonResponse */ public function show($id) { $user = User::find($id); if (!$this->checkTenant($user)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canRead($user)) { return $this->errorResponse(403); } $response = $this->userResponse($user); // Simplified Entitlement/SKU information, // TODO: I agree this format may need to be extended in future $response['skus'] = []; foreach ($user->entitlements as $ent) { $sku = $ent->sku; if (!isset($response['skus'][$sku->id])) { $response['skus'][$sku->id] = ['costs' => [], 'count' => 0]; } $response['skus'][$sku->id]['count']++; $response['skus'][$sku->id]['costs'][] = $ent->cost; } $response['config'] = $user->getConfig(); return response()->json($response); } /** * Create a new user record. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function store(Request $request) { return $this->errorResponse(404); } /** * Suspend the user * * @param \Illuminate\Http\Request $request The API request. * @param string $id User identifier * * @return \Illuminate\Http\JsonResponse The response */ public function suspend(Request $request, $id) { $user = User::find($id); if (!$this->checkTenant($user)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canUpdate($user)) { return $this->errorResponse(403); } $user->suspend(); return response()->json([ 'status' => 'success', 'message' => \trans('app.user-suspend-success'), ]); } /** * Un-Suspend the user * * @param \Illuminate\Http\Request $request The API request. * @param string $id User identifier * * @return \Illuminate\Http\JsonResponse The response */ public function unsuspend(Request $request, $id) { $user = User::find($id); if (!$this->checkTenant($user)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canUpdate($user)) { return $this->errorResponse(403); } $user->unsuspend(); return response()->json([ 'status' => 'success', 'message' => \trans('app.user-unsuspend-success'), ]); } /** * Update user data. * * @param \Illuminate\Http\Request $request The API request. * @param string $id User identifier * * @return \Illuminate\Http\JsonResponse The response */ public function update(Request $request, $id) { $user = User::find($id); if (!$this->checkTenant($user)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canUpdate($user)) { return $this->errorResponse(403); } // For now admins can change only user external email address $rules = []; if (array_key_exists('external_email', $request->input())) { $rules['external_email'] = 'email'; } // Validate input $v = Validator::make($request->all(), $rules); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } // Update user settings $settings = $request->only(array_keys($rules)); if (!empty($settings)) { $user->setSettings($settings); } return response()->json([ 'status' => 'success', 'message' => \trans('app.user-update-success'), ]); } } diff --git a/src/app/Http/Controllers/API/V4/PaymentsController.php b/src/app/Http/Controllers/API/V4/PaymentsController.php index 28b77266..8f62437c 100644 --- a/src/app/Http/Controllers/API/V4/PaymentsController.php +++ b/src/app/Http/Controllers/API/V4/PaymentsController.php @@ -1,482 +1,482 @@ guard()->user(); // TODO: Wallet selection $wallet = $user->wallets()->first(); $mandate = self::walletMandate($wallet); return response()->json($mandate); } /** * Create a new auto-payment mandate. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function mandateCreate(Request $request) { $user = $this->guard()->user(); // TODO: Wallet selection $wallet = $user->wallets()->first(); // Input validation if ($errors = self::mandateValidate($request, $wallet)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } $wallet->setSettings([ 'mandate_amount' => $request->amount, 'mandate_balance' => $request->balance, ]); $mandate = [ 'currency' => $wallet->currency, 'description' => Tenant::getConfig($user->tenant_id, 'app.name') . ' Auto-Payment Setup', 'methodId' => $request->methodId ]; // Normally the auto-payment setup operation is 0, if the balance is below the threshold // we'll top-up the wallet with the configured auto-payment amount if ($wallet->balance < intval($request->balance * 100)) { $mandate['amount'] = intval($request->amount * 100); } $provider = PaymentProvider::factory($wallet); $result = $provider->createMandate($wallet, $mandate); $result['status'] = 'success'; return response()->json($result); } /** * Revoke the auto-payment mandate. * * @return \Illuminate\Http\JsonResponse The response */ public function mandateDelete() { $user = $this->guard()->user(); // TODO: Wallet selection $wallet = $user->wallets()->first(); $provider = PaymentProvider::factory($wallet); $provider->deleteMandate($wallet); $wallet->setSetting('mandate_disabled', null); return response()->json([ 'status' => 'success', 'message' => \trans('app.mandate-delete-success'), ]); } /** * Update a new auto-payment mandate. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function mandateUpdate(Request $request) { $user = $this->guard()->user(); // TODO: Wallet selection $wallet = $user->wallets()->first(); // Input validation if ($errors = self::mandateValidate($request, $wallet)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } $wallet->setSettings([ 'mandate_amount' => $request->amount, 'mandate_balance' => $request->balance, // Re-enable the mandate to give it a chance to charge again // after it has been disabled (e.g. because the mandate amount was too small) 'mandate_disabled' => null, ]); // Trigger auto-payment if the balance is below the threshold if ($wallet->balance < intval($request->balance * 100)) { \App\Jobs\WalletCharge::dispatch($wallet); } $result = self::walletMandate($wallet); $result['status'] = 'success'; $result['message'] = \trans('app.mandate-update-success'); return response()->json($result); } /** * Validate an auto-payment mandate request. * * @param \Illuminate\Http\Request $request The API request. * @param \App\Wallet $wallet The wallet * * @return array|null List of errors on error or Null on success */ protected static function mandateValidate(Request $request, Wallet $wallet) { $rules = [ 'amount' => 'required|numeric', 'balance' => 'required|numeric|min:0', ]; // Check required fields $v = Validator::make($request->all(), $rules); // TODO: allow comma as a decimal point? if ($v->fails()) { return $v->errors()->toArray(); } $amount = (int) ($request->amount * 100); // Validate the minimum value // It has to be at least minimum payment amount and must cover current debt if ( $wallet->balance < 0 - && $wallet->balance * -1 > PaymentProvider::MIN_AMOUNT + && $wallet->balance <= PaymentProvider::MIN_AMOUNT * -1 && $wallet->balance + $amount < 0 ) { return ['amount' => \trans('validation.minamountdebt')]; } if ($amount < PaymentProvider::MIN_AMOUNT) { $min = $wallet->money(PaymentProvider::MIN_AMOUNT); return ['amount' => \trans('validation.minamount', ['amount' => $min])]; } return null; } /** * Create a new payment. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function store(Request $request) { $user = $this->guard()->user(); // TODO: Wallet selection $wallet = $user->wallets()->first(); $rules = [ 'amount' => 'required|numeric', ]; // Check required fields $v = Validator::make($request->all(), $rules); // TODO: allow comma as a decimal point? if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } $amount = (int) ($request->amount * 100); // Validate the minimum value if ($amount < PaymentProvider::MIN_AMOUNT) { $min = $wallet->money(PaymentProvider::MIN_AMOUNT); $errors = ['amount' => \trans('validation.minamount', ['amount' => $min])]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } $request = [ 'type' => PaymentProvider::TYPE_ONEOFF, 'currency' => $request->currency, 'amount' => $amount, 'methodId' => $request->methodId, 'description' => Tenant::getConfig($user->tenant_id, 'app.name') . ' Payment', ]; $provider = PaymentProvider::factory($wallet); $result = $provider->payment($wallet, $request); $result['status'] = 'success'; return response()->json($result); } /** * Delete a pending payment. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ // TODO currently unused // public function cancel(Request $request) // { // $user = $this->guard()->user(); // // TODO: Wallet selection // $wallet = $user->wallets()->first(); // $paymentId = $request->payment; // $user_owns_payment = Payment::where('id', $paymentId) // ->where('wallet_id', $wallet->id) // ->exists(); // if (!$user_owns_payment) { // return $this->errorResponse(404); // } // $provider = PaymentProvider::factory($wallet); // if ($provider->cancel($wallet, $paymentId)) { // $result = ['status' => 'success']; // return response()->json($result); // } // return $this->errorResponse(404); // } /** * Update payment status (and balance). * * @param string $provider Provider name * * @return \Illuminate\Http\Response The response */ public function webhook($provider) { $code = 200; if ($provider = PaymentProvider::factory($provider)) { $code = $provider->webhook(); } return response($code < 400 ? 'Success' : 'Server error', $code); } /** * Top up a wallet with a "recurring" payment. * * @param \App\Wallet $wallet The wallet to charge * * @return bool True if the payment has been initialized */ public static function topUpWallet(Wallet $wallet): bool { $settings = $wallet->getSettings(['mandate_disabled', 'mandate_balance', 'mandate_amount']); if (!empty($settings['mandate_disabled'])) { return false; } $min_balance = (int) (floatval($settings['mandate_balance']) * 100); $amount = (int) (floatval($settings['mandate_amount']) * 100); // The wallet balance is greater than the auto-payment threshold if ($wallet->balance >= $min_balance) { // Do nothing return false; } $provider = PaymentProvider::factory($wallet); $mandate = (array) $provider->getMandate($wallet); if (empty($mandate['isValid'])) { return false; } // The defined top-up amount is not enough // Disable auto-payment and notify the user if ($wallet->balance + $amount < 0) { // Disable (not remove) the mandate $wallet->setSetting('mandate_disabled', 1); \App\Jobs\PaymentMandateDisabledEmail::dispatch($wallet); return false; } $request = [ 'type' => PaymentProvider::TYPE_RECURRING, 'currency' => $wallet->currency, 'amount' => $amount, 'methodId' => PaymentProvider::METHOD_CREDITCARD, 'description' => Tenant::getConfig($wallet->owner->tenant_id, 'app.name') . ' Recurring Payment', ]; $result = $provider->payment($wallet, $request); return !empty($result); } /** * Returns auto-payment mandate info for the specified wallet * * @param \App\Wallet $wallet A wallet object * * @return array A mandate metadata */ public static function walletMandate(Wallet $wallet): array { $provider = PaymentProvider::factory($wallet); $settings = $wallet->getSettings(['mandate_disabled', 'mandate_balance', 'mandate_amount']); // Get the Mandate info $mandate = (array) $provider->getMandate($wallet); $mandate['amount'] = (int) (PaymentProvider::MIN_AMOUNT / 100); $mandate['balance'] = 0; $mandate['isDisabled'] = !empty($mandate['id']) && $settings['mandate_disabled']; foreach (['amount', 'balance'] as $key) { if (($value = $settings["mandate_{$key}"]) !== null) { $mandate[$key] = $value; } } return $mandate; } /** * List supported payment methods. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function paymentMethods(Request $request) { $user = $this->guard()->user(); // TODO: Wallet selection $wallet = $user->wallets()->first(); $methods = PaymentProvider::paymentMethods($wallet, $request->type); \Log::debug("Provider methods" . var_export(json_encode($methods), true)); return response()->json($methods); } /** * Check for pending payments. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function hasPayments(Request $request) { $user = $this->guard()->user(); // TODO: Wallet selection $wallet = $user->wallets()->first(); $exists = Payment::where('wallet_id', $wallet->id) ->where('type', PaymentProvider::TYPE_ONEOFF) ->whereIn('status', [ PaymentProvider::STATUS_OPEN, PaymentProvider::STATUS_PENDING, PaymentProvider::STATUS_AUTHORIZED ]) ->exists(); return response()->json([ 'status' => 'success', 'hasPending' => $exists ]); } /** * List pending payments. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function payments(Request $request) { $user = $this->guard()->user(); // TODO: Wallet selection $wallet = $user->wallets()->first(); $pageSize = 10; $page = intval(request()->input('page')) ?: 1; $hasMore = false; $result = Payment::where('wallet_id', $wallet->id) ->where('type', PaymentProvider::TYPE_ONEOFF) ->whereIn('status', [ PaymentProvider::STATUS_OPEN, PaymentProvider::STATUS_PENDING, PaymentProvider::STATUS_AUTHORIZED ]) ->orderBy('created_at', 'desc') ->limit($pageSize + 1) ->offset($pageSize * ($page - 1)) ->get(); if (count($result) > $pageSize) { $result->pop(); $hasMore = true; } $result = $result->map(function ($item) use ($wallet) { $provider = PaymentProvider::factory($item->provider); $payment = $provider->getPayment($item->id); $entry = [ 'id' => $item->id, 'createdAt' => $item->created_at->format('Y-m-d H:i'), 'type' => $item->type, 'description' => $item->description, 'amount' => $item->amount, 'currency' => $wallet->currency, // note: $item->currency/$item->currency_amount might be different 'status' => $item->status, 'isCancelable' => $payment['isCancelable'], 'checkoutUrl' => $payment['checkoutUrl'] ]; return $entry; }); return response()->json([ 'status' => 'success', 'list' => $result, 'count' => count($result), 'hasMore' => $hasMore, 'page' => $page, ]); } } diff --git a/src/app/Http/Controllers/API/V4/Reseller/SharedFoldersController.php b/src/app/Http/Controllers/API/V4/Reseller/SharedFoldersController.php new file mode 100644 index 00000000..a2ba6abe --- /dev/null +++ b/src/app/Http/Controllers/API/V4/Reseller/SharedFoldersController.php @@ -0,0 +1,46 @@ +input('search')); + $owner = trim(request()->input('owner')); + $result = collect([]); + + if ($owner) { + if ($owner = User::withSubjectTenantContext()->find($owner)) { + $result = $owner->sharedFolders(false)->orderBy('name')->get(); + } + } elseif (!empty($search)) { + if ($folder = SharedFolder::withSubjectTenantContext()->where('email', $search)->first()) { + $result->push($folder); + } + } + + // Process the result + $result = $result->map( + function ($folder) { + return $this->objectToClient($folder); + } + ); + + $result = [ + 'list' => $result, + 'count' => count($result), + 'message' => \trans('app.search-foundxsharedfolders', ['x' => count($result)]), + ]; + + return response()->json($result); + } +} diff --git a/src/app/Http/Controllers/API/V4/Reseller/UsersController.php b/src/app/Http/Controllers/API/V4/Reseller/UsersController.php index 01104e0c..7b3de67c 100644 --- a/src/app/Http/Controllers/API/V4/Reseller/UsersController.php +++ b/src/app/Http/Controllers/API/V4/Reseller/UsersController.php @@ -1,108 +1,112 @@ input('search')); $owner = trim(request()->input('owner')); $result = collect([]); if ($owner) { $owner = User::where('id', $owner) ->withSubjectTenantContext() ->whereNull('role') ->first(); if ($owner) { $result = $owner->users(false)->whereNull('role')->orderBy('email')->get(); } } elseif (strpos($search, '@')) { // Search by email $result = User::withTrashed()->where('email', $search) ->withSubjectTenantContext() ->whereNull('role') ->orderBy('email') ->get(); if ($result->isEmpty()) { // Search by an alias $user_ids = UserAlias::where('alias', $search)->get()->pluck('user_id'); // Search by an external email $ext_user_ids = UserSetting::where('key', 'external_email') ->where('value', $search) ->get() ->pluck('user_id'); $user_ids = $user_ids->merge($ext_user_ids)->unique(); // Search by a distribution list email if ($group = Group::withTrashed()->where('email', $search)->first()) { $user_ids = $user_ids->merge([$group->wallet()->user_id])->unique(); + } elseif ($resource = \App\Resource::withTrashed()->where('email', $search)->first()) { + $user_ids = $user_ids->merge([$resource->wallet()->user_id])->unique(); + } elseif ($folder = \App\SharedFolder::withTrashed()->where('email', $search)->first()) { + $user_ids = $user_ids->merge([$folder->wallet()->user_id])->unique(); } if (!$user_ids->isEmpty()) { $result = User::withTrashed()->whereIn('id', $user_ids) ->withSubjectTenantContext() ->whereNull('role') ->orderBy('email') ->get(); } } } elseif (is_numeric($search)) { // Search by user ID $user = User::withTrashed()->where('id', $search) ->withSubjectTenantContext() ->whereNull('role') ->first(); if ($user) { $result->push($user); } } elseif (!empty($search)) { // Search by domain $domain = Domain::withTrashed()->where('namespace', $search) ->withSubjectTenantContext() ->first(); if ($domain) { if ( ($wallet = $domain->wallet()) && ($owner = $wallet->owner()->withTrashed()->withSubjectTenantContext()->first()) && empty($owner->role) ) { $result->push($owner); } } } // Process the result $result = $result->map( function ($user) { return $this->objectToClient($user, true); } ); $result = [ 'list' => $result, 'count' => count($result), 'message' => \trans('app.search-foundxusers', ['x' => count($result)]), ]; return response()->json($result); } } diff --git a/src/app/Http/Controllers/API/V4/ResourcesController.php b/src/app/Http/Controllers/API/V4/ResourcesController.php index b7c34d7b..d912df83 100644 --- a/src/app/Http/Controllers/API/V4/ResourcesController.php +++ b/src/app/Http/Controllers/API/V4/ResourcesController.php @@ -1,353 +1,352 @@ errorResponse(404); } /** * Delete a resource. * * @param int $id Resource identifier * * @return \Illuminate\Http\JsonResponse The response */ public function destroy($id) { $resource = Resource::find($id); if (!$this->checkTenant($resource)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canDelete($resource)) { return $this->errorResponse(403); } $resource->delete(); return response()->json([ 'status' => 'success', 'message' => \trans('app.resource-delete-success'), ]); } /** * Show the form for editing the specified resource. * * @param int $id Resource identifier * * @return \Illuminate\Http\JsonResponse */ public function edit($id) { return $this->errorResponse(404); } /** * Listing of resources belonging to the authenticated user. * * The resource-entitlements billed to the current user wallet(s) * * @return \Illuminate\Http\JsonResponse */ public function index() { $user = $this->guard()->user(); $result = $user->resources()->orderBy('name')->get() ->map(function (Resource $resource) { return $this->objectToClient($resource); }); return response()->json($result); } /** * Set the resource configuration. * * @param int $id Resource identifier * * @return \Illuminate\Http\JsonResponse|void */ public function setConfig($id) { $resource = Resource::find($id); if (!$this->checkTenant($resource)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canUpdate($resource)) { return $this->errorResponse(403); } $errors = $resource->setConfig(request()->input()); if (!empty($errors)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } return response()->json([ 'status' => 'success', 'message' => \trans('app.resource-setconfig-success'), ]); } /** * Display information of a resource specified by $id. * * @param int $id Resource identifier * * @return \Illuminate\Http\JsonResponse */ public function show($id) { $resource = Resource::find($id); if (!$this->checkTenant($resource)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canRead($resource)) { return $this->errorResponse(403); } $response = $this->objectToClient($resource, true); $response['statusInfo'] = self::statusInfo($resource); // Resource configuration, e.g. invitation_policy $response['config'] = $resource->getConfig(); return response()->json($response); } /** * Fetch resource status (and reload setup process) * * @param int $id Resource identifier * * @return \Illuminate\Http\JsonResponse */ public function status($id) { $resource = Resource::find($id); if (!$this->checkTenant($resource)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canRead($resource)) { return $this->errorResponse(403); } $response = $this->processStateUpdate($resource); $response = array_merge($response, self::objectState($resource)); return response()->json($response); } /** * Resource status (extended) information * * @param \App\Resource $resource Resource object * * @return array Status information */ public static function statusInfo(Resource $resource): array { return self::processStateInfo( $resource, [ 'resource-new' => true, 'resource-ldap-ready' => $resource->isLdapReady(), 'resource-imap-ready' => $resource->isImapReady(), ] ); } /** * Create a new resource record. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function store(Request $request) { $current_user = $this->guard()->user(); $owner = $current_user->wallet()->owner; if ($owner->id != $current_user->id) { return $this->errorResponse(403); } $domain = request()->input('domain'); $rules = ['name' => ['required', 'string', new ResourceName($owner, $domain)]]; $v = Validator::make($request->all(), $rules); if ($v->fails()) { - $errors = $v->errors()->toArray(); return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } DB::beginTransaction(); // Create the resource $resource = new Resource(); $resource->name = request()->input('name'); $resource->domain = $domain; $resource->save(); $resource->assignToWallet($owner->wallets->first()); DB::commit(); return response()->json([ 'status' => 'success', 'message' => \trans('app.resource-create-success'), ]); } /** * Update a resource. * * @param \Illuminate\Http\Request $request The API request. * @param string $id Resource identifier * * @return \Illuminate\Http\JsonResponse The response */ public function update(Request $request, $id) { $resource = Resource::find($id); if (!$this->checkTenant($resource)) { return $this->errorResponse(404); } $current_user = $this->guard()->user(); if (!$current_user->canUpdate($resource)) { return $this->errorResponse(403); } $owner = $resource->wallet()->owner; $name = $request->input('name'); $errors = []; // Validate the resource name if ($name !== null && $name != $resource->name) { $domainName = explode('@', $resource->email, 2)[1]; $rules = ['name' => ['required', 'string', new ResourceName($owner, $domainName)]]; $v = Validator::make($request->all(), $rules); if ($v->fails()) { $errors = $v->errors()->toArray(); } else { $resource->name = $name; } } if (!empty($errors)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } $resource->save(); return response()->json([ 'status' => 'success', 'message' => \trans('app.resource-update-success'), ]); } /** * Execute (synchronously) specified step in a resource setup process. * * @param \App\Resource $resource Resource object * @param string $step Step identifier (as in self::statusInfo()) * * @return bool|null True if the execution succeeded, False if not, Null when * the job has been sent to the worker (result unknown) */ public static function execProcessStep(Resource $resource, string $step): ?bool { try { if (strpos($step, 'domain-') === 0) { return DomainsController::execProcessStep($resource->domain(), $step); } switch ($step) { case 'resource-ldap-ready': // Resource not in LDAP, create it $job = new \App\Jobs\Resource\CreateJob($resource->id); $job->handle(); $resource->refresh(); return $resource->isLdapReady(); case 'resource-imap-ready': // Resource not in IMAP? Verify again // Do it synchronously if the imap admin credentials are available // otherwise let the worker do the job if (!\config('imap.admin_password')) { \App\Jobs\Resource\VerifyJob::dispatch($resource->id); return null; } $job = new \App\Jobs\Resource\VerifyJob($resource->id); $job->handle(); $resource->refresh(); return $resource->isImapReady(); } } catch (\Exception $e) { \Log::error($e); } return false; } /** * Prepare resource statuses for the UI * * @param \App\Resource $resource Resource object * * @return array Statuses array */ protected static function objectState(Resource $resource): array { return [ 'isLdapReady' => $resource->isLdapReady(), 'isImapReady' => $resource->isImapReady(), 'isActive' => $resource->isActive(), 'isDeleted' => $resource->isDeleted() || $resource->trashed(), ]; } } diff --git a/src/app/Http/Controllers/API/V4/SharedFoldersController.php b/src/app/Http/Controllers/API/V4/SharedFoldersController.php new file mode 100644 index 00000000..0bee2b5a --- /dev/null +++ b/src/app/Http/Controllers/API/V4/SharedFoldersController.php @@ -0,0 +1,357 @@ +errorResponse(404); + } + + /** + * Delete a shared folder. + * + * @param int $id Shared folder identifier + * + * @return \Illuminate\Http\JsonResponse The response + */ + public function destroy($id) + { + $folder = SharedFolder::find($id); + + if (!$this->checkTenant($folder)) { + return $this->errorResponse(404); + } + + if (!$this->guard()->user()->canDelete($folder)) { + return $this->errorResponse(403); + } + + $folder->delete(); + + return response()->json([ + 'status' => 'success', + 'message' => \trans('app.shared-folder-delete-success'), + ]); + } + + /** + * Show the form for editing the specified shared folder. + * + * @param int $id Shared folder identifier + * + * @return \Illuminate\Http\JsonResponse + */ + public function edit($id) + { + return $this->errorResponse(404); + } + + /** + * Listing of a shared folders belonging to the authenticated user. + * + * The shared-folder entitlements billed to the current user wallet(s) + * + * @return \Illuminate\Http\JsonResponse + */ + public function index() + { + $user = $this->guard()->user(); + + $result = $user->sharedFolders()->orderBy('name')->get() + ->map(function (SharedFolder $folder) { + return $this->objectToClient($folder); + }); + + return response()->json($result); + } + + /** + * Set the shared folder configuration. + * + * @param int $id Shared folder identifier + * + * @return \Illuminate\Http\JsonResponse|void + */ + public function setConfig($id) + { + $folder = SharedFolder::find($id); + + if (!$this->checkTenant($folder)) { + return $this->errorResponse(404); + } + + if (!$this->guard()->user()->canUpdate($folder)) { + return $this->errorResponse(403); + } + + $errors = $folder->setConfig(request()->input()); + + if (!empty($errors)) { + return response()->json(['status' => 'error', 'errors' => $errors], 422); + } + + return response()->json([ + 'status' => 'success', + 'message' => \trans('app.shared-folder-setconfig-success'), + ]); + } + + /** + * Display information of a shared folder specified by $id. + * + * @param int $id Shared folder identifier + * + * @return \Illuminate\Http\JsonResponse + */ + public function show($id) + { + $folder = SharedFolder::find($id); + + if (!$this->checkTenant($folder)) { + return $this->errorResponse(404); + } + + if (!$this->guard()->user()->canRead($folder)) { + return $this->errorResponse(403); + } + + $response = $this->objectToClient($folder, true); + + $response['statusInfo'] = self::statusInfo($folder); + + // Shared folder configuration, e.g. acl + $response['config'] = $folder->getConfig(); + + return response()->json($response); + } + + /** + * Fetch a shared folder status (and reload setup process) + * + * @param int $id Shared folder identifier + * + * @return \Illuminate\Http\JsonResponse + */ + public function status($id) + { + $folder = SharedFolder::find($id); + + if (!$this->checkTenant($folder)) { + return $this->errorResponse(404); + } + + if (!$this->guard()->user()->canRead($folder)) { + return $this->errorResponse(403); + } + + $response = $this->processStateUpdate($folder); + $response = array_merge($response, self::objectState($folder)); + + return response()->json($response); + } + + /** + * SharedFolder status (extended) information + * + * @param \App\SharedFolder $folder SharedFolder object + * + * @return array Status information + */ + public static function statusInfo(SharedFolder $folder): array + { + return self::processStateInfo( + $folder, + [ + 'shared-folder-new' => true, + 'shared-folder-ldap-ready' => $folder->isLdapReady(), + 'shared-folder-imap-ready' => $folder->isImapReady(), + ] + ); + } + + /** + * Create a new shared folder record. + * + * @param \Illuminate\Http\Request $request The API request. + * + * @return \Illuminate\Http\JsonResponse The response + */ + public function store(Request $request) + { + $current_user = $this->guard()->user(); + $owner = $current_user->wallet()->owner; + + if ($owner->id != $current_user->id) { + return $this->errorResponse(403); + } + + $domain = request()->input('domain'); + + $rules = [ + 'name' => ['required', 'string', new SharedFolderName($owner, $domain)], + 'type' => ['required', 'string', new SharedFolderType()] + ]; + + $v = Validator::make($request->all(), $rules); + + if ($v->fails()) { + return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); + } + + DB::beginTransaction(); + + // Create the shared folder + $folder = new SharedFolder(); + $folder->name = request()->input('name'); + $folder->type = request()->input('type'); + $folder->domain = $domain; + $folder->save(); + + $folder->assignToWallet($owner->wallets->first()); + + DB::commit(); + + return response()->json([ + 'status' => 'success', + 'message' => \trans('app.shared-folder-create-success'), + ]); + } + + /** + * Update a shared folder. + * + * @param \Illuminate\Http\Request $request The API request. + * @param string $id Shared folder identifier + * + * @return \Illuminate\Http\JsonResponse The response + */ + public function update(Request $request, $id) + { + $folder = SharedFolder::find($id); + + if (!$this->checkTenant($folder)) { + return $this->errorResponse(404); + } + + $current_user = $this->guard()->user(); + + if (!$current_user->canUpdate($folder)) { + return $this->errorResponse(403); + } + + $owner = $folder->wallet()->owner; + + $name = $request->input('name'); + $errors = []; + + // Validate the folder name + if ($name !== null && $name != $folder->name) { + $domainName = explode('@', $folder->email, 2)[1]; + $rules = ['name' => ['required', 'string', new SharedFolderName($owner, $domainName)]]; + + $v = Validator::make($request->all(), $rules); + + if ($v->fails()) { + $errors = $v->errors()->toArray(); + } else { + $folder->name = $name; + } + } + + if (!empty($errors)) { + return response()->json(['status' => 'error', 'errors' => $errors], 422); + } + + $folder->save(); + + return response()->json([ + 'status' => 'success', + 'message' => \trans('app.shared-folder-update-success'), + ]); + } + + /** + * Execute (synchronously) specified step in a shared folder setup process. + * + * @param \App\SharedFolder $folder Shared folder object + * @param string $step Step identifier (as in self::statusInfo()) + * + * @return bool|null True if the execution succeeded, False if not, Null when + * the job has been sent to the worker (result unknown) + */ + public static function execProcessStep(SharedFolder $folder, string $step): ?bool + { + try { + if (strpos($step, 'domain-') === 0) { + return DomainsController::execProcessStep($folder->domain(), $step); + } + + switch ($step) { + case 'shared-folder-ldap-ready': + // Shared folder not in LDAP, create it + $job = new \App\Jobs\SharedFolder\CreateJob($folder->id); + $job->handle(); + + $folder->refresh(); + + return $folder->isLdapReady(); + + case 'shared-folder-imap-ready': + // Shared folder not in IMAP? Verify again + // Do it synchronously if the imap admin credentials are available + // otherwise let the worker do the job + if (!\config('imap.admin_password')) { + \App\Jobs\SharedFolder\VerifyJob::dispatch($folder->id); + + return null; + } + + $job = new \App\Jobs\SharedFolder\VerifyJob($folder->id); + $job->handle(); + + $folder->refresh(); + + return $folder->isImapReady(); + } + } catch (\Exception $e) { + \Log::error($e); + } + + return false; + } + + /** + * Prepare shared folder statuses for the UI + * + * @param \App\SharedFolder $folder Shared folder object + * + * @return array Statuses array + */ + protected static function objectState(SharedFolder $folder): array + { + return [ + 'isLdapReady' => $folder->isLdapReady(), + 'isImapReady' => $folder->isImapReady(), + 'isActive' => $folder->isActive(), + 'isDeleted' => $folder->isDeleted() || $folder->trashed(), + ]; + } +} diff --git a/src/app/Http/Controllers/API/V4/UsersController.php b/src/app/Http/Controllers/API/V4/UsersController.php index e427555c..56a9a618 100644 --- a/src/app/Http/Controllers/API/V4/UsersController.php +++ b/src/app/Http/Controllers/API/V4/UsersController.php @@ -1,808 +1,810 @@ find($id); if (empty($user)) { return $this->errorResponse(404); } // User can't remove himself until he's the controller if (!$this->guard()->user()->canDelete($user)) { return $this->errorResponse(403); } $user->delete(); return response()->json([ 'status' => 'success', 'message' => \trans('app.user-delete-success'), ]); } /** * Listing of users. * * The user-entitlements billed to the current user wallet(s) * * @return \Illuminate\Http\JsonResponse */ public function index() { $user = $this->guard()->user(); $search = trim(request()->input('search')); $page = intval(request()->input('page')) ?: 1; $pageSize = 20; $hasMore = false; $result = $user->users(); // Search by user email, alias or name if (strlen($search) > 0) { // thanks to cloning we skip some extra queries in $user->users() $allUsers1 = clone $result; $allUsers2 = clone $result; $result->whereLike('email', $search) ->union( $allUsers1->join('user_aliases', 'users.id', '=', 'user_aliases.user_id') ->whereLike('alias', $search) ) ->union( $allUsers2->join('user_settings', 'users.id', '=', 'user_settings.user_id') ->whereLike('value', $search) ->whereIn('key', ['first_name', 'last_name']) ); } $result = $result->orderBy('email') ->limit($pageSize + 1) ->offset($pageSize * ($page - 1)) ->get(); if (count($result) > $pageSize) { $result->pop(); $hasMore = true; } // Process the result $result = $result->map( function ($user) { return $this->objectToClient($user); } ); $result = [ 'list' => $result, 'count' => count($result), 'hasMore' => $hasMore, ]; return response()->json($result); } /** * Set user config. * * @param int $id The user * * @return \Illuminate\Http\JsonResponse */ public function setConfig($id) { $user = User::find($id); if (empty($user)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canUpdate($user)) { return $this->errorResponse(403); } $errors = $user->setConfig(request()->input()); if (!empty($errors)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } return response()->json([ 'status' => 'success', 'message' => \trans('app.user-setconfig-success'), ]); } /** * Display information on the user account specified by $id. * * @param int $id The account to show information for. * * @return \Illuminate\Http\JsonResponse */ public function show($id) { $user = User::withEnvTenantContext()->find($id); if (empty($user)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canRead($user)) { return $this->errorResponse(403); } $response = $this->userResponse($user); $response['skus'] = \App\Entitlement::objectEntitlementsSummary($user); $response['config'] = $user->getConfig(); return response()->json($response); } /** * Fetch user status (and reload setup process) * * @param int $id User identifier * * @return \Illuminate\Http\JsonResponse */ public function status($id) { $user = User::withEnvTenantContext()->find($id); if (empty($user)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canRead($user)) { return $this->errorResponse(403); } $response = $this->processStateUpdate($user); $response = array_merge($response, self::objectState($user)); return response()->json($response); } /** * User status (extended) information * * @param \App\User $user User object * * @return array Status information */ public static function statusInfo(User $user): array { $process = self::processStateInfo( $user, [ 'user-new' => true, 'user-ldap-ready' => $user->isLdapReady(), 'user-imap-ready' => $user->isImapReady(), ] ); // Check if the user is a controller of his wallet $isController = $user->canDelete($user); $hasCustomDomain = $user->wallet()->entitlements() ->where('entitleable_type', Domain::class) ->count() > 0; // Get user's entitlements titles $skus = $user->entitlements()->select('skus.title') ->join('skus', 'skus.id', '=', 'entitlements.sku_id') ->get() ->pluck('title') ->sort() ->unique() ->values() ->all(); $result = [ 'skus' => $skus, // TODO: This will change when we enable all users to create domains 'enableDomains' => $isController && $hasCustomDomain, // TODO: Make 'enableDistlists' working for wallet controllers that aren't account owners 'enableDistlists' => $isController && $hasCustomDomain && in_array('distlist', $skus), + // TODO: Make 'enableFolders' working for wallet controllers that aren't account owners + 'enableFolders' => $isController && $hasCustomDomain && in_array('beta-shared-folders', $skus), // TODO: Make 'enableResources' working for wallet controllers that aren't account owners 'enableResources' => $isController && $hasCustomDomain && in_array('beta-resources', $skus), 'enableUsers' => $isController, 'enableWallets' => $isController, ]; return array_merge($process, $result); } /** * Create a new user record. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function store(Request $request) { $current_user = $this->guard()->user(); $owner = $current_user->wallet()->owner; if ($owner->id != $current_user->id) { return $this->errorResponse(403); } $this->deleteBeforeCreate = null; if ($error_response = $this->validateUserRequest($request, null, $settings)) { return $error_response; } if (empty($request->package) || !($package = \App\Package::withEnvTenantContext()->find($request->package))) { $errors = ['package' => \trans('validation.packagerequired')]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } if ($package->isDomain()) { $errors = ['package' => \trans('validation.packageinvalid')]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } DB::beginTransaction(); // @phpstan-ignore-next-line if ($this->deleteBeforeCreate) { $this->deleteBeforeCreate->forceDelete(); } // Create user record $user = User::create([ 'email' => $request->email, 'password' => $request->password, ]); $owner->assignPackage($package, $user); if (!empty($settings)) { $user->setSettings($settings); } if (!empty($request->aliases)) { $user->setAliases($request->aliases); } DB::commit(); return response()->json([ 'status' => 'success', 'message' => \trans('app.user-create-success'), ]); } /** * Update user data. * * @param \Illuminate\Http\Request $request The API request. * @param string $id User identifier * * @return \Illuminate\Http\JsonResponse The response */ public function update(Request $request, $id) { $user = User::withEnvTenantContext()->find($id); if (empty($user)) { return $this->errorResponse(404); } $current_user = $this->guard()->user(); // TODO: Decide what attributes a user can change on his own profile if (!$current_user->canUpdate($user)) { return $this->errorResponse(403); } if ($error_response = $this->validateUserRequest($request, $user, $settings)) { return $error_response; } // Entitlements, only controller can do that if ($request->skus !== null && !$current_user->canDelete($user)) { return $this->errorResponse(422, "You have no permission to change entitlements"); } DB::beginTransaction(); $this->updateEntitlements($user, $request->skus); if (!empty($settings)) { $user->setSettings($settings); } if (!empty($request->password)) { $user->password = $request->password; $user->save(); } if (isset($request->aliases)) { $user->setAliases($request->aliases); } // TODO: Make sure that UserUpdate job is created in case of entitlements update // and no password change. So, for example quota change is applied to LDAP // TODO: Review use of $user->save() in the above context DB::commit(); $response = [ 'status' => 'success', 'message' => \trans('app.user-update-success'), ]; // For self-update refresh the statusInfo in the UI if ($user->id == $current_user->id) { $response['statusInfo'] = self::statusInfo($user); } return response()->json($response); } /** * Update user entitlements. * * @param \App\User $user The user * @param array $rSkus List of SKU IDs requested for the user in the form [id=>qty] */ protected function updateEntitlements(User $user, $rSkus) { if (!is_array($rSkus)) { return; } // list of skus, [id=>obj] $skus = Sku::withEnvTenantContext()->get()->mapWithKeys( function ($sku) { return [$sku->id => $sku]; } ); // existing entitlement's SKUs $eSkus = []; $user->entitlements()->groupBy('sku_id') ->selectRaw('count(*) as total, sku_id')->each( function ($e) use (&$eSkus) { $eSkus[$e->sku_id] = $e->total; } ); foreach ($skus as $skuID => $sku) { $e = array_key_exists($skuID, $eSkus) ? $eSkus[$skuID] : 0; $r = array_key_exists($skuID, $rSkus) ? $rSkus[$skuID] : 0; if ($sku->handler_class == \App\Handlers\Mailbox::class) { if ($r != 1) { throw new \Exception("Invalid quantity of mailboxes"); } } if ($e > $r) { // remove those entitled more than existing $user->removeSku($sku, ($e - $r)); } elseif ($e < $r) { // add those requested more than entitled $user->assignSku($sku, ($r - $e)); } } } /** * Create a response data array for specified user. * * @param \App\User $user User object * * @return array Response data */ public static function userResponse(User $user): array { $response = self::objectToClient($user, true); // Settings $response['settings'] = []; foreach ($user->settings()->whereIn('key', self::USER_SETTINGS)->get() as $item) { $response['settings'][$item->key] = $item->value; } // Aliases $response['aliases'] = []; foreach ($user->aliases as $item) { $response['aliases'][] = $item->alias; } // Status info $response['statusInfo'] = self::statusInfo($user); // Add more info to the wallet object output $map_func = function ($wallet) use ($user) { $result = $wallet->toArray(); if ($wallet->discount) { $result['discount'] = $wallet->discount->discount; $result['discount_description'] = $wallet->discount->description; } if ($wallet->user_id != $user->id) { $result['user_email'] = $wallet->owner->email; } $provider = \App\Providers\PaymentProvider::factory($wallet); $result['provider'] = $provider->name(); return $result; }; // Information about wallets and accounts for access checks $response['wallets'] = $user->wallets->map($map_func)->toArray(); $response['accounts'] = $user->accounts->map($map_func)->toArray(); $response['wallet'] = $map_func($user->wallet()); return $response; } /** * Prepare user statuses for the UI * * @param \App\User $user User object * * @return array Statuses array */ protected static function objectState(User $user): array { return [ 'isImapReady' => $user->isImapReady(), 'isLdapReady' => $user->isLdapReady(), 'isSuspended' => $user->isSuspended(), 'isActive' => $user->isActive(), 'isDeleted' => $user->isDeleted() || $user->trashed(), ]; } /** * Validate user input * * @param \Illuminate\Http\Request $request The API request. * @param \App\User|null $user User identifier * @param array $settings User settings (from the request) * * @return \Illuminate\Http\JsonResponse|null The error response on error */ protected function validateUserRequest(Request $request, $user, &$settings = []) { $rules = [ 'external_email' => 'nullable|email', 'phone' => 'string|nullable|max:64|regex:/^[0-9+() -]+$/', 'first_name' => 'string|nullable|max:128', 'last_name' => 'string|nullable|max:128', 'organization' => 'string|nullable|max:512', 'billing_address' => 'string|nullable|max:1024', 'country' => 'string|nullable|alpha|size:2', 'currency' => 'string|nullable|alpha|size:3', 'aliases' => 'array|nullable', ]; if (empty($user) || !empty($request->password) || !empty($request->password_confirmation)) { $rules['password'] = 'required|min:4|max:2048|confirmed'; } $errors = []; // Validate input $v = Validator::make($request->all(), $rules); if ($v->fails()) { $errors = $v->errors()->toArray(); } $controller = $user ? $user->wallet()->owner : $this->guard()->user(); // For new user validate email address if (empty($user)) { $email = $request->email; if (empty($email)) { $errors['email'] = \trans('validation.required', ['attribute' => 'email']); } elseif ($error = self::validateEmail($email, $controller, $this->deleteBeforeCreate)) { $errors['email'] = $error; } } // Validate aliases input if (isset($request->aliases)) { $aliases = []; $existing_aliases = $user ? $user->aliases()->get()->pluck('alias')->toArray() : []; foreach ($request->aliases as $idx => $alias) { if (is_string($alias) && !empty($alias)) { // Alias cannot be the same as the email address (new user) if (!empty($email) && Str::lower($alias) == Str::lower($email)) { continue; } // validate new aliases if ( !in_array($alias, $existing_aliases) && ($error = self::validateAlias($alias, $controller)) ) { if (!isset($errors['aliases'])) { $errors['aliases'] = []; } $errors['aliases'][$idx] = $error; continue; } $aliases[] = $alias; } } $request->aliases = $aliases; } if (!empty($errors)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } // Update user settings $settings = $request->only(array_keys($rules)); unset($settings['password'], $settings['aliases'], $settings['email']); return null; } /** * Execute (synchronously) specified step in a user setup process. * * @param \App\User $user User object * @param string $step Step identifier (as in self::statusInfo()) * * @return bool|null True if the execution succeeded, False if not, Null when * the job has been sent to the worker (result unknown) */ public static function execProcessStep(User $user, string $step): ?bool { try { if (strpos($step, 'domain-') === 0) { list ($local, $domain) = explode('@', $user->email); $domain = Domain::where('namespace', $domain)->first(); return DomainsController::execProcessStep($domain, $step); } switch ($step) { case 'user-ldap-ready': // User not in LDAP, create it $job = new \App\Jobs\User\CreateJob($user->id); $job->handle(); $user->refresh(); return $user->isLdapReady(); case 'user-imap-ready': // User not in IMAP? Verify again // Do it synchronously if the imap admin credentials are available // otherwise let the worker do the job if (!\config('imap.admin_password')) { \App\Jobs\User\VerifyJob::dispatch($user->id); return null; } $job = new \App\Jobs\User\VerifyJob($user->id); $job->handle(); $user->refresh(); return $user->isImapReady(); } } catch (\Exception $e) { \Log::error($e); } return false; } /** * Email address validation for use as a user mailbox (login). * * @param string $email Email address * @param \App\User $user The account owner * @param null|\App\User|\App\Group $deleted Filled with an instance of a deleted user or group * with the specified email address, if exists * * @return ?string Error message on validation error */ public static function validateEmail(string $email, \App\User $user, &$deleted = null): ?string { $deleted = null; if (strpos($email, '@') === false) { return \trans('validation.entryinvalid', ['attribute' => 'email']); } list($login, $domain) = explode('@', Str::lower($email)); if (strlen($login) === 0 || strlen($domain) === 0) { return \trans('validation.entryinvalid', ['attribute' => 'email']); } // Check if domain exists $domain = Domain::withEnvTenantContext()->where('namespace', $domain)->first(); if (empty($domain)) { return \trans('validation.domaininvalid'); } // Validate login part alone $v = Validator::make( ['email' => $login], ['email' => ['required', new UserEmailLocal(!$domain->isPublic())]] ); if ($v->fails()) { return $v->errors()->toArray()['email'][0]; } // Check if it is one of domains available to the user $domains = \collect($user->domains())->pluck('namespace')->all(); if (!in_array($domain->namespace, $domains)) { return \trans('validation.entryexists', ['attribute' => 'domain']); } // Check if a user with specified address already exists if ($existing_user = User::emailExists($email, true)) { // If this is a deleted user in the same custom domain // we'll force delete him before if (!$domain->isPublic() && $existing_user->trashed()) { $deleted = $existing_user; } else { return \trans('validation.entryexists', ['attribute' => 'email']); } } // Check if an alias with specified address already exists. if (User::aliasExists($email)) { return \trans('validation.entryexists', ['attribute' => 'email']); } // Check if a group or resource with specified address already exists if ( ($existing = Group::emailExists($email, true)) || ($existing = \App\Resource::emailExists($email, true)) ) { // If this is a deleted group/resource in the same custom domain // we'll force delete it before if (!$domain->isPublic() && $existing->trashed()) { $deleted = $existing; } else { return \trans('validation.entryexists', ['attribute' => 'email']); } } return null; } /** * Email address validation for use as an alias. * * @param string $email Email address * @param \App\User $user The account owner * * @return ?string Error message on validation error */ public static function validateAlias(string $email, \App\User $user): ?string { if (strpos($email, '@') === false) { return \trans('validation.entryinvalid', ['attribute' => 'alias']); } list($login, $domain) = explode('@', Str::lower($email)); if (strlen($login) === 0 || strlen($domain) === 0) { return \trans('validation.entryinvalid', ['attribute' => 'alias']); } // Check if domain exists $domain = Domain::withEnvTenantContext()->where('namespace', $domain)->first(); if (empty($domain)) { return \trans('validation.domaininvalid'); } // Validate login part alone $v = Validator::make( ['alias' => $login], ['alias' => ['required', new UserEmailLocal(!$domain->isPublic())]] ); if ($v->fails()) { return $v->errors()->toArray()['alias'][0]; } // Check if it is one of domains available to the user $domains = \collect($user->domains())->pluck('namespace')->all(); if (!in_array($domain->namespace, $domains)) { return \trans('validation.entryexists', ['attribute' => 'domain']); } // Check if a user with specified address already exists if ($existing_user = User::emailExists($email, true)) { // Allow an alias in a custom domain to an address that was a user before if ($domain->isPublic() || !$existing_user->trashed()) { return \trans('validation.entryexists', ['attribute' => 'alias']); } } // Check if an alias with specified address already exists if (User::aliasExists($email)) { // Allow assigning the same alias to a user in the same group account, // but only for non-public domains if ($domain->isPublic()) { return \trans('validation.entryexists', ['attribute' => 'alias']); } } // Check if a group with specified address already exists if (Group::emailExists($email)) { return \trans('validation.entryexists', ['attribute' => 'alias']); } return null; } } diff --git a/src/app/Jobs/CommonJob.php b/src/app/Jobs/CommonJob.php index d0c2b001..fa9d0831 100644 --- a/src/app/Jobs/CommonJob.php +++ b/src/app/Jobs/CommonJob.php @@ -1,107 +1,140 @@ handle(); * ``` */ abstract class CommonJob implements ShouldQueue { use Dispatchable; use InteractsWithQueue; use Queueable; /** * The failure message. * * @var string */ public $failureMessage; + /** + * The job deleted state. + * + * @var bool + */ + protected $isDeleted = false; + /** * The job released state. * * @var bool */ protected $isReleased = false; /** * The number of tries for this Job. * * @var int */ public $tries = 5; /** * Execute the job. * * @return void */ abstract public function handle(); + /** + * Delete the job from the queue. + * + * @return void + */ + public function delete() + { + // We need this for testing purposes + $this->isDeleted = true; + + // @phpstan-ignore-next-line + if ($this->job) { + $this->job->delete(); + } + } + /** * Delete the job, call the "failed" method, and raise the failed job event. * * @param \Throwable|null $e An Exception * * @return void */ public function fail($e = null) { // Save the message, for testing purposes $this->failureMessage = $e->getMessage(); // @phpstan-ignore-next-line if ($this->job) { $this->job->fail($e); } } /** * Check if the job has failed * * @return bool */ public function hasFailed(): bool { return $this->failureMessage !== null; } /** * Release the job back into the queue. * * @param int $delay Time in seconds * @return void */ public function release($delay = 0) { // We need this for testing purposes $this->isReleased = true; // @phpstan-ignore-next-line if ($this->job) { $this->job->release($delay); } } + /** + * Determine if the job has been deleted. + * + * @return bool + */ + public function isDeleted(): bool + { + return $this->isDeleted; + } + /** * Check if the job was released * * @return bool */ public function isReleased(): bool { return $this->isReleased; } } diff --git a/src/app/Jobs/Resource/VerifyJob.php b/src/app/Jobs/Resource/VerifyJob.php index 44ae6e69..9de19e72 100644 --- a/src/app/Jobs/Resource/VerifyJob.php +++ b/src/app/Jobs/Resource/VerifyJob.php @@ -1,36 +1,36 @@ getResource(); if (!$resource) { return; } - // the user has a mailbox (or is marked as such) + // the resource was already verified if ($resource->isImapReady()) { $this->fail(new \Exception("Resource {$this->resourceId} is already verified.")); return; } $folder = $resource->getSetting('folder'); if ($folder && \App\Backends\IMAP::verifySharedFolder($folder)) { $resource->status |= \App\Resource::STATUS_IMAP_READY; $resource->status |= \App\Resource::STATUS_ACTIVE; $resource->save(); } } } diff --git a/src/app/Jobs/SharedFolder/CreateJob.php b/src/app/Jobs/SharedFolder/CreateJob.php new file mode 100644 index 00000000..7e2cf586 --- /dev/null +++ b/src/app/Jobs/SharedFolder/CreateJob.php @@ -0,0 +1,61 @@ +getSharedFolder(); + + if (!$folder) { + return; + } + + // sanity checks + if ($folder->isDeleted()) { + $this->fail(new \Exception("Shared folder {$this->folderId} is marked as deleted.")); + return; + } + + if ($folder->trashed()) { + $this->fail(new \Exception("Shared folder {$this->folderId} is actually deleted.")); + return; + } + + if ($folder->isLdapReady()) { + $this->fail(new \Exception("Shared folder {$this->folderId} is already marked as ldap-ready.")); + return; + } + + // see if the domain is ready + $domain = $folder->domain(); + + if (!$domain) { + $this->fail(new \Exception("The domain for shared folder {$this->folderId} does not exist.")); + return; + } + + if ($domain->isDeleted()) { + $this->fail(new \Exception("The domain for shared folder {$this->folderId} is marked as deleted.")); + return; + } + + if (!$domain->isLdapReady()) { + $this->release(60); + return; + } + + \App\Backends\LDAP::createSharedFolder($folder); + + $folder->status |= \App\SharedFolder::STATUS_LDAP_READY; + $folder->save(); + } +} diff --git a/src/app/Jobs/SharedFolder/DeleteJob.php b/src/app/Jobs/SharedFolder/DeleteJob.php new file mode 100644 index 00000000..361d25dc --- /dev/null +++ b/src/app/Jobs/SharedFolder/DeleteJob.php @@ -0,0 +1,42 @@ +getSharedFolder(); + + if (!$folder) { + return; + } + + // sanity checks + if ($folder->isDeleted()) { + $this->fail(new \Exception("Shared folder {$this->folderId} is already marked as deleted.")); + return; + } + + \App\Backends\LDAP::deleteSharedFolder($folder); + + $folder->status |= \App\SharedFolder::STATUS_DELETED; + + if ($folder->isLdapReady()) { + $folder->status ^= \App\SharedFolder::STATUS_LDAP_READY; + } + + if ($folder->isImapReady()) { + $folder->status ^= \App\SharedFolder::STATUS_IMAP_READY; + } + + $folder->save(); + } +} diff --git a/src/app/Jobs/SharedFolder/UpdateJob.php b/src/app/Jobs/SharedFolder/UpdateJob.php new file mode 100644 index 00000000..7cd6e420 --- /dev/null +++ b/src/app/Jobs/SharedFolder/UpdateJob.php @@ -0,0 +1,30 @@ +getSharedFolder(); + + if (!$folder) { + return; + } + + // Cancel the update if the folder is deleted or not yet in LDAP + if (!$folder->isLdapReady() || $folder->isDeleted()) { + $this->delete(); + return; + } + + \App\Backends\LDAP::updateSharedFolder($folder); + } +} diff --git a/src/app/Jobs/SharedFolder/VerifyJob.php b/src/app/Jobs/SharedFolder/VerifyJob.php new file mode 100644 index 00000000..0f3bc330 --- /dev/null +++ b/src/app/Jobs/SharedFolder/VerifyJob.php @@ -0,0 +1,36 @@ +getSharedFolder(); + + if (!$folder) { + return; + } + + // the user has a mailbox (or is marked as such) + if ($folder->isImapReady()) { + $this->fail(new \Exception("Shared folder {$this->folderId} is already verified.")); + return; + } + + $folderName = $folder->getSetting('folder'); + + if (\App\Backends\IMAP::verifySharedFolder($folderName)) { + $folder->status |= \App\SharedFolder::STATUS_IMAP_READY; + $folder->status |= \App\SharedFolder::STATUS_ACTIVE; + $folder->save(); + } + } +} diff --git a/src/app/Jobs/SharedFolderJob.php b/src/app/Jobs/SharedFolderJob.php new file mode 100644 index 00000000..304b3dd7 --- /dev/null +++ b/src/app/Jobs/SharedFolderJob.php @@ -0,0 +1,72 @@ +handle(); + * ``` + */ +abstract class SharedFolderJob extends CommonJob +{ + /** + * The ID for the \App\SharedFolder. This is the shortest globally unique identifier and saves Redis space + * compared to a serialized version of the complete \App\SharedFolder object. + * + * @var int + */ + protected $folderId; + /** + * The \App\SharedFolder email property, for legibility in the queue management. + * + * @var string + */ + protected $folderEmail; + + /** + * Create a new job instance. + * + * @param int $folderId The ID for the shared folder to process. + * + * @return void + */ + public function __construct(int $folderId) + { + $this->folderId = $folderId; + + $folder = $this->getSharedFolder(); + + if ($folder) { + $this->folderEmail = $folder->email; + } + } + + /** + * Get the \App\SharedFolder entry associated with this job. + * + * @return \App\SharedFolder|null + * + * @throws \Exception + */ + protected function getSharedFolder() + { + $folder = \App\SharedFolder::withTrashed()->find($this->folderId); + + if (!$folder) { + // The record might not exist yet in case of a db replication environment + // This will release the job and delay another attempt for 5 seconds + if ($this instanceof SharedFolder\CreateJob) { + $this->release(5); + return null; + } + + $this->fail(new \Exception("Shared folder {$this->folderId} could not be found in the database.")); + } + + return $folder; + } +} diff --git a/src/app/Observers/SharedFolderObserver.php b/src/app/Observers/SharedFolderObserver.php new file mode 100644 index 00000000..d1a5d64c --- /dev/null +++ b/src/app/Observers/SharedFolderObserver.php @@ -0,0 +1,140 @@ +type)) { + $folder->type = 'mail'; + } + + if (empty($folder->email)) { + if (!isset($folder->name)) { + throw new \Exception("Missing 'domain' property for a new shared folder"); + } + + $domainName = \strtolower($folder->domain); + + $folder->email = "{$folder->type}-{$folder->id}@{$domainName}"; + } else { + $folder->email = \strtolower($folder->email); + } + + $folder->status |= SharedFolder::STATUS_NEW | SharedFolder::STATUS_ACTIVE; + } + + /** + * Handle the shared folder "created" event. + * + * @param \App\SharedFolder $folder The folder + * + * @return void + */ + public function created(SharedFolder $folder) + { + $domainName = explode('@', $folder->email, 2)[1]; + + $settings = [ + 'folder' => "shared/{$folder->name}@{$domainName}", + ]; + + foreach ($settings as $key => $value) { + $settings[$key] = [ + 'key' => $key, + 'value' => $value, + 'shared_folder_id' => $folder->id, + ]; + } + + // Note: Don't use setSettings() here to bypass SharedFolderSetting observers + // Note: This is a single multi-insert query + $folder->settings()->insert(array_values($settings)); + + // Create folder record in LDAP, then check if it is created in IMAP + $chain = [ + new \App\Jobs\SharedFolder\VerifyJob($folder->id), + ]; + + \App\Jobs\SharedFolder\CreateJob::withChain($chain)->dispatch($folder->id); + } + + /** + * Handle the shared folder "deleting" event. + * + * @param \App\SharedFolder $folder The folder + * + * @return void + */ + public function deleting(SharedFolder $folder) + { + // Entitlements do not have referential integrity on the entitled object, so this is our + // way of doing an onDelete('cascade') without the foreign key. + \App\Entitlement::where('entitleable_id', $folder->id) + ->where('entitleable_type', SharedFolder::class) + ->delete(); + } + + /** + * Handle the shared folder "deleted" event. + * + * @param \App\SharedFolder $folder The folder + * + * @return void + */ + public function deleted(SharedFolder $folder) + { + if ($folder->isForceDeleting()) { + return; + } + + \App\Jobs\SharedFolder\DeleteJob::dispatch($folder->id); + } + + /** + * Handle the shared folder "updated" event. + * + * @param \App\SharedFolder $folder The folder + * + * @return void + */ + public function updated(SharedFolder $folder) + { + \App\Jobs\SharedFolder\UpdateJob::dispatch($folder->id); + + // Update the folder property if name changed + if ($folder->name != $folder->getOriginal('name')) { + $domainName = explode('@', $folder->email, 2)[1]; + $folderName = "shared/{$folder->name}@{$domainName}"; + + // Note: This does not invoke SharedFolderSetting observer events, good. + $folder->settings()->where('key', 'folder')->update(['value' => $folderName]); + } + } + + /** + * Handle the shared folder "force deleted" event. + * + * @param \App\SharedFolder $folder The folder + * + * @return void + */ + public function forceDeleted(SharedFolder $folder) + { + // A folder can be force-deleted separately from the owner + // we have to force-delete entitlements + \App\Entitlement::where('entitleable_id', $folder->id) + ->where('entitleable_type', SharedFolder::class) + ->forceDelete(); + } +} diff --git a/src/app/Observers/SharedFolderSettingObserver.php b/src/app/Observers/SharedFolderSettingObserver.php new file mode 100644 index 00000000..7accfb03 --- /dev/null +++ b/src/app/Observers/SharedFolderSettingObserver.php @@ -0,0 +1,51 @@ +key, LDAP::SHARED_FOLDER_SETTINGS)) { + \App\Jobs\SharedFolder\UpdateJob::dispatch($folderSetting->shared_folder_id); + } + } + + /** + * Handle the shared folder setting "updated" event. + * + * @param \App\SharedFolderSetting $folderSetting Settings object + * + * @return void + */ + public function updated(SharedFolderSetting $folderSetting) + { + if (in_array($folderSetting->key, LDAP::SHARED_FOLDER_SETTINGS)) { + \App\Jobs\SharedFolder\UpdateJob::dispatch($folderSetting->shared_folder_id); + } + } + + /** + * Handle the shared folder setting "deleted" event. + * + * @param \App\SharedFolderSetting $folderSetting Settings object + * + * @return void + */ + public function deleted(SharedFolderSetting $folderSetting) + { + if (in_array($folderSetting->key, LDAP::SHARED_FOLDER_SETTINGS)) { + \App\Jobs\SharedFolder\UpdateJob::dispatch($folderSetting->shared_folder_id); + } + } +} diff --git a/src/app/Observers/UserObserver.php b/src/app/Observers/UserObserver.php index 08ad8503..72927071 100644 --- a/src/app/Observers/UserObserver.php +++ b/src/app/Observers/UserObserver.php @@ -1,366 +1,384 @@ email = \strtolower($user->email); // only users that are not imported get the benefit of the doubt. $user->status |= User::STATUS_NEW | User::STATUS_ACTIVE; } /** * Handle the "created" event. * * Ensures the user has at least one wallet. * * Should ensure some basic settings are available as well. * * @param \App\User $user The user created. * * @return void */ public function created(User $user) { $settings = [ 'country' => \App\Utils::countryForRequest(), 'currency' => \config('app.currency'), /* 'first_name' => '', 'last_name' => '', 'billing_address' => '', 'organization' => '', 'phone' => '', 'external_email' => '', */ ]; foreach ($settings as $key => $value) { $settings[$key] = [ 'key' => $key, 'value' => $value, 'user_id' => $user->id, ]; } // Note: Don't use setSettings() here to bypass UserSetting observers // Note: This is a single multi-insert query $user->settings()->insert(array_values($settings)); $user->wallets()->create(); // Create user record in LDAP, then check if the account is created in IMAP $chain = [ new \App\Jobs\User\VerifyJob($user->id), ]; \App\Jobs\User\CreateJob::withChain($chain)->dispatch($user->id); if (\App\Tenant::getConfig($user->tenant_id, 'pgp.enable')) { \App\Jobs\PGP\KeyCreateJob::dispatch($user->id, $user->email); } } /** * Handle the "deleted" event. * * @param \App\User $user The user deleted. * * @return void */ public function deleted(User $user) { // Remove the user from existing groups $wallet = $user->wallet(); if ($wallet && $wallet->owner) { $wallet->owner->groups()->each(function ($group) use ($user) { if (in_array($user->email, $group->members)) { $group->members = array_diff($group->members, [$user->email]); $group->save(); } }); } // Debit the reseller's wallet with the user negative balance $balance = 0; foreach ($user->wallets as $wallet) { // Note: here we assume all user wallets are using the same currency. // It might get changed in the future $balance += $wallet->balance; } if ($balance < 0 && $user->tenant && ($wallet = $user->tenant->wallet())) { $wallet->debit($balance * -1, "Deleted user {$user->email}"); } } /** * Handle the "deleting" event. * * @param User $user The user that is being deleted. * * @return void */ public function deleting(User $user) { if ($user->isForceDeleting()) { $this->forceDeleting($user); return; } // TODO: Especially in tests we're doing delete() on a already deleted user. // Should we escape here - for performance reasons? // TODO: I think all of this should use database transactions // Entitlements do not have referential integrity on the entitled object, so this is our // way of doing an onDelete('cascade') without the foreign key. $entitlements = Entitlement::where('entitleable_id', $user->id) ->where('entitleable_type', User::class)->get(); foreach ($entitlements as $entitlement) { $entitlement->delete(); } // Remove owned users/domains $wallets = $user->wallets()->pluck('id')->all(); $assignments = Entitlement::whereIn('wallet_id', $wallets)->get(); $users = []; $domains = []; $groups = []; $resources = []; + $folders = []; $entitlements = []; foreach ($assignments as $entitlement) { if ($entitlement->entitleable_type == Domain::class) { $domains[] = $entitlement->entitleable_id; } elseif ($entitlement->entitleable_type == User::class && $entitlement->entitleable_id != $user->id) { $users[] = $entitlement->entitleable_id; } elseif ($entitlement->entitleable_type == Group::class) { $groups[] = $entitlement->entitleable_id; } elseif ($entitlement->entitleable_type == Resource::class) { $resources[] = $entitlement->entitleable_id; + } elseif ($entitlement->entitleable_type == SharedFolder::class) { + $folders[] = $entitlement->entitleable_id; } else { $entitlements[] = $entitlement; } } // Domains/users/entitlements need to be deleted one by one to make sure // events are fired and observers can do the proper cleanup. if (!empty($users)) { foreach (User::whereIn('id', array_unique($users))->get() as $_user) { $_user->delete(); } } if (!empty($domains)) { foreach (Domain::whereIn('id', array_unique($domains))->get() as $_domain) { $_domain->delete(); } } if (!empty($groups)) { foreach (Group::whereIn('id', array_unique($groups))->get() as $_group) { $_group->delete(); } } if (!empty($resources)) { foreach (Resource::whereIn('id', array_unique($resources))->get() as $_resource) { $_resource->delete(); } } + if (!empty($folders)) { + foreach (SharedFolder::whereIn('id', array_unique($folders))->get() as $_folder) { + $_folder->delete(); + } + } + foreach ($entitlements as $entitlement) { $entitlement->delete(); } // FIXME: What do we do with user wallets? \App\Jobs\User\DeleteJob::dispatch($user->id); if (\App\Tenant::getConfig($user->tenant_id, 'pgp.enable')) { \App\Jobs\PGP\KeyDeleteJob::dispatch($user->id, $user->email); } } /** * Handle the "deleting" event on forceDelete() call. * * @param User $user The user that is being deleted. * * @return void */ public function forceDeleting(User $user) { // TODO: We assume that at this moment all belongings are already soft-deleted. // Remove owned users/domains $wallets = $user->wallets()->pluck('id')->all(); $assignments = Entitlement::withTrashed()->whereIn('wallet_id', $wallets)->get(); $entitlements = []; $domains = []; $groups = []; $resources = []; + $folders = []; $users = []; foreach ($assignments as $entitlement) { $entitlements[] = $entitlement->id; if ($entitlement->entitleable_type == Domain::class) { $domains[] = $entitlement->entitleable_id; } elseif ( $entitlement->entitleable_type == User::class && $entitlement->entitleable_id != $user->id ) { $users[] = $entitlement->entitleable_id; } elseif ($entitlement->entitleable_type == Group::class) { $groups[] = $entitlement->entitleable_id; } elseif ($entitlement->entitleable_type == Resource::class) { $resources[] = $entitlement->entitleable_id; + } elseif ($entitlement->entitleable_type == SharedFolder::class) { + $folders[] = $entitlement->entitleable_id; } } // Remove the user "direct" entitlements explicitely, if they belong to another // user's wallet they will not be removed by the wallets foreign key cascade Entitlement::withTrashed() ->where('entitleable_id', $user->id) ->where('entitleable_type', User::class) ->forceDelete(); // Users need to be deleted one by one to make sure observers can do the proper cleanup. if (!empty($users)) { foreach (User::withTrashed()->whereIn('id', array_unique($users))->get() as $_user) { $_user->forceDelete(); } } // Domains can be just removed if (!empty($domains)) { Domain::withTrashed()->whereIn('id', array_unique($domains))->forceDelete(); } // Groups can be just removed if (!empty($groups)) { Group::withTrashed()->whereIn('id', array_unique($groups))->forceDelete(); } // Resources can be just removed if (!empty($resources)) { Resource::withTrashed()->whereIn('id', array_unique($resources))->forceDelete(); } + // Shared folders can be just removed + if (!empty($folders)) { + SharedFolder::withTrashed()->whereIn('id', array_unique($folders))->forceDelete(); + } + // Remove transactions, they also have no foreign key constraint Transaction::where('object_type', Entitlement::class) ->whereIn('object_id', $entitlements) ->delete(); Transaction::where('object_type', Wallet::class) ->whereIn('object_id', $wallets) ->delete(); } /** * Handle the user "restoring" event. * * @param \App\User $user The user * * @return void */ public function restoring(User $user) { // Make sure it's not DELETED/LDAP_READY/IMAP_READY/SUSPENDED anymore if ($user->isDeleted()) { $user->status ^= User::STATUS_DELETED; } if ($user->isLdapReady()) { $user->status ^= User::STATUS_LDAP_READY; } if ($user->isImapReady()) { $user->status ^= User::STATUS_IMAP_READY; } if ($user->isSuspended()) { $user->status ^= User::STATUS_SUSPENDED; } $user->status |= User::STATUS_ACTIVE; // Note: $user->save() is invoked between 'restoring' and 'restored' events } /** * Handle the user "restored" event. * * @param \App\User $user The user * * @return void */ public function restored(User $user) { // Restore user entitlements \App\Entitlement::restoreEntitlementsFor($user); // We need at least the user domain so it can be created in ldap. // FIXME: What if the domain is owned by someone else? $domain = $user->domain(); if ($domain->trashed() && !$domain->isPublic()) { // Note: Domain entitlements will be restored by the DomainObserver $domain->restore(); } // FIXME: Should we reset user aliases? or re-validate them in any way? // Create user record in LDAP, then run the verification process $chain = [ new \App\Jobs\User\VerifyJob($user->id), ]; \App\Jobs\User\CreateJob::withChain($chain)->dispatch($user->id); } /** * Handle the "retrieving" event. * * @param User $user The user that is being retrieved. * * @todo This is useful for audit. * * @return void */ public function retrieving(User $user) { // TODO \App\Jobs\User\ReadJob::dispatch($user->id); } /** * Handle the "updating" event. * * @param User $user The user that is being updated. * * @return void */ public function updating(User $user) { \App\Jobs\User\UpdateJob::dispatch($user->id); } } diff --git a/src/app/Providers/AppServiceProvider.php b/src/app/Providers/AppServiceProvider.php index 29904762..ac784b42 100644 --- a/src/app/Providers/AppServiceProvider.php +++ b/src/app/Providers/AppServiceProvider.php @@ -1,164 +1,166 @@ format('Y-m-d h:i:s'); } return $entry; }, $array); return implode(', ', $serialized); } /** * Bootstrap any application services. * * @return void */ public function boot() { \App\Domain::observe(\App\Observers\DomainObserver::class); \App\Entitlement::observe(\App\Observers\EntitlementObserver::class); \App\Group::observe(\App\Observers\GroupObserver::class); \App\GroupSetting::observe(\App\Observers\GroupSettingObserver::class); \App\OpenVidu\Connection::observe(\App\Observers\OpenVidu\ConnectionObserver::class); \App\PackageSku::observe(\App\Observers\PackageSkuObserver::class); \App\PlanPackage::observe(\App\Observers\PlanPackageObserver::class); \App\Resource::observe(\App\Observers\ResourceObserver::class); \App\ResourceSetting::observe(\App\Observers\ResourceSettingObserver::class); + \App\SharedFolder::observe(\App\Observers\SharedFolderObserver::class); + \App\SharedFolderSetting::observe(\App\Observers\SharedFolderSettingObserver::class); \App\SignupCode::observe(\App\Observers\SignupCodeObserver::class); \App\SignupInvitation::observe(\App\Observers\SignupInvitationObserver::class); \App\Transaction::observe(\App\Observers\TransactionObserver::class); \App\User::observe(\App\Observers\UserObserver::class); \App\UserAlias::observe(\App\Observers\UserAliasObserver::class); \App\UserSetting::observe(\App\Observers\UserSettingObserver::class); \App\VerificationCode::observe(\App\Observers\VerificationCodeObserver::class); \App\Wallet::observe(\App\Observers\WalletObserver::class); \App\PowerDNS\Domain::observe(\App\Observers\PowerDNS\DomainObserver::class); \App\PowerDNS\Record::observe(\App\Observers\PowerDNS\RecordObserver::class); Schema::defaultStringLength(191); // Log SQL queries in debug mode if (\config('app.debug')) { DB::listen(function ($query) { \Log::debug( sprintf( '[SQL] %s [%s]: %.4f sec.', $query->sql, self::serializeSQLBindings($query->bindings), $query->time / 1000 ) ); }); } // Register some template helpers Blade::directive( 'theme_asset', function ($path) { $path = trim($path, '/\'"'); return ""; } ); Builder::macro( 'withEnvTenantContext', function (string $table = null) { $tenantId = \config('app.tenant_id'); if ($tenantId) { /** @var Builder $this */ return $this->where(($table ? "$table." : "") . "tenant_id", $tenantId); } /** @var Builder $this */ return $this->whereNull(($table ? "$table." : "") . "tenant_id"); } ); Builder::macro( 'withObjectTenantContext', function (object $object, string $table = null) { $tenantId = $object->tenant_id; if ($tenantId) { /** @var Builder $this */ return $this->where(($table ? "$table." : "") . "tenant_id", $tenantId); } /** @var Builder $this */ return $this->whereNull(($table ? "$table." : "") . "tenant_id"); } ); Builder::macro( 'withSubjectTenantContext', function (string $table = null) { if ($user = auth()->user()) { $tenantId = $user->tenant_id; } else { $tenantId = \config('app.tenant_id'); } if ($tenantId) { /** @var Builder $this */ return $this->where(($table ? "$table." : "") . "tenant_id", $tenantId); } /** @var Builder $this */ return $this->whereNull(($table ? "$table." : "") . "tenant_id"); } ); // Query builder 'whereLike' mocro Builder::macro( 'whereLike', function (string $column, string $search, int $mode = 0) { $search = addcslashes($search, '%_'); switch ($mode) { case 2: $search .= '%'; break; case 1: $search = '%' . $search; break; default: $search = '%' . $search . '%'; } /** @var Builder $this */ return $this->where($column, 'like', $search); } ); } } diff --git a/src/app/Resource.php b/src/app/Resource.php index 732d088b..7345b755 100644 --- a/src/app/Resource.php +++ b/src/app/Resource.php @@ -1,210 +1,209 @@ id)) { throw new \Exception("Resource not yet exists"); } if ($this->entitlements()->count()) { throw new \Exception("Resource already assigned to a wallet"); } $sku = \App\Sku::withObjectTenantContext($this)->where('title', 'resource')->first(); $exists = $wallet->entitlements()->where('sku_id', $sku->id)->count(); \App\Entitlement::create([ 'wallet_id' => $wallet->id, 'sku_id' => $sku->id, 'cost' => $exists >= $sku->units_free ? $sku->cost : 0, 'fee' => $exists >= $sku->units_free ? $sku->fee : 0, 'entitleable_id' => $this->id, 'entitleable_type' => Resource::class ]); return $this; } /** * Returns the resource domain. * * @return ?\App\Domain The domain to which the resource belongs to, NULL if it does not exist */ public function domain(): ?Domain { if (isset($this->domain)) { $domainName = $this->domain; } else { list($local, $domainName) = explode('@', $this->email); } return Domain::where('namespace', $domainName)->first(); } /** * Find whether an email address exists as a resource (including deleted resources). * * @param string $email Email address * @param bool $return_resource Return Resource instance instead of boolean * * @return \App\Resource|bool True or Resource model object if found, False otherwise */ public static function emailExists(string $email, bool $return_resource = false) { if (strpos($email, '@') === false) { return false; } $email = \strtolower($email); $resource = self::withTrashed()->where('email', $email)->first(); if ($resource) { return $return_resource ? $resource : true; } return false; } /** * Returns whether this resource is active. * * @return bool */ public function isActive(): bool { return ($this->status & self::STATUS_ACTIVE) > 0; } /** * Returns whether this resource is deleted. * * @return bool */ public function isDeleted(): bool { return ($this->status & self::STATUS_DELETED) > 0; } /** * Returns whether this resource's folder exists in IMAP. * * @return bool */ public function isImapReady(): bool { return ($this->status & self::STATUS_IMAP_READY) > 0; } /** * Returns whether this resource is registered in LDAP. * * @return bool */ public function isLdapReady(): bool { return ($this->status & self::STATUS_LDAP_READY) > 0; } /** * Returns whether this resource is new. * * @return bool */ public function isNew(): bool { return ($this->status & self::STATUS_NEW) > 0; } /** * Resource status mutator * * @throws \Exception */ public function setStatusAttribute($status) { $new_status = 0; $allowed_values = [ self::STATUS_NEW, self::STATUS_ACTIVE, self::STATUS_DELETED, self::STATUS_IMAP_READY, self::STATUS_LDAP_READY, ]; foreach ($allowed_values as $value) { if ($status & $value) { $new_status |= $value; $status ^= $value; } } if ($status > 0) { throw new \Exception("Invalid resource status: {$status}"); } $this->attributes['status'] = $new_status; } } diff --git a/src/app/Rules/ResourceName.php b/src/app/Rules/ResourceName.php index 73ab9b1e..fb88b3bd 100644 --- a/src/app/Rules/ResourceName.php +++ b/src/app/Rules/ResourceName.php @@ -1,79 +1,86 @@ owner = $owner; $this->domain = Str::lower($domain); } /** * Determine if the validation rule passes. * * @param string $attribute Attribute name * @param mixed $name Resource name input * * @return bool */ public function passes($attribute, $name): bool { if (empty($name) || !is_string($name)) { $this->message = \trans('validation.nameinvalid'); return false; } + if (strcspn($name, self::FORBIDDEN_CHARS) < strlen($name)) { + $this->message = \trans('validation.nameinvalid'); + return false; + } + // Check the max length, according to the database column length if (strlen($name) > 191) { $this->message = \trans('validation.max.string', ['max' => 191]); return false; } - // Check if specified domain is belongs to the user + // Check if specified domain belongs to the user $domains = \collect($this->owner->domains(true, false))->pluck('namespace')->all(); if (!in_array($this->domain, $domains)) { $this->message = \trans('validation.domaininvalid'); return false; } // Check if the name is unique in the domain // FIXME: Maybe just using the whole resources table would be faster than resources()? $exists = $this->owner->resources() ->where('resources.name', $name) ->where('resources.email', 'like', '%@' . $this->domain) ->exists(); if ($exists) { $this->message = \trans('validation.nameexists'); return false; } return true; } /** * Get the validation error message. * * @return string */ public function message(): ?string { return $this->message; } } diff --git a/src/app/Rules/ResourceName.php b/src/app/Rules/SharedFolderName.php similarity index 69% copy from src/app/Rules/ResourceName.php copy to src/app/Rules/SharedFolderName.php index 73ab9b1e..1dff6aeb 100644 --- a/src/app/Rules/ResourceName.php +++ b/src/app/Rules/SharedFolderName.php @@ -1,79 +1,86 @@ owner = $owner; $this->domain = Str::lower($domain); } /** * Determine if the validation rule passes. * * @param string $attribute Attribute name - * @param mixed $name Resource name input + * @param mixed $name Shared folder name input * * @return bool */ public function passes($attribute, $name): bool { - if (empty($name) || !is_string($name)) { + if (empty($name) || !is_string($name) || $name == 'Resources') { + $this->message = \trans('validation.nameinvalid'); + return false; + } + + if (strcspn($name, self::FORBIDDEN_CHARS) < strlen($name)) { $this->message = \trans('validation.nameinvalid'); return false; } // Check the max length, according to the database column length if (strlen($name) > 191) { $this->message = \trans('validation.max.string', ['max' => 191]); return false; } - // Check if specified domain is belongs to the user + // Check if specified domain belongs to the user $domains = \collect($this->owner->domains(true, false))->pluck('namespace')->all(); if (!in_array($this->domain, $domains)) { $this->message = \trans('validation.domaininvalid'); return false; } // Check if the name is unique in the domain - // FIXME: Maybe just using the whole resources table would be faster than resources()? - $exists = $this->owner->resources() - ->where('resources.name', $name) - ->where('resources.email', 'like', '%@' . $this->domain) + // FIXME: Maybe just using the whole shared_folders table would be faster than sharedFolders()? + $exists = $this->owner->sharedFolders() + ->where('shared_folders.name', $name) + ->where('shared_folders.email', 'like', '%@' . $this->domain) ->exists(); if ($exists) { $this->message = \trans('validation.nameexists'); return false; } return true; } /** * Get the validation error message. * * @return string */ public function message(): ?string { return $this->message; } } diff --git a/src/app/Rules/SharedFolderType.php b/src/app/Rules/SharedFolderType.php new file mode 100644 index 00000000..c6b868ff --- /dev/null +++ b/src/app/Rules/SharedFolderType.php @@ -0,0 +1,40 @@ +message = \trans('validation.entryinvalid', ['attribute' => $attribute]); + return false; + } + + return true; + } + + /** + * Get the validation error message. + * + * @return string + */ + public function message(): ?string + { + return $this->message; + } +} diff --git a/src/app/Resource.php b/src/app/SharedFolder.php similarity index 58% copy from src/app/Resource.php copy to src/app/SharedFolder.php index 732d088b..e22df5cf 100644 --- a/src/app/Resource.php +++ b/src/app/SharedFolder.php @@ -1,210 +1,229 @@ id)) { - throw new \Exception("Resource not yet exists"); + throw new \Exception("Shared folder not yet exists"); } if ($this->entitlements()->count()) { - throw new \Exception("Resource already assigned to a wallet"); + throw new \Exception("Shared folder already assigned to a wallet"); } - $sku = \App\Sku::withObjectTenantContext($this)->where('title', 'resource')->first(); + $sku = \App\Sku::withObjectTenantContext($this)->where('title', 'shared-folder')->first(); $exists = $wallet->entitlements()->where('sku_id', $sku->id)->count(); \App\Entitlement::create([ 'wallet_id' => $wallet->id, 'sku_id' => $sku->id, 'cost' => $exists >= $sku->units_free ? $sku->cost : 0, 'fee' => $exists >= $sku->units_free ? $sku->fee : 0, 'entitleable_id' => $this->id, - 'entitleable_type' => Resource::class + 'entitleable_type' => SharedFolder::class ]); return $this; } /** - * Returns the resource domain. + * Returns the shared folder domain. * - * @return ?\App\Domain The domain to which the resource belongs to, NULL if it does not exist + * @return ?\App\Domain The domain to which the folder belongs to, NULL if it does not exist */ public function domain(): ?Domain { if (isset($this->domain)) { $domainName = $this->domain; } else { list($local, $domainName) = explode('@', $this->email); } return Domain::where('namespace', $domainName)->first(); } /** - * Find whether an email address exists as a resource (including deleted resources). + * Find whether an email address exists as a shared folder (including deleted folders). * - * @param string $email Email address - * @param bool $return_resource Return Resource instance instead of boolean + * @param string $email Email address + * @param bool $return_folder Return SharedFolder instance instead of boolean * - * @return \App\Resource|bool True or Resource model object if found, False otherwise + * @return \App\SharedFolder|bool True or Resource model object if found, False otherwise */ - public static function emailExists(string $email, bool $return_resource = false) + public static function emailExists(string $email, bool $return_folder = false) { if (strpos($email, '@') === false) { return false; } $email = \strtolower($email); - $resource = self::withTrashed()->where('email', $email)->first(); + $folder = self::withTrashed()->where('email', $email)->first(); - if ($resource) { - return $return_resource ? $resource : true; + if ($folder) { + return $return_folder ? $folder : true; } return false; } /** - * Returns whether this resource is active. + * Returns whether this folder is active. * * @return bool */ public function isActive(): bool { return ($this->status & self::STATUS_ACTIVE) > 0; } /** - * Returns whether this resource is deleted. + * Returns whether this folder is deleted. * * @return bool */ public function isDeleted(): bool { return ($this->status & self::STATUS_DELETED) > 0; } /** - * Returns whether this resource's folder exists in IMAP. + * Returns whether this folder exists in IMAP. * * @return bool */ public function isImapReady(): bool { return ($this->status & self::STATUS_IMAP_READY) > 0; } /** - * Returns whether this resource is registered in LDAP. + * Returns whether this folder is registered in LDAP. * * @return bool */ public function isLdapReady(): bool { return ($this->status & self::STATUS_LDAP_READY) > 0; } /** - * Returns whether this resource is new. + * Returns whether this folder is new. * * @return bool */ public function isNew(): bool { return ($this->status & self::STATUS_NEW) > 0; } /** - * Resource status mutator + * Folder status mutator * * @throws \Exception */ public function setStatusAttribute($status) { $new_status = 0; $allowed_values = [ self::STATUS_NEW, self::STATUS_ACTIVE, self::STATUS_DELETED, self::STATUS_IMAP_READY, self::STATUS_LDAP_READY, ]; foreach ($allowed_values as $value) { if ($status & $value) { $new_status |= $value; $status ^= $value; } } if ($status > 0) { - throw new \Exception("Invalid resource status: {$status}"); + throw new \Exception("Invalid shared folder status: {$status}"); } $this->attributes['status'] = $new_status; } + + /** + * Folder type mutator + * + * @throws \Exception + */ + public function setTypeAttribute($type) + { + if (!in_array($type, self::SUPPORTED_TYPES)) { + throw new \Exception("Invalid shared folder type: {$type}"); + } + + $this->attributes['type'] = $type; + } } diff --git a/src/app/SharedFolderSetting.php b/src/app/SharedFolderSetting.php new file mode 100644 index 00000000..a73740e6 --- /dev/null +++ b/src/app/SharedFolderSetting.php @@ -0,0 +1,30 @@ +belongsTo(\App\SharedFolder::class, 'shared_folder_id', 'id'); + } +} diff --git a/src/app/Traits/SharedFolderConfigTrait.php b/src/app/Traits/SharedFolderConfigTrait.php new file mode 100644 index 00000000..4c8f375a --- /dev/null +++ b/src/app/Traits/SharedFolderConfigTrait.php @@ -0,0 +1,118 @@ +getSettings(['acl']); + + $config['acl'] = !empty($settings['acl']) ? json_decode($settings['acl'], true) : []; + + return $config; + } + + /** + * A helper to update a shared folder configuration. + * + * @param array $config An array of configuration options + * + * @return array A list of input validation errors + */ + public function setConfig(array $config): array + { + $errors = []; + + foreach ($config as $key => $value) { + // validate and save the acl + if ($key === 'acl') { + // Here's the list of acl labels supported by kolabd + // 'all': 'lrsedntxakcpiw', + // 'append': 'wip', + // 'full': 'lrswipkxtecdn', + // 'read': 'lrs', + // 'read-only': 'lrs', + // 'read-write': 'lrswitedn', + // 'post': 'p', + // 'semi-full': 'lrswit', + // 'write': 'lrswite', + // For now we support read-only, read-write, and full + + if (!is_array($value)) { + $value = (array) $value; + } + + $users = []; + + foreach ($value as $i => $v) { + if (!is_string($v) || empty($v) || !substr_count($v, ',')) { + $errors[$key][$i] = \trans('validation.acl-entry-invalid'); + } else { + list($user, $acl) = explode(',', $v, 2); + $user = trim($user); + $acl = trim($acl); + $error = null; + + if ( + !in_array($acl, ['read-only', 'read-write', 'full']) + || ($error = $this->validateAclIdentifier($user)) + || in_array($user, $users) + ) { + $errors[$key][$i] = $error ?: \trans('validation.acl-entry-invalid'); + } + + $value[$i] = "$user, $acl"; + $users[] = $user; + } + } + + if (empty($errors[$key])) { + $this->setSetting($key, json_encode($value)); + } + } else { + $errors[$key] = \trans('validation.invalid-config-parameter'); + } + } + + return $errors; + } + + /** + * Validate an ACL identifier. + * + * @param string $identifier Email address or a special identifier + * + * @return ?string Error message on validation error + */ + protected function validateAclIdentifier(string $identifier): ?string + { + if ($identifier === 'anyone') { + return null; + } + + $v = Validator::make(['email' => $identifier], ['email' => 'required|email']); + + if ($v->fails()) { + return \trans('validation.emailinvalid'); + } + + $user = \App\User::where('email', \strtolower($identifier))->first(); + + // The user and shared folder must be in the same wallet + if ($user && ($wallet = $user->wallet())) { + if ($wallet->user_id == $this->wallet()->user_id) { + return null; + } + } + + return \trans('validation.notalocaluser'); + } +} diff --git a/src/app/User.php b/src/app/User.php index 5679af44..595cf940 100644 --- a/src/app/User.php +++ b/src/app/User.php @@ -1,886 +1,909 @@ belongsToMany( 'App\Wallet', // The foreign object definition 'user_accounts', // The table name 'user_id', // The local foreign key 'wallet_id' // The remote foreign key ); } /** * Email aliases of this user. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function aliases() { return $this->hasMany('App\UserAlias', 'user_id'); } /** * Assign a package to a user. The user should not have any existing entitlements. * * @param \App\Package $package The package to assign. * @param \App\User|null $user Assign the package to another user. * * @return \App\User */ public function assignPackage($package, $user = null) { if (!$user) { $user = $this; } $wallet_id = $this->wallets()->first()->id; foreach ($package->skus as $sku) { for ($i = $sku->pivot->qty; $i > 0; $i--) { \App\Entitlement::create( [ 'wallet_id' => $wallet_id, 'sku_id' => $sku->id, 'cost' => $sku->pivot->cost(), 'fee' => $sku->pivot->fee(), 'entitleable_id' => $user->id, 'entitleable_type' => User::class ] ); } } return $user; } /** * Assign a package plan to a user. * * @param \App\Plan $plan The plan to assign * @param \App\Domain $domain Optional domain object * * @return \App\User Self */ public function assignPlan($plan, $domain = null): User { $this->setSetting('plan_id', $plan->id); foreach ($plan->packages as $package) { if ($package->isDomain()) { $domain->assignPackage($package, $this); } else { $this->assignPackage($package); } } return $this; } /** * Assign a Sku to a user. * * @param \App\Sku $sku The sku to assign. * @param int $count Count of entitlements to add * * @return \App\User Self * @throws \Exception */ public function assignSku(Sku $sku, int $count = 1): User { // TODO: I guess wallet could be parametrized in future $wallet = $this->wallet(); $exists = $this->entitlements()->where('sku_id', $sku->id)->count(); while ($count > 0) { \App\Entitlement::create([ 'wallet_id' => $wallet->id, 'sku_id' => $sku->id, 'cost' => $exists >= $sku->units_free ? $sku->cost : 0, 'fee' => $exists >= $sku->units_free ? $sku->fee : 0, 'entitleable_id' => $this->id, 'entitleable_type' => User::class ]); $exists++; $count--; } return $this; } /** * Check if current user can delete another object. * * @param mixed $object A user|domain|wallet|group object * * @return bool True if he can, False otherwise */ public function canDelete($object): bool { if (!method_exists($object, 'wallet')) { return false; } $wallet = $object->wallet(); // TODO: For now controller can delete/update the account owner, // this may change in future, controllers are not 0-regression feature return $wallet && ($wallet->user_id == $this->id || $this->accounts->contains($wallet)); } /** * Check if current user can read data of another object. * * @param mixed $object A user|domain|wallet|group object * * @return bool True if he can, False otherwise */ public function canRead($object): bool { if ($this->role == 'admin') { return true; } if ($object instanceof User && $this->id == $object->id) { return true; } if ($this->role == 'reseller') { if ($object instanceof User && $object->role == 'admin') { return false; } if ($object instanceof Wallet && !empty($object->owner)) { $object = $object->owner; } return isset($object->tenant_id) && $object->tenant_id == $this->tenant_id; } if ($object instanceof Wallet) { return $object->user_id == $this->id || $object->controllers->contains($this); } if (!method_exists($object, 'wallet')) { return false; } $wallet = $object->wallet(); return $wallet && ($wallet->user_id == $this->id || $this->accounts->contains($wallet)); } /** * Check if current user can update data of another object. * * @param mixed $object A user|domain|wallet|group object * * @return bool True if he can, False otherwise */ public function canUpdate($object): bool { if ($object instanceof User && $this->id == $object->id) { return true; } if ($this->role == 'admin') { return true; } if ($this->role == 'reseller') { if ($object instanceof User && $object->role == 'admin') { return false; } if ($object instanceof Wallet && !empty($object->owner)) { $object = $object->owner; } return isset($object->tenant_id) && $object->tenant_id == $this->tenant_id; } return $this->canDelete($object); } /** * Return the \App\Domain for this user. * * @return \App\Domain|null */ public function domain() { list($local, $domainName) = explode('@', $this->email); $domain = \App\Domain::withTrashed()->where('namespace', $domainName)->first(); return $domain; } /** * List the domains to which this user is entitled. * * @param bool $with_accounts Include domains assigned to wallets * the current user controls but not owns. * @param bool $with_public Include active public domains (for the user tenant). * * @return Domain[] List of Domain objects */ public function domains($with_accounts = true, $with_public = true): array { $domains = []; if ($with_public) { if ($this->tenant_id) { $domains = Domain::where('tenant_id', $this->tenant_id); } else { $domains = Domain::withEnvTenantContext(); } $domains = $domains->whereRaw(sprintf('(type & %s)', Domain::TYPE_PUBLIC)) ->whereRaw(sprintf('(status & %s)', Domain::STATUS_ACTIVE)) ->get() ->all(); } foreach ($this->wallets as $wallet) { $entitlements = $wallet->entitlements()->where('entitleable_type', Domain::class)->get(); foreach ($entitlements as $entitlement) { $domains[] = $entitlement->entitleable; } } if ($with_accounts) { foreach ($this->accounts as $wallet) { $entitlements = $wallet->entitlements()->where('entitleable_type', Domain::class)->get(); foreach ($entitlements as $entitlement) { $domains[] = $entitlement->entitleable; } } } return $domains; } /** * Find whether an email address exists as a user (including deleted users). * * @param string $email Email address * @param bool $return_user Return User instance instead of boolean * * @return \App\User|bool True or User model object if found, False otherwise */ public static function emailExists(string $email, bool $return_user = false) { if (strpos($email, '@') === false) { return false; } $email = \strtolower($email); $user = self::withTrashed()->where('email', $email)->first(); if ($user) { return $return_user ? $user : true; } return false; } /** * Helper to find user by email address, whether it is * main email address, alias or an external email. * * If there's more than one alias NULL will be returned. * * @param string $email Email address * @param bool $external Search also for an external email * * @return \App\User|null User model object if found */ public static function findByEmail(string $email, bool $external = false): ?User { if (strpos($email, '@') === false) { return null; } $email = \strtolower($email); $user = self::where('email', $email)->first(); if ($user) { return $user; } $aliases = UserAlias::where('alias', $email)->get(); if (count($aliases) == 1) { return $aliases->first()->user; } // TODO: External email return null; } /** * Return groups controlled by the current user. * * @param bool $with_accounts Include groups assigned to wallets * the current user controls but not owns. * * @return \Illuminate\Database\Eloquent\Builder Query builder */ public function groups($with_accounts = true) { $wallets = $this->wallets()->pluck('id')->all(); if ($with_accounts) { $wallets = array_merge($wallets, $this->accounts()->pluck('wallet_id')->all()); } return Group::select(['groups.*', 'entitlements.wallet_id']) ->distinct() ->join('entitlements', 'entitlements.entitleable_id', '=', 'groups.id') ->whereIn('entitlements.wallet_id', $wallets) ->where('entitlements.entitleable_type', Group::class); } /** * Check if user has an entitlement for the specified SKU. * * @param string $title The SKU title * * @return bool True if specified SKU entitlement exists */ public function hasSku(string $title): bool { $sku = Sku::withObjectTenantContext($this)->where('title', $title)->first(); if (!$sku) { return false; } return $this->entitlements()->where('sku_id', $sku->id)->count() > 0; } /** * Returns whether this domain is active. * * @return bool */ public function isActive(): bool { return ($this->status & self::STATUS_ACTIVE) > 0; } /** * Returns whether this domain is deleted. * * @return bool */ public function isDeleted(): bool { return ($this->status & self::STATUS_DELETED) > 0; } /** * Returns whether this (external) domain has been verified * to exist in DNS. * * @return bool */ public function isImapReady(): bool { return ($this->status & self::STATUS_IMAP_READY) > 0; } /** * Returns whether this user is registered in LDAP. * * @return bool */ public function isLdapReady(): bool { return ($this->status & self::STATUS_LDAP_READY) > 0; } /** * Returns whether this user is new. * * @return bool */ public function isNew(): bool { return ($this->status & self::STATUS_NEW) > 0; } /** * Returns whether this domain is suspended. * * @return bool */ public function isSuspended(): bool { return ($this->status & self::STATUS_SUSPENDED) > 0; } /** * A shortcut to get the user name. * * @param bool $fallback Return " User" if there's no name * * @return string Full user name */ public function name(bool $fallback = false): string { $settings = $this->getSettings(['first_name', 'last_name']); $name = trim($settings['first_name'] . ' ' . $settings['last_name']); if (empty($name) && $fallback) { return trim(\trans('app.siteuser', ['site' => \App\Tenant::getConfig($this->tenant_id, 'app.name')])); } return $name; } /** * Remove a number of entitlements for the SKU. * * @param \App\Sku $sku The SKU * @param int $count The number of entitlements to remove * * @return User Self */ public function removeSku(Sku $sku, int $count = 1): User { $entitlements = $this->entitlements() ->where('sku_id', $sku->id) ->orderBy('cost', 'desc') ->orderBy('created_at') ->get(); $entitlements_count = count($entitlements); foreach ($entitlements as $entitlement) { if ($entitlements_count <= $sku->units_free) { continue; } if ($count > 0) { $entitlement->delete(); $entitlements_count--; $count--; } } return $this; } /** * Return resources controlled by the current user. * * @param bool $with_accounts Include resources assigned to wallets * the current user controls but not owns. * * @return \Illuminate\Database\Eloquent\Builder Query builder */ public function resources($with_accounts = true) { $wallets = $this->wallets()->pluck('id')->all(); if ($with_accounts) { $wallets = array_merge($wallets, $this->accounts()->pluck('wallet_id')->all()); } return \App\Resource::select(['resources.*', 'entitlements.wallet_id']) ->distinct() ->join('entitlements', 'entitlements.entitleable_id', '=', 'resources.id') ->whereIn('entitlements.wallet_id', $wallets) ->where('entitlements.entitleable_type', \App\Resource::class); } + /** + * Return shared folders controlled by the current user. + * + * @param bool $with_accounts Include folders assigned to wallets + * the current user controls but not owns. + * + * @return \Illuminate\Database\Eloquent\Builder Query builder + */ + public function sharedFolders($with_accounts = true) + { + $wallets = $this->wallets()->pluck('id')->all(); + + if ($with_accounts) { + $wallets = array_merge($wallets, $this->accounts()->pluck('wallet_id')->all()); + } + + return \App\SharedFolder::select(['shared_folders.*', 'entitlements.wallet_id']) + ->distinct() + ->join('entitlements', 'entitlements.entitleable_id', '=', 'shared_folders.id') + ->whereIn('entitlements.wallet_id', $wallets) + ->where('entitlements.entitleable_type', \App\SharedFolder::class); + } + public function senderPolicyFrameworkWhitelist($clientName) { $setting = $this->getSetting('spf_whitelist'); if (!$setting) { return false; } $whitelist = json_decode($setting); $matchFound = false; foreach ($whitelist as $entry) { if (substr($entry, 0, 1) == '/') { $match = preg_match($entry, $clientName); if ($match) { $matchFound = true; } continue; } if (substr($entry, 0, 1) == '.') { if (substr($clientName, (-1 * strlen($entry))) == $entry) { $matchFound = true; } continue; } if ($entry == $clientName) { $matchFound = true; continue; } } return $matchFound; } /** * Suspend this domain. * * @return void */ public function suspend(): void { if ($this->isSuspended()) { return; } $this->status |= User::STATUS_SUSPENDED; $this->save(); } /** * Unsuspend this domain. * * @return void */ public function unsuspend(): void { if (!$this->isSuspended()) { return; } $this->status ^= User::STATUS_SUSPENDED; $this->save(); } /** * Return users controlled by the current user. * * @param bool $with_accounts Include users assigned to wallets * the current user controls but not owns. * * @return \Illuminate\Database\Eloquent\Builder Query builder */ public function users($with_accounts = true) { $wallets = $this->wallets()->pluck('id')->all(); if ($with_accounts) { $wallets = array_merge($wallets, $this->accounts()->pluck('wallet_id')->all()); } return $this->select(['users.*', 'entitlements.wallet_id']) ->distinct() ->leftJoin('entitlements', 'entitlements.entitleable_id', '=', 'users.id') ->whereIn('entitlements.wallet_id', $wallets) ->where('entitlements.entitleable_type', User::class); } /** * Verification codes for this user. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function verificationcodes() { return $this->hasMany('App\VerificationCode', 'user_id', 'id'); } /** * Wallets this user owns. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function wallets() { return $this->hasMany('App\Wallet'); } /** * User password mutator * * @param string $password The password in plain text. * * @return void */ public function setPasswordAttribute($password) { if (!empty($password)) { $this->attributes['password'] = bcrypt($password, [ "rounds" => 12 ]); $this->attributes['password_ldap'] = '{SSHA512}' . base64_encode( pack('H*', hash('sha512', $password)) ); } } /** * User LDAP password mutator * * @param string $password The password in plain text. * * @return void */ public function setPasswordLdapAttribute($password) { $this->setPasswordAttribute($password); } /** * User status mutator * * @throws \Exception */ public function setStatusAttribute($status) { $new_status = 0; $allowed_values = [ self::STATUS_NEW, self::STATUS_ACTIVE, self::STATUS_SUSPENDED, self::STATUS_DELETED, self::STATUS_LDAP_READY, self::STATUS_IMAP_READY, ]; foreach ($allowed_values as $value) { if ($status & $value) { $new_status |= $value; $status ^= $value; } } if ($status > 0) { throw new \Exception("Invalid user status: {$status}"); } $this->attributes['status'] = $new_status; } /** * Validate the user credentials * * @param string $username The username. * @param string $password The password in plain text. * @param bool $updatePassword Store the password if currently empty * * @return bool true on success */ public function validateCredentials(string $username, string $password, bool $updatePassword = true): bool { $authenticated = false; if ($this->email === \strtolower($username)) { if (!empty($this->password)) { if (Hash::check($password, $this->password)) { $authenticated = true; } } elseif (!empty($this->password_ldap)) { if (substr($this->password_ldap, 0, 6) == "{SSHA}") { $salt = substr(base64_decode(substr($this->password_ldap, 6)), 20); $hash = '{SSHA}' . base64_encode( sha1($password . $salt, true) . $salt ); if ($hash == $this->password_ldap) { $authenticated = true; } } elseif (substr($this->password_ldap, 0, 9) == "{SSHA512}") { $salt = substr(base64_decode(substr($this->password_ldap, 9)), 64); $hash = '{SSHA512}' . base64_encode( pack('H*', hash('sha512', $password . $salt)) . $salt ); if ($hash == $this->password_ldap) { $authenticated = true; } } } else { \Log::error("Incomplete credentials for {$this->email}"); } } if ($authenticated) { \Log::info("Successful authentication for {$this->email}"); // TODO: update last login time if ($updatePassword && (empty($this->password) || empty($this->password_ldap))) { $this->password = $password; $this->save(); } } else { // TODO: Try actual LDAP? \Log::info("Authentication failed for {$this->email}"); } return $authenticated; } /** * Retrieve and authenticate a user * * @param string $username The username. * @param string $password The password in plain text. * @param string $secondFactor The second factor (secondfactor from current request is used as fallback). * * @return array ['user', 'reason', 'errorMessage'] */ public static function findAndAuthenticate($username, $password, $secondFactor = null): ?array { $user = User::where('email', $username)->first(); if (!$user) { return ['reason' => 'notfound', 'errorMessage' => "User not found."]; } if (!$user->validateCredentials($username, $password)) { return ['reason' => 'credentials', 'errorMessage' => "Invalid password."]; } if (!$secondFactor) { // Check the request if there is a second factor provided // as fallback. $secondFactor = request()->secondfactor; } try { (new \App\Auth\SecondFactor($user))->validate($secondFactor); } catch (\Exception $e) { return ['reason' => 'secondfactor', 'errorMessage' => $e->getMessage()]; } return ['user' => $user]; } /** * Hook for passport * * @throws \Throwable * * @return \App\User User model object if found */ public function findAndValidateForPassport($username, $password): User { $result = self::findAndAuthenticate($username, $password); if (isset($result['reason'])) { if ($result['reason'] == 'secondfactor') { // This results in a json response of {'error': 'secondfactor', 'error_description': '$errorMessage'} throw new OAuthServerException($result['errorMessage'], 6, 'secondfactor', 401); } throw OAuthServerException::invalidCredentials(); } return $result['user']; } } diff --git a/src/database/migrations/2021_11_25_100000_create_shared_folders_table.php b/src/database/migrations/2021_11_25_100000_create_shared_folders_table.php new file mode 100644 index 00000000..01ce6f94 --- /dev/null +++ b/src/database/migrations/2021_11_25_100000_create_shared_folders_table.php @@ -0,0 +1,83 @@ +unsignedBigInteger('id'); + $table->string('email')->unique(); + $table->string('name'); + $table->string('type', 8); + $table->smallInteger('status'); + $table->unsignedBigInteger('tenant_id')->nullable(); + + $table->timestamps(); + $table->softDeletes(); + + $table->primary('id'); + $table->foreign('tenant_id')->references('id')->on('tenants')->onDelete('set null'); + } + ); + + Schema::create( + 'shared_folder_settings', + function (Blueprint $table) { + $table->bigIncrements('id'); + $table->unsignedBigInteger('shared_folder_id'); + $table->string('key'); + $table->text('value'); + $table->timestamps(); + + $table->foreign('shared_folder_id')->references('id')->on('shared_folders') + ->onDelete('cascade')->onUpdate('cascade'); + + $table->unique(['shared_folder_id', 'key']); + } + ); + + \App\Sku::where('title', 'shared_folder')->update([ + 'active' => true, + 'cost' => 0, + 'title' => 'shared-folder', + ]); + + if (!\App\Sku::where('title', 'beta-shared-folders')->first()) { + \App\Sku::create([ + 'title' => 'beta-shared-folders', + 'name' => 'Shared folders', + 'description' => 'Access to shared folders', + 'cost' => 0, + 'units_free' => 0, + 'period' => 'monthly', + 'handler_class' => 'App\Handlers\Beta\SharedFolders', + 'active' => true, + ]); + } + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('shared_folder_settings'); + Schema::dropIfExists('shared_folders'); + + // there's no need to remove the SKU + } +} diff --git a/src/database/seeds/DatabaseSeeder.php b/src/database/seeds/DatabaseSeeder.php index 3c463ac9..4947ec86 100644 --- a/src/database/seeds/DatabaseSeeder.php +++ b/src/database/seeds/DatabaseSeeder.php @@ -1,43 +1,44 @@ $name) { $class = "Database\\Seeds\\$env\\$name"; $seeders[$idx] = class_exists($class) ? $class : null; } $seeders = array_filter($seeders); $this->call($seeders); } } diff --git a/src/database/seeds/local/SharedFolderSeeder.php b/src/database/seeds/local/SharedFolderSeeder.php new file mode 100644 index 00000000..fdb9ed7c --- /dev/null +++ b/src/database/seeds/local/SharedFolderSeeder.php @@ -0,0 +1,35 @@ +first(); + $wallet = $john->wallets()->first(); + + $folder = SharedFolder::create([ + 'name' => 'Calendar', + 'email' => 'folder-event@kolab.org', + 'type' => 'event', + ]); + $folder->assignToWallet($wallet); + + $folder = SharedFolder::create([ + 'name' => 'Contacts', + 'email' => 'folder-contact@kolab.org', + 'type' => 'contact', + ]); + $folder->assignToWallet($wallet); + } +} diff --git a/src/database/seeds/local/SkuSeeder.php b/src/database/seeds/local/SkuSeeder.php index 4e0ef730..eaa0db48 100644 --- a/src/database/seeds/local/SkuSeeder.php +++ b/src/database/seeds/local/SkuSeeder.php @@ -1,348 +1,364 @@ 'mailbox', 'name' => 'User Mailbox', 'description' => 'Just a mailbox', 'cost' => 500, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Mailbox', 'active' => true, ] ); Sku::create( [ 'title' => 'domain', 'name' => 'Hosted Domain', 'description' => 'Somewhere to place a mailbox', 'cost' => 100, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Domain', 'active' => false, ] ); Sku::create( [ 'title' => 'domain-registration', 'name' => 'Domain Registration', 'description' => 'Register a domain with us', 'cost' => 101, 'period' => 'yearly', 'handler_class' => 'App\Handlers\DomainRegistration', 'active' => false, ] ); Sku::create( [ 'title' => 'domain-hosting', 'name' => 'External Domain', 'description' => 'Host a domain that is externally registered', 'cost' => 100, 'units_free' => 1, 'period' => 'monthly', 'handler_class' => 'App\Handlers\DomainHosting', 'active' => true, ] ); Sku::create( [ 'title' => 'domain-relay', 'name' => 'Domain Relay', 'description' => 'A domain you host at home, for which we relay email', 'cost' => 103, 'period' => 'monthly', 'handler_class' => 'App\Handlers\DomainRelay', 'active' => false, ] ); Sku::create( [ 'title' => 'storage', 'name' => 'Storage Quota', 'description' => 'Some wiggle room', 'cost' => 25, 'units_free' => 5, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Storage', 'active' => true, ] ); Sku::create( [ 'title' => 'groupware', 'name' => 'Groupware Features', 'description' => 'Groupware functions like Calendar, Tasks, Notes, etc.', 'cost' => 490, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Groupware', 'active' => true, ] ); Sku::create( [ 'title' => 'resource', 'name' => 'Resource', 'description' => 'Reservation taker', 'cost' => 101, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Resource', 'active' => true, ] ); Sku::create( [ - 'title' => 'shared_folder', + 'title' => 'shared-folder', 'name' => 'Shared Folder', 'description' => 'A shared folder', 'cost' => 89, 'period' => 'monthly', 'handler_class' => 'App\Handlers\SharedFolder', - 'active' => false, + 'active' => true, ] ); Sku::create( [ 'title' => '2fa', 'name' => '2-Factor Authentication', 'description' => 'Two factor authentication for webmail and administration panel', 'cost' => 0, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Auth2F', 'active' => true, ] ); Sku::create( [ 'title' => 'activesync', 'name' => 'Activesync', 'description' => 'Mobile synchronization', 'cost' => 0, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Activesync', 'active' => true, ] ); // Check existence because migration might have added this already $sku = Sku::where(['title' => 'beta', 'tenant_id' => \config('app.tenant_id')])->first(); if (!$sku) { Sku::create( [ 'title' => 'beta', 'name' => 'Private Beta (invitation only)', 'description' => 'Access to the private beta program subscriptions', 'cost' => 0, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Beta', 'active' => false, ] ); } // Check existence because migration might have added this already $sku = Sku::where(['title' => 'meet', 'tenant_id' => \config('app.tenant_id')])->first(); if (!$sku) { Sku::create( [ 'title' => 'meet', 'name' => 'Voice & Video Conferencing (public beta)', 'description' => 'Video conferencing tool', 'cost' => 0, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Meet', 'active' => true, ] ); } // Check existence because migration might have added this already $sku = Sku::where(['title' => 'group', 'tenant_id' => \config('app.tenant_id')])->first(); if (!$sku) { Sku::create( [ 'title' => 'group', 'name' => 'Group', 'description' => 'Distribution list', 'cost' => 0, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Group', 'active' => true, ] ); } // Check existence because migration might have added this already $sku = Sku::where(['title' => 'distlist', 'tenant_id' => \config('app.tenant_id')])->first(); if (!$sku) { Sku::create( [ 'title' => 'distlist', 'name' => 'Distribution lists', 'description' => 'Access to mail distribution lists', 'cost' => 0, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Distlist', 'active' => true, ] ); } // Check existence because migration might have added this already $sku = Sku::where(['title' => 'beta-resources', 'tenant_id' => \config('app.tenant_id')])->first(); if (!$sku) { Sku::create([ 'title' => 'beta-resources', 'name' => 'Calendaring resources', 'description' => 'Access to calendaring resources', 'cost' => 0, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Beta\Resources', 'active' => true, ]); } + // Check existence because migration might have added this already + $sku = Sku::where(['title' => 'beta-shared-folders', 'tenant_id' => \config('app.tenant_id')])->first(); + + if (!$sku) { + Sku::create([ + 'title' => 'beta-shared-folders', + 'name' => 'Shared folders', + 'description' => 'Access to shared folders', + 'cost' => 0, + 'units_free' => 0, + 'period' => 'monthly', + 'handler_class' => 'App\Handlers\Beta\SharedFolders', + 'active' => true, + ]); + } + // for tenants that are not the configured tenant id $tenants = \App\Tenant::where('id', '!=', \config('app.tenant_id'))->get(); foreach ($tenants as $tenant) { $sku = Sku::create( [ 'title' => 'mailbox', 'name' => 'User Mailbox', 'description' => 'Just a mailbox', 'cost' => 500, 'fee' => 333, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Mailbox', 'active' => true, ] ); $sku->tenant_id = $tenant->id; $sku->save(); $sku = Sku::create( [ 'title' => 'storage', 'name' => 'Storage Quota', 'description' => 'Some wiggle room', 'cost' => 25, 'fee' => 16, 'units_free' => 5, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Storage', 'active' => true, ] ); $sku->tenant_id = $tenant->id; $sku->save(); $sku = Sku::create( [ 'title' => 'domain-hosting', 'name' => 'External Domain', 'description' => 'Host a domain that is externally registered', 'cost' => 100, 'fee' => 66, 'units_free' => 1, 'period' => 'monthly', 'handler_class' => 'App\Handlers\DomainHosting', 'active' => true, ] ); $sku->tenant_id = $tenant->id; $sku->save(); $sku = Sku::create( [ 'title' => 'groupware', 'name' => 'Groupware Features', 'description' => 'Groupware functions like Calendar, Tasks, Notes, etc.', 'cost' => 490, 'fee' => 327, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Groupware', 'active' => true, ] ); $sku->tenant_id = $tenant->id; $sku->save(); $sku = Sku::create( [ 'title' => '2fa', 'name' => '2-Factor Authentication', 'description' => 'Two factor authentication for webmail and administration panel', 'cost' => 0, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Auth2F', 'active' => true, ] ); $sku->tenant_id = $tenant->id; $sku->save(); $sku = Sku::create( [ 'title' => 'activesync', 'name' => 'Activesync', 'description' => 'Mobile synchronization', 'cost' => 0, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Activesync', 'active' => true, ] ); $sku->tenant_id = $tenant->id; $sku->save(); } } } diff --git a/src/database/seeds/production/SkuSeeder.php b/src/database/seeds/production/SkuSeeder.php index 128d9a1d..107b76b5 100644 --- a/src/database/seeds/production/SkuSeeder.php +++ b/src/database/seeds/production/SkuSeeder.php @@ -1,231 +1,245 @@ 'mailbox', 'name' => 'User Mailbox', 'description' => 'Just a mailbox', 'cost' => 444, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Mailbox', 'active' => true, ] ); Sku::create( [ 'title' => 'domain', 'name' => 'Hosted Domain', 'description' => 'Somewhere to place a mailbox', 'cost' => 100, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Domain', 'active' => false, ] ); Sku::create( [ 'title' => 'domain-registration', 'name' => 'Domain Registration', 'description' => 'Register a domain with us', 'cost' => 101, 'period' => 'yearly', 'handler_class' => 'App\Handlers\DomainRegistration', 'active' => false, ] ); Sku::create( [ 'title' => 'domain-hosting', 'name' => 'External Domain', 'description' => 'Host a domain that is externally registered', 'cost' => 100, 'units_free' => 1, 'period' => 'monthly', 'handler_class' => 'App\Handlers\DomainHosting', 'active' => true, ] ); Sku::create( [ 'title' => 'domain-relay', 'name' => 'Domain Relay', 'description' => 'A domain you host at home, for which we relay email', 'cost' => 103, 'period' => 'monthly', 'handler_class' => 'App\Handlers\DomainRelay', 'active' => false, ] ); Sku::create( [ 'title' => 'storage', 'name' => 'Storage Quota', 'description' => 'Some wiggle room', 'cost' => 50, 'units_free' => 2, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Storage', 'active' => true, ] ); Sku::create( [ 'title' => 'groupware', 'name' => 'Groupware Features', 'description' => 'Groupware functions like Calendar, Tasks, Notes, etc.', 'cost' => 555, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Groupware', 'active' => true, ] ); Sku::create( [ 'title' => 'resource', 'name' => 'Resource', 'description' => 'Reservation taker', 'cost' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Resource', 'active' => true, ] ); Sku::create( [ - 'title' => 'shared_folder', + 'title' => 'shared-folder', 'name' => 'Shared Folder', 'description' => 'A shared folder', 'cost' => 89, 'period' => 'monthly', 'handler_class' => 'App\Handlers\SharedFolder', 'active' => false, ] ); Sku::create( [ 'title' => '2fa', 'name' => '2-Factor Authentication', 'description' => 'Two factor authentication for webmail and administration panel', 'cost' => 0, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Auth2F', 'active' => true, ] ); Sku::create( [ 'title' => 'activesync', 'name' => 'Activesync', 'description' => 'Mobile synchronization', 'cost' => 100, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Activesync', 'active' => true, ] ); // Check existence because migration might have added this already if (!Sku::where('title', 'beta')->first()) { Sku::create( [ 'title' => 'beta', 'name' => 'Private Beta (invitation only)', 'description' => 'Access to the private beta program subscriptions', 'cost' => 0, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Beta', 'active' => false, ] ); } // Check existence because migration might have added this already if (!Sku::where('title', 'meet')->first()) { Sku::create( [ 'title' => 'meet', 'name' => 'Voice & Video Conferencing (public beta)', 'description' => 'Video conferencing tool', 'cost' => 0, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Meet', 'active' => true, ] ); } // Check existence because migration might have added this already if (!Sku::where('title', 'group')->first()) { Sku::create( [ 'title' => 'group', 'name' => 'Group', 'description' => 'Distribution list', 'cost' => 0, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Group', 'active' => true, ] ); } // Check existence because migration might have added this already if (!Sku::where('title', 'distlist')->first()) { Sku::create([ 'title' => 'distlist', 'name' => 'Distribution lists', 'description' => 'Access to mail distribution lists', 'cost' => 0, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Distlist', 'active' => true, ]); } // Check existence because migration might have added this already if (!Sku::where('title', 'beta-resources')->first()) { Sku::create([ 'title' => 'beta-resources', 'name' => 'Calendaring resources', 'description' => 'Access to calendaring resources', 'cost' => 0, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Beta\Resources', 'active' => true, ]); } + + // Check existence because migration might have added this already + if (!Sku::where('title', 'beta-shared-folders')->first()) { + Sku::create([ + 'title' => 'beta-shared-folders', + 'name' => 'Shared folders', + 'description' => 'Access to shared folders', + 'cost' => 0, + 'units_free' => 0, + 'period' => 'monthly', + 'handler_class' => 'App\Handlers\Beta\SharedFolders', + 'active' => true, + ]); + } } } diff --git a/src/phpstan.neon b/src/phpstan.neon index 3c10190b..539c683d 100644 --- a/src/phpstan.neon +++ b/src/phpstan.neon @@ -1,15 +1,16 @@ includes: - ./vendor/nunomaduro/larastan/extension.neon parameters: ignoreErrors: - '#Access to an undefined property [a-zA-Z\\]+::\$pivot#' - '#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/ - tests/ diff --git a/src/resources/js/admin/routes.js b/src/resources/js/admin/routes.js index 53f02a35..1d73c098 100644 --- a/src/resources/js/admin/routes.js +++ b/src/resources/js/admin/routes.js @@ -1,69 +1,76 @@ import DashboardComponent from '../../vue/Admin/Dashboard' import DistlistComponent from '../../vue/Admin/Distlist' import DomainComponent from '../../vue/Admin/Domain' import LoginComponent from '../../vue/Login' import LogoutComponent from '../../vue/Logout' import PageComponent from '../../vue/Page' import ResourceComponent from '../../vue/Admin/Resource' +import SharedFolderComponent from '../../vue/Admin/SharedFolder' import StatsComponent from '../../vue/Admin/Stats' import UserComponent from '../../vue/Admin/User' const routes = [ { path: '/', redirect: { name: 'dashboard' } }, { path: '/dashboard', name: 'dashboard', component: DashboardComponent, meta: { requiresAuth: true } }, { path: '/distlist/:list', name: 'distlist', component: DistlistComponent, meta: { requiresAuth: true } }, { path: '/domain/:domain', name: 'domain', component: DomainComponent, meta: { requiresAuth: true } }, { path: '/login', name: 'login', component: LoginComponent }, { path: '/logout', name: 'logout', component: LogoutComponent }, { path: '/resource/:resource', name: 'resource', component: ResourceComponent, meta: { requiresAuth: true } }, + { + path: '/shared-folder/:folder', + name: 'shared-folder', + component: SharedFolderComponent, + meta: { requiresAuth: true } + }, { path: '/stats', name: 'stats', component: StatsComponent, meta: { requiresAuth: true } }, { path: '/user/:user', name: 'user', component: UserComponent, meta: { requiresAuth: true } }, { name: '404', path: '*', component: PageComponent } ] export default routes diff --git a/src/resources/js/app.js b/src/resources/js/app.js index af98c22a..c6e4e30e 100644 --- a/src/resources/js/app.js +++ b/src/resources/js/app.js @@ -1,565 +1,571 @@ /** * First we will load all of this project's JavaScript dependencies which * includes Vue and other libraries. It is a great starting point when * building robust, powerful web applications using Vue and Laravel. */ require('./bootstrap') import AppComponent from '../vue/App' import MenuComponent from '../vue/Widgets/Menu' import SupportForm from '../vue/Widgets/SupportForm' import store from './store' import { Tab } from 'bootstrap' import { loadLangAsync, i18n } from './locale' const loader = '
Loading
' let isLoading = 0 // Lock the UI with the 'loading...' element const startLoading = () => { isLoading++ let loading = $('#app > .app-loader').removeClass('fadeOut') if (!loading.length) { $('#app').append($(loader)) } } // Hide "loading" overlay const stopLoading = () => { if (isLoading > 0) { $('#app > .app-loader').addClass('fadeOut') isLoading--; } } let loadingRoute // Note: This has to be before the app is created // Note: You cannot use app inside of the function window.router.beforeEach((to, from, next) => { // check if the route requires authentication and user is not logged in if (to.meta.requiresAuth && !store.state.isLoggedIn) { // remember the original request, to use after login store.state.afterLogin = to; // redirect to login page next({ name: 'login' }) return } if (to.meta.loading) { startLoading() loadingRoute = to.name } next() }) window.router.afterEach((to, from) => { if (to.name && loadingRoute === to.name) { stopLoading() loadingRoute = null } // When changing a page remove old: // - error page // - modal backdrop $('#error-page,.modal-backdrop.show').remove() $('body').css('padding', 0) // remove padding added by unclosed modal }) const app = new Vue({ components: { AppComponent, MenuComponent, }, i18n, store, router: window.router, data() { return { isUser: !window.isAdmin && !window.isReseller, appName: window.config['app.name'], appUrl: window.config['app.url'], themeDir: '/themes/' + window.config['app.theme'] } }, methods: { // Clear (bootstrap) form validation state clearFormValidation(form) { $(form).find('.is-invalid').removeClass('is-invalid') $(form).find('.invalid-feedback').remove() }, hasPermission(type) { const authInfo = store.state.authInfo const key = 'enable' + type.charAt(0).toUpperCase() + type.slice(1) return !!(authInfo && authInfo.statusInfo[key]) }, hasRoute(name) { return this.$router.resolve({ name: name }).resolved.matched.length > 0 }, hasSKU(name) { const authInfo = store.state.authInfo return authInfo.statusInfo.skus && authInfo.statusInfo.skus.indexOf(name) != -1 }, isController(wallet_id) { if (wallet_id && store.state.authInfo) { let i for (i = 0; i < store.state.authInfo.wallets.length; i++) { if (wallet_id == store.state.authInfo.wallets[i].id) { return true } } for (i = 0; i < store.state.authInfo.accounts.length; i++) { if (wallet_id == store.state.authInfo.accounts[i].id) { return true } } } return false }, // Set user state to "logged in" loginUser(response, dashboard, update) { if (!update) { store.commit('logoutUser') // destroy old state data store.commit('loginUser') } localStorage.setItem('token', response.access_token) localStorage.setItem('refreshToken', response.refresh_token) axios.defaults.headers.common.Authorization = 'Bearer ' + response.access_token if (response.email) { store.state.authInfo = response } if (dashboard !== false) { this.$router.push(store.state.afterLogin || { name: 'dashboard' }) } store.state.afterLogin = null // Refresh the token before it expires let timeout = response.expires_in || 0 // We'll refresh 60 seconds before the token expires if (timeout > 60) { timeout -= 60 } // TODO: We probably should try a few times in case of an error // TODO: We probably should prevent axios from doing any requests // while the token is being refreshed this.refreshTimeout = setTimeout(() => { axios.post('/api/auth/refresh', {'refresh_token': response.refresh_token}).then(response => { this.loginUser(response.data, false, true) }) }, timeout * 1000) }, // Set user state to "not logged in" logoutUser(redirect) { store.commit('logoutUser') localStorage.setItem('token', '') localStorage.setItem('refreshToken', '') delete axios.defaults.headers.common.Authorization if (redirect !== false) { this.$router.push({ name: 'login' }) } clearTimeout(this.refreshTimeout) }, logo(mode) { let src = this.appUrl + this.themeDir + '/images/logo_' + (mode || 'header') + '.png' return `${this.appName}` }, // Display "loading" overlay inside of the specified element addLoader(elem, small = true, style = null) { if (style) { $(elem).css(style) } else { $(elem).css('position', 'relative') } $(elem).append(small ? $(loader).addClass('small') : $(loader)) }, // Create an object copy with specified properties only pick(obj, properties) { let result = {} properties.forEach(prop => { if (prop in obj) { result[prop] = obj[prop] } }) return result }, // Remove loader element added in addLoader() removeLoader(elem) { $(elem).find('.app-loader').remove() }, startLoading, stopLoading, isLoading() { return isLoading > 0 }, tab(e) { e.preventDefault() new Tab(e.target).show() }, errorPage(code, msg, hint) { // Until https://github.com/vuejs/vue-router/issues/977 is implemented // we can't really use router to display error page as it has two side // effects: it changes the URL and adds the error page to browser history. // For now we'll be replacing current view with error page "manually". if (!msg) msg = this.$te('error.' + code) ? this.$t('error.' + code) : this.$t('error.unknown') if (!hint) hint = '' const error_page = '
' + `
${code}
${msg}
${hint}
` + '
' $('#error-page').remove() $('#app').append(error_page) app.updateBodyClass('error') }, errorHandler(error) { this.stopLoading() if (!error.response) { // TODO: probably network connection error } else if (error.response.status === 401) { // Remember requested route to come back to it after log in if (this.$route.meta.requiresAuth) { store.state.afterLogin = this.$route this.logoutUser() } else { this.logoutUser(false) } } else { this.errorPage(error.response.status, error.response.statusText) } }, downloadFile(url) { // TODO: This might not be a best way for big files as the content // will be stored (temporarily) in browser memory // TODO: This method does not show the download progress in the browser // but it could be implemented in the UI, axios has 'progress' property axios.get(url, { responseType: 'blob' }) .then(response => { const link = document.createElement('a') const contentDisposition = response.headers['content-disposition'] let filename = 'unknown' if (contentDisposition) { const match = contentDisposition.match(/filename="(.+)"/); if (match.length === 2) { filename = match[1]; } } link.href = window.URL.createObjectURL(response.data) link.download = filename link.click() }) }, price(price, currency) { // TODO: Set locale argument according to the currently used locale return ((price || 0) / 100).toLocaleString('de-DE', { style: 'currency', currency: currency || 'CHF' }) }, priceLabel(cost, discount, currency) { let index = '' if (discount) { cost = Math.floor(cost * ((100 - discount) / 100)) index = '\u00B9' } return this.price(cost, currency) + '/' + this.$t('wallet.month') + index }, clickRecord(event) { if (!/^(a|button|svg|path)$/i.test(event.target.nodeName)) { $(event.target).closest('tr').find('a').trigger('click') } }, domainStatusClass(domain) { if (domain.isDeleted) { return 'text-muted' } if (domain.isSuspended) { return 'text-warning' } if (!domain.isVerified || !domain.isLdapReady || !domain.isConfirmed) { return 'text-danger' } return 'text-success' }, domainStatusText(domain) { if (domain.isDeleted) { return this.$t('status.deleted') } if (domain.isSuspended) { return this.$t('status.suspended') } if (!domain.isVerified || !domain.isLdapReady || !domain.isConfirmed) { return this.$t('status.notready') } return this.$t('status.active') }, distlistStatusClass(list) { if (list.isDeleted) { return 'text-muted' } if (list.isSuspended) { return 'text-warning' } if (!list.isLdapReady) { return 'text-danger' } return 'text-success' }, distlistStatusText(list) { if (list.isDeleted) { return this.$t('status.deleted') } if (list.isSuspended) { return this.$t('status.suspended') } if (!list.isLdapReady) { return this.$t('status.notready') } return this.$t('status.active') }, + folderStatusClass(folder) { + return this.userStatusClass(folder) + }, + folderStatusText(folder) { + return this.userStatusText(folder) + }, pageName(path) { let page = this.$route.path // check if it is a "menu page", find the page name // otherwise we'll use the real path as page name window.config.menu.every(item => { if (item.location == page && item.page) { page = item.page return false } }) page = page.replace(/^\//, '') return page ? page : '404' }, resourceStatusClass(resource) { return this.userStatusClass(resource) }, resourceStatusText(resource) { return this.userStatusText(resource) }, supportDialog(container) { let dialog = $('#support-dialog')[0] if (!dialog) { // FIXME: Find a nicer way of doing this SupportForm.i18n = i18n let form = new Vue(SupportForm) form.$mount($('
').appendTo(container)[0]) form.$root = this form.$toast = this.$toast dialog = form.$el } dialog.__vue__.showDialog() }, userStatusClass(user) { if (user.isDeleted) { return 'text-muted' } if (user.isSuspended) { return 'text-warning' } if (!user.isImapReady || !user.isLdapReady) { return 'text-danger' } return 'text-success' }, userStatusText(user) { if (user.isDeleted) { return this.$t('status.deleted') } if (user.isSuspended) { return this.$t('status.suspended') } if (!user.isImapReady || !user.isLdapReady) { return this.$t('status.notready') } return this.$t('status.active') }, // Append some wallet properties to the object userWalletProps(object) { let wallet = store.state.authInfo.accounts[0] if (!wallet) { wallet = store.state.authInfo.wallets[0] } if (wallet) { object.currency = wallet.currency if (wallet.discount) { object.discount = wallet.discount object.discount_description = wallet.discount_description } } }, updateBodyClass(name) { // Add 'class' attribute to the body, different for each page // so, we can apply page-specific styles document.body.className = 'page-' + (name || this.pageName()).replace(/\/.*$/, '') } } }) // Fetch the locale file and the start the app loadLangAsync().then(() => app.$mount('#app')) // Add a axios request interceptor window.axios.interceptors.request.use( config => { // This is the only way I found to change configuration options // on a running application. We need this for browser testing. config.headers['X-Test-Payment-Provider'] = window.config.paymentProvider return config }, error => { // Do something with request error return Promise.reject(error) } ) // Add a axios response interceptor for general/validation error handler window.axios.interceptors.response.use( response => { if (response.config.onFinish) { response.config.onFinish() } return response }, error => { // Do not display the error in a toast message, pass the error as-is if (error.config.ignoreErrors) { return Promise.reject(error) } if (error.config.onFinish) { error.config.onFinish() } let error_msg const status = error.response ? error.response.status : 200 const data = error.response ? error.response.data : {} if (status == 422 && data.errors) { error_msg = app.$t('error.form') const modal = $('div.modal.show') $(modal.length ? modal : 'form').each((i, form) => { form = $(form) $.each(data.errors, (idx, msg) => { const input_name = (form.data('validation-prefix') || form.find('form').first().data('validation-prefix') || '') + idx let input = form.find('#' + input_name) if (!input.length) { input = form.find('[name="' + input_name + '"]'); } if (input.length) { // Create an error message // API responses can use a string, array or object let msg_text = '' if (typeof(msg) !== 'string') { $.each(msg, (index, str) => { msg_text += str + ' ' }) } else { msg_text = msg } let feedback = $('
').text(msg_text) if (input.is('.list-input')) { // List input widget let controls = input.children(':not(:first-child)') if (!controls.length && typeof msg == 'string') { // this is an empty list (the main input only) // and the error message is not an array input.find('.main-input').addClass('is-invalid') } else { controls.each((index, element) => { if (msg[index]) { $(element).find('input').addClass('is-invalid') } }) } input.addClass('is-invalid').next('.invalid-feedback').remove() input.after(feedback) } else { // a special case, e.g. the invitation policy widget if (input.is('select') && input.parent().is('.input-group-select.selected')) { input = input.next() } // Standard form element input.addClass('is-invalid') input.parent().find('.invalid-feedback').remove() input.parent().append(feedback) } } }) form.find('.is-invalid:not(.listinput-widget)').first().focus() }) } else if (data.status == 'error') { error_msg = data.message } else { error_msg = error.request ? error.request.statusText : error.message } app.$toast.error(error_msg || app.$t('error.server')) // Pass the error as-is return Promise.reject(error) } ) diff --git a/src/resources/js/fontawesome.js b/src/resources/js/fontawesome.js index 605a6b26..4454bb89 100644 --- a/src/resources/js/fontawesome.js +++ b/src/resources/js/fontawesome.js @@ -1,70 +1,72 @@ import { library } from '@fortawesome/fontawesome-svg-core' import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' //import { } from '@fortawesome/free-brands-svg-icons' import { faCheckSquare, faCreditCard, faSquare, } from '@fortawesome/free-regular-svg-icons' import { faCheck, faCheckCircle, faCog, faComments, faDownload, faEnvelope, + faFolderOpen, faGlobe, faUniversity, faExclamationCircle, faInfoCircle, faLock, faKey, faPlus, faSearch, faSignInAlt, faSyncAlt, faTrashAlt, faUser, faUserCog, faUserFriends, faUsers, faWallet } from '@fortawesome/free-solid-svg-icons' import { faPaypal } from '@fortawesome/free-brands-svg-icons' // Register only these icons we need library.add( faCheck, faCheckCircle, faCheckSquare, faCog, faComments, faCreditCard, faPaypal, faUniversity, faDownload, faEnvelope, faExclamationCircle, + faFolderOpen, faGlobe, faInfoCircle, faLock, faKey, faPlus, faSearch, faSignInAlt, faSquare, faSyncAlt, faTrashAlt, faUser, faUserCog, faUserFriends, faUsers, faWallet ) export default FontAwesomeIcon diff --git a/src/resources/js/reseller/routes.js b/src/resources/js/reseller/routes.js index 569a86d9..b1770e9d 100644 --- a/src/resources/js/reseller/routes.js +++ b/src/resources/js/reseller/routes.js @@ -1,83 +1,90 @@ import DashboardComponent from '../../vue/Reseller/Dashboard' import DistlistComponent from '../../vue/Admin/Distlist' import DomainComponent from '../../vue/Admin/Domain' import InvitationsComponent from '../../vue/Reseller/Invitations' import LoginComponent from '../../vue/Login' import LogoutComponent from '../../vue/Logout' import PageComponent from '../../vue/Page' import ResourceComponent from '../../vue/Admin/Resource' +import SharedFolderComponent from '../../vue/Admin/SharedFolder' import StatsComponent from '../../vue/Reseller/Stats' import UserComponent from '../../vue/Admin/User' import WalletComponent from '../../vue/Wallet' const routes = [ { path: '/', redirect: { name: 'dashboard' } }, { path: '/dashboard', name: 'dashboard', component: DashboardComponent, meta: { requiresAuth: true } }, { path: '/distlist/:list', name: 'distlist', component: DistlistComponent, meta: { requiresAuth: true } }, { path: '/domain/:domain', name: 'domain', component: DomainComponent, meta: { requiresAuth: true } }, { path: '/login', name: 'login', component: LoginComponent }, { path: '/logout', name: 'logout', component: LogoutComponent }, { path: '/invitations', name: 'invitations', component: InvitationsComponent, meta: { requiresAuth: true } }, { path: '/resource/:resource', name: 'resource', component: ResourceComponent, meta: { requiresAuth: true } }, + { + path: '/shared-folder/:folder', + name: 'shared-folder', + component: SharedFolderComponent, + meta: { requiresAuth: true } + }, { path: '/stats', name: 'stats', component: StatsComponent, meta: { requiresAuth: true } }, { path: '/user/:user', name: 'user', component: UserComponent, meta: { requiresAuth: true } }, { path: '/wallet', name: 'wallet', component: WalletComponent, meta: { requiresAuth: true } }, { name: '404', path: '*', component: PageComponent } ] export default routes diff --git a/src/resources/js/user/routes.js b/src/resources/js/user/routes.js index 8a1d5b95..ce9e6e47 100644 --- a/src/resources/js/user/routes.js +++ b/src/resources/js/user/routes.js @@ -1,143 +1,157 @@ import DashboardComponent from '../../vue/Dashboard' import DistlistInfoComponent from '../../vue/Distlist/Info' import DistlistListComponent from '../../vue/Distlist/List' import DomainInfoComponent from '../../vue/Domain/Info' import DomainListComponent from '../../vue/Domain/List' import LoginComponent from '../../vue/Login' import LogoutComponent from '../../vue/Logout' import MeetComponent from '../../vue/Rooms' import PageComponent from '../../vue/Page' import PasswordResetComponent from '../../vue/PasswordReset' import ResourceInfoComponent from '../../vue/Resource/Info' import ResourceListComponent from '../../vue/Resource/List' +import SharedFolderInfoComponent from '../../vue/SharedFolder/Info' +import SharedFolderListComponent from '../../vue/SharedFolder/List' import SignupComponent from '../../vue/Signup' import UserInfoComponent from '../../vue/User/Info' import UserListComponent from '../../vue/User/List' import UserProfileComponent from '../../vue/User/Profile' import UserProfileDeleteComponent from '../../vue/User/ProfileDelete' import WalletComponent from '../../vue/Wallet' // Here's a list of lazy-loaded components // Note: you can pack multiple components into the same chunk, webpackChunkName // is also used to get a sensible file name instead of numbers const RoomComponent = () => import(/* webpackChunkName: "room" */ '../../vue/Meet/Room.vue') const routes = [ { path: '/dashboard', name: 'dashboard', component: DashboardComponent, meta: { requiresAuth: true } }, { path: '/distlist/:list', name: 'distlist', component: DistlistInfoComponent, meta: { requiresAuth: true, perm: 'distlists' } }, { path: '/distlists', name: 'distlists', component: DistlistListComponent, meta: { requiresAuth: true, perm: 'distlists' } }, { path: '/domain/:domain', name: 'domain', component: DomainInfoComponent, meta: { requiresAuth: true, perm: 'domains' } }, { path: '/domains', name: 'domains', component: DomainListComponent, meta: { requiresAuth: true, perm: 'domains' } }, { path: '/login', name: 'login', component: LoginComponent }, { path: '/logout', name: 'logout', component: LogoutComponent }, { path: '/password-reset/:code?', name: 'password-reset', component: PasswordResetComponent }, { path: '/profile', name: 'profile', component: UserProfileComponent, meta: { requiresAuth: true } }, { path: '/profile/delete', name: 'profile-delete', component: UserProfileDeleteComponent, meta: { requiresAuth: true } }, { path: '/resource/:resource', name: 'resource', component: ResourceInfoComponent, meta: { requiresAuth: true, perm: 'resources' } }, { path: '/resources', name: 'resources', component: ResourceListComponent, meta: { requiresAuth: true, perm: 'resources' } }, { component: RoomComponent, name: 'room', path: '/meet/:room', meta: { loading: true } }, { path: '/rooms', name: 'rooms', component: MeetComponent, meta: { requiresAuth: true } }, + { + path: '/shared-folder/:folder', + name: 'shared-folder', + component: SharedFolderInfoComponent, + meta: { requiresAuth: true, perm: 'folders' } + }, + { + path: '/shared-folders', + name: 'shared-folders', + component: SharedFolderListComponent, + meta: { requiresAuth: true, perm: 'folders' } + }, { path: '/signup/invite/:param', name: 'signup-invite', component: SignupComponent }, { path: '/signup/:param?', alias: '/signup/voucher/:param', name: 'signup', component: SignupComponent }, { path: '/user/:user', name: 'user', component: UserInfoComponent, meta: { requiresAuth: true, perm: 'users' } }, { path: '/users', name: 'users', component: UserListComponent, meta: { requiresAuth: true, perm: 'users' } }, { path: '/wallet', name: 'wallet', component: WalletComponent, meta: { requiresAuth: true, perm: 'wallets' } }, { name: '404', path: '*', component: PageComponent } ] export default routes diff --git a/src/resources/lang/en/app.php b/src/resources/lang/en/app.php index 49a40be1..d17b211a 100644 --- a/src/resources/lang/en/app.php +++ b/src/resources/lang/en/app.php @@ -1,106 +1,117 @@ 'Created', 'chart-deleted' => 'Deleted', 'chart-average' => 'average', 'chart-allusers' => 'All Users - last year', 'chart-discounts' => 'Discounts', 'chart-vouchers' => 'Vouchers', 'chart-income' => 'Income in :currency - last 8 weeks', 'chart-users' => 'Users - last 8 weeks', 'mandate-delete-success' => 'The auto-payment has been removed.', 'mandate-update-success' => 'The auto-payment has been updated.', 'planbutton' => 'Choose :plan', 'process-async' => 'Setup process has been pushed. Please wait.', 'process-user-new' => 'Registering a user...', 'process-user-ldap-ready' => 'Creating a user...', 'process-user-imap-ready' => 'Creating a mailbox...', 'process-domain-new' => 'Registering a custom domain...', 'process-domain-ldap-ready' => 'Creating a custom domain...', 'process-domain-verified' => 'Verifying a custom domain...', 'process-domain-confirmed' => 'Verifying an ownership of a custom domain...', 'process-success' => 'Setup process finished successfully.', 'process-error-distlist-ldap-ready' => 'Failed to create a distribution list.', 'process-error-domain-ldap-ready' => 'Failed to create a domain.', 'process-error-domain-verified' => 'Failed to verify a domain.', 'process-error-domain-confirmed' => 'Failed to verify an ownership of a domain.', 'process-error-resource-imap-ready' => 'Failed to verify that a shared folder exists.', 'process-error-resource-ldap-ready' => 'Failed to create a resource.', + 'process-error-shared-folder-imap-ready' => 'Failed to verify that a shared folder exists.', + 'process-error-shared-folder-ldap-ready' => 'Failed to create a shared folder.', 'process-error-user-ldap-ready' => 'Failed to create a user.', 'process-error-user-imap-ready' => 'Failed to verify that a mailbox exists.', 'process-distlist-new' => 'Registering a distribution list...', 'process-distlist-ldap-ready' => 'Creating a distribution list...', 'process-resource-new' => 'Registering a resource...', 'process-resource-imap-ready' => 'Creating a shared folder...', 'process-resource-ldap-ready' => 'Creating a resource...', + 'process-shared-folder-new' => 'Registering a shared folder...', + 'process-shared-folder-imap-ready' => 'Creating a shared folder...', + 'process-shared-folder-ldap-ready' => 'Creating a shared folder...', 'distlist-update-success' => 'Distribution list updated successfully.', 'distlist-create-success' => 'Distribution list created successfully.', 'distlist-delete-success' => 'Distribution list deleted successfully.', 'distlist-suspend-success' => 'Distribution list suspended successfully.', 'distlist-unsuspend-success' => 'Distribution list unsuspended successfully.', 'distlist-setconfig-success' => 'Distribution list settings updated successfully.', 'domain-create-success' => 'Domain created successfully.', 'domain-delete-success' => 'Domain deleted successfully.', 'domain-notempty-error' => 'Unable to delete a domain with assigned users or other objects.', 'domain-verify-success' => 'Domain verified successfully.', 'domain-verify-error' => 'Domain ownership verification failed.', 'domain-suspend-success' => 'Domain suspended successfully.', 'domain-unsuspend-success' => 'Domain unsuspended successfully.', 'domain-setconfig-success' => 'Domain settings updated successfully.', 'resource-update-success' => 'Resource updated successfully.', 'resource-create-success' => 'Resource created successfully.', 'resource-delete-success' => 'Resource deleted successfully.', 'resource-setconfig-success' => 'Resource settings updated successfully.', + 'shared-folder-update-success' => 'Shared folder updated successfully.', + 'shared-folder-create-success' => 'Shared folder created successfully.', + 'shared-folder-delete-success' => 'Shared folder deleted successfully.', + 'shared-folder-setconfig-success' => 'Shared folder settings updated successfully.', + 'user-update-success' => 'User data updated successfully.', 'user-create-success' => 'User created successfully.', 'user-delete-success' => 'User deleted successfully.', 'user-suspend-success' => 'User suspended successfully.', 'user-unsuspend-success' => 'User unsuspended successfully.', 'user-reset-2fa-success' => '2-Factor authentication reset successfully.', 'user-setconfig-success' => 'User settings updated successfully.', 'user-set-sku-success' => 'The subscription added successfully.', 'user-set-sku-already-exists' => 'The subscription already exists.', 'search-foundxdomains' => ':x domains have been found.', 'search-foundxdistlists' => ':x distribution lists have been found.', 'search-foundxresources' => ':x resources have been found.', + 'search-foundxsharedfolders' => ':x shared folders have been found.', 'search-foundxusers' => ':x user accounts have been found.', 'signup-invitations-created' => 'The invitation has been created.|:count invitations has been created.', 'signup-invitations-csv-empty' => 'Failed to find any valid email addresses in the uploaded file.', 'signup-invitations-csv-invalid-email' => 'Found an invalid email address (:email) on line :line.', 'signup-invitation-delete-success' => 'Invitation deleted successfully.', 'signup-invitation-resend-success' => 'Invitation added to the sending queue successfully.', 'support-request-success' => 'Support request submitted successfully.', 'support-request-error' => 'Failed to submit the support request.', 'siteuser' => ':site User', 'wallet-award-success' => 'The bonus has been added to the wallet successfully.', 'wallet-penalty-success' => 'The penalty has been added to the wallet successfully.', 'wallet-update-success' => 'User wallet updated successfully.', 'wallet-notice-date' => 'With your current subscriptions your account balance will last until about :date (:days).', 'wallet-notice-nocredit' => 'You are out of credit, top up your balance now.', 'wallet-notice-today' => 'You will run out of credit today, top up your balance now.', 'wallet-notice-trial' => 'You are in your free trial period.', 'wallet-notice-trial-end' => 'Your free trial is about to end, top up to continue.', ]; diff --git a/src/resources/lang/en/ui.php b/src/resources/lang/en/ui.php index 7a13c96e..795ac2c8 100644 --- a/src/resources/lang/en/ui.php +++ b/src/resources/lang/en/ui.php @@ -1,456 +1,480 @@ [ 'faq' => "FAQ", ], 'btn' => [ 'add' => "Add", 'accept' => "Accept", 'back' => "Back", 'cancel' => "Cancel", 'close' => "Close", 'continue' => "Continue", 'delete' => "Delete", 'deny' => "Deny", 'download' => "Download", 'edit' => "Edit", 'file' => "Choose file...", 'moreinfo' => "More information", 'refresh' => "Refresh", 'reset' => "Reset", 'resend' => "Resend", 'save' => "Save", 'search' => "Search", 'signup' => "Sign Up", 'submit' => "Submit", 'suspend' => "Suspend", 'unsuspend' => "Unsuspend", 'verify' => "Verify", ], 'dashboard' => [ 'beta' => "beta", 'distlists' => "Distribution lists", 'chat' => "Video chat", 'domains' => "Domains", 'invitations' => "Invitations", 'profile' => "Your profile", 'resources' => "Resources", + 'shared-folders' => "Shared folders", 'users' => "User accounts", 'wallet' => "Wallet", 'webmail' => "Webmail", 'stats' => "Stats", ], 'distlist' => [ 'list-title' => "Distribution list | Distribution lists", 'create' => "Create list", 'delete' => "Delete list", 'email' => "Email", 'list-empty' => "There are no distribution lists in this account.", 'name' => "Name", 'new' => "New distribution list", 'recipients' => "Recipients", 'sender-policy' => "Sender Access List", 'sender-policy-text' => "With this list you can specify who can send mail to the distribution list." . " You can put a complete email address (jane@kolab.org), domain (kolab.org) or suffix (.org) that the sender email address is compared to." . " If the list is empty, mail from anyone is allowed.", ], 'domain' => [ 'delete' => "Delete domain", 'delete-domain' => "Delete {domain}", 'delete-text' => "Do you really want to delete this domain permanently?" . " This is only possible if there are no users, aliases or other objects in this domain." . " Please note that this action cannot be undone.", 'dns-verify' => "Domain DNS verification sample:", 'dns-config' => "Domain DNS configuration sample:", 'namespace' => "Namespace", 'spf-whitelist' => "SPF Whitelist", 'spf-whitelist-text' => "The Sender Policy Framework allows a sender domain to disclose, through DNS, " . "which systems are allowed to send emails with an envelope sender address within said domain.", 'spf-whitelist-ex' => "Here you can specify a list of allowed servers, for example: .ess.barracuda.com.", 'verify' => "Domain verification", 'verify-intro' => "In order to confirm that you're the actual holder of the domain, we need to run a verification process before finally activating it for email delivery.", 'verify-dns' => "The domain must have one of the following entries in DNS:", 'verify-dns-txt' => "TXT entry with value:", 'verify-dns-cname' => "or CNAME entry:", 'verify-outro' => "When this is done press the button below to start the verification.", 'verify-sample' => "Here's a sample zone file for your domain:", 'config' => "Domain configuration", 'config-intro' => "In order to let {app} receive email traffic for your domain you need to adjust the DNS settings, more precisely the MX entries, accordingly.", 'config-sample' => "Edit your domain's zone file and replace existing MX entries with the following values:", 'config-hint' => "If you don't know how to set DNS entries for your domain, please contact the registration service where you registered the domain or your web hosting provider.", 'create' => "Create domain", 'new' => "New domain", ], 'error' => [ '400' => "Bad request", '401' => "Unauthorized", '403' => "Access denied", '404' => "Not found", '405' => "Method not allowed", '500' => "Internal server error", 'unknown' => "Unknown Error", 'server' => "Server Error", 'form' => "Form validation error", ], 'form' => [ + 'acl' => "Access rights", + 'acl-full' => "All", + 'acl-read-only' => "Read-only", + 'acl-read-write' => "Read-write", 'amount' => "Amount", + 'anyone' => "Anyone", 'code' => "Confirmation Code", 'config' => "Configuration", 'date' => "Date", 'description' => "Description", 'details' => "Details", 'disabled' => "disabled", 'domain' => "Domain", 'email' => "Email Address", 'enabled' => "enabled", 'firstname' => "First Name", 'general' => "General", 'lastname' => "Last Name", 'name' => "Name", 'none' => "none", 'or' => "or", 'password' => "Password", 'password-confirm' => "Confirm Password", 'phone' => "Phone", 'settings' => "Settings", 'shared-folder' => "Shared Folder", 'status' => "Status", 'surname' => "Surname", + 'type' => "Type", 'user' => "User", 'primary-email' => "Primary Email", 'id' => "ID", 'created' => "Created", 'deleted' => "Deleted", ], 'invitation' => [ 'create' => "Create invite(s)", 'create-title' => "Invite for a signup", 'create-email' => "Enter an email address of the person you want to invite.", 'create-csv' => "To send multiple invitations at once, provide a CSV (comma separated) file, or alternatively a plain-text file, containing one email address per line.", 'empty-list' => "There are no invitations in the database.", 'title' => "Signup invitations", 'search' => "Email address or domain", 'send' => "Send invite(s)", 'status-completed' => "User signed up", 'status-failed' => "Sending failed", 'status-sent' => "Sent", 'status-new' => "Not sent yet", ], 'lang' => [ 'en' => "English", 'de' => "German", 'fr' => "French", 'it' => "Italian", ], 'login' => [ '2fa' => "Second factor code", '2fa_desc' => "Second factor code is optional for users with no 2-Factor Authentication setup.", 'forgot_password' => "Forgot password?", 'header' => "Please sign in", 'sign_in' => "Sign in", 'webmail' => "Webmail" ], 'meet' => [ 'title' => "Voice & Video Conferencing", 'welcome' => "Welcome to our beta program for Voice & Video Conferencing.", 'url' => "You have a room of your own at the URL below. This room is only open when you yourself are in attendance. Use this URL to invite people to join you.", 'notice' => "This is a work in progress and more features will be added over time. Current features include:", 'sharing' => "Screen Sharing", 'sharing-text' => "Share your screen for presentations or show-and-tell.", 'security' => "Room Security", 'security-text' => "Increase the room security by setting a password that attendees will need to know" . " before they can enter, or lock the door so attendees will have to knock, and a moderator can accept or deny those requests.", 'qa' => "Raise Hand (Q&A)", 'qa-text' => "Silent audience members can raise their hand to facilitate a Question & Answer session with the panel members.", 'moderation' => "Moderator Delegation", 'moderation-text' => "Delegate moderator authority for the session, so that a speaker is not needlessly" . " interrupted with attendees knocking and other moderator duties.", 'eject' => "Eject Attendees", 'eject-text' => "Eject attendees from the session in order to force them to reconnect, or address policy" . " violations. Click the user icon for effective dismissal.", 'silent' => "Silent Audience Members", 'silent-text' => "For a webinar-style session, configure the room to force all new attendees to be silent audience members.", 'interpreters' => "Language Specific Audio Channels", 'interpreters-text' => "Designate a participant to interpret the original audio to a target language, for sessions" . " with multi-lingual attendees. The interpreter is expected to be able to relay the original audio, and override it.", 'beta-notice' => "Keep in mind that this is still in beta and might come with some issues." . " Should you encounter any on your way, let us know by contacting support.", // Room options dialog 'options' => "Room options", 'password' => "Password", 'password-none' => "none", 'password-clear' => "Clear password", 'password-set' => "Set password", 'password-text' => "You can add a password to your meeting. Participants will have to provide the password before they are allowed to join the meeting.", 'lock' => "Locked room", 'lock-text' => "When the room is locked participants have to be approved by a moderator before they could join the meeting.", 'nomedia' => "Subscribers only", 'nomedia-text' => "Forces all participants to join as subscribers (with camera and microphone turned off)." . " Moderators will be able to promote them to publishers throughout the session.", // Room menu 'partcnt' => "Number of participants", 'menu-audio-mute' => "Mute audio", 'menu-audio-unmute' => "Unmute audio", 'menu-video-mute' => "Mute video", 'menu-video-unmute' => "Unmute video", 'menu-screen' => "Share screen", 'menu-hand-lower' => "Lower hand", 'menu-hand-raise' => "Raise hand", 'menu-channel' => "Interpreted language channel", 'menu-chat' => "Chat", 'menu-fullscreen' => "Full screen", 'menu-fullscreen-exit' => "Exit full screen", 'menu-leave' => "Leave session", // Room setup screen 'setup-title' => "Set up your session", 'mic' => "Microphone", 'cam' => "Camera", 'nick' => "Nickname", 'nick-placeholder' => "Your name", 'join' => "JOIN", 'joinnow' => "JOIN NOW", 'imaowner' => "I'm the owner", // Room 'qa' => "Q & A", 'leave-title' => "Room closed", 'leave-body' => "The session has been closed by the room owner.", 'media-title' => "Media setup", 'join-request' => "Join request", 'join-requested' => "{user} requested to join.", // Status messages 'status-init' => "Checking the room...", 'status-323' => "The room is closed. Please, wait for the owner to start the session.", 'status-324' => "The room is closed. It will be open for others after you join.", 'status-325' => "The room is ready. Please, provide a valid password.", 'status-326' => "The room is locked. Please, enter your name and try again.", 'status-327' => "Waiting for permission to join the room.", 'status-404' => "The room does not exist.", 'status-429' => "Too many requests. Please, wait.", 'status-500' => "Failed to connect to the room. Server error.", // Other menus 'media-setup' => "Media setup", 'perm' => "Permissions", 'perm-av' => "Audio & Video publishing", 'perm-mod' => "Moderation", 'lang-int' => "Language interpreter", 'menu-options' => "Options", ], 'menu' => [ 'cockpit' => "Cockpit", 'login' => "Login", 'logout' => "Logout", 'signup' => "Signup", 'toggle' => "Toggle navigation", ], 'msg' => [ 'initializing' => "Initializing...", 'loading' => "Loading...", 'loading-failed' => "Failed to load data.", 'notfound' => "Resource not found.", 'info' => "Information", 'error' => "Error", 'warning' => "Warning", 'success' => "Success", ], 'nav' => [ 'more' => "Load more", 'step' => "Step {i}/{n}", ], 'password' => [ 'reset' => "Password Reset", 'reset-step1' => "Enter your email address to reset your password.", 'reset-step1-hint' => "You may need to check your spam folder or unblock {email}.", 'reset-step2' => "We sent out a confirmation code to your external email address." . " Enter the code we sent you, or click the link in the message.", ], 'resource' => [ 'create' => "Create resource", 'delete' => "Delete resource", 'invitation-policy' => "Invitation policy", 'invitation-policy-text' => "Event invitations for a resource are normally accepted automatically" . " if there is no conflicting event on the requested time slot. Invitation policy allows" . " for rejecting such requests or to require a manual acceptance from a specified user.", 'ipolicy-manual' => "Manual (tentative)", 'ipolicy-accept' => "Accept", 'ipolicy-reject' => "Reject", 'list-title' => "Resource | Resources", 'list-empty' => "There are no resources in this account.", 'new' => "New resource", ], + 'shf' => [ + 'create' => "Create folder", + 'delete' => "Delete folder", + 'acl-text' => "Defines user permissions to access the shared folder.", + 'list-title' => "Shared folder | Shared folders", + 'list-empty' => "There are no shared folders in this account.", + 'new' => "New shared folder", + 'type-mail' => "Mail", + 'type-event' => "Calendar", + 'type-contact' => "Address Book", + 'type-task' => "Tasks", + 'type-note' => "Notes", + 'type-file' => "Files", + ], + 'signup' => [ 'email' => "Existing Email Address", 'login' => "Login", 'title' => "Sign Up", 'step1' => "Sign up to start your free month.", 'step2' => "We sent out a confirmation code to your email address. Enter the code we sent you, or click the link in the message.", 'step3' => "Create your Kolab identity (you can choose additional addresses later).", 'voucher' => "Voucher Code", ], 'status' => [ 'prepare-account' => "We are preparing your account.", 'prepare-domain' => "We are preparing the domain.", 'prepare-distlist' => "We are preparing the distribution list.", 'prepare-resource' => "We are preparing the resource.", + 'prepare-shared-folder' => "We are preparing the shared folder.", 'prepare-user' => "We are preparing the user account.", 'prepare-hint' => "Some features may be missing or readonly at the moment.", 'prepare-refresh' => "The process never ends? Press the \"Refresh\" button, please.", 'ready-account' => "Your account is almost ready.", 'ready-domain' => "The domain is almost ready.", 'ready-distlist' => "The distribution list is almost ready.", 'ready-resource' => "The resource is almost ready.", + 'ready-shared-folder' => "The shared-folder is almost ready.", 'ready-user' => "The user account is almost ready.", 'verify' => "Verify your domain to finish the setup process.", 'verify-domain' => "Verify domain", 'deleted' => "Deleted", 'suspended' => "Suspended", 'notready' => "Not Ready", 'active' => "Active", ], 'support' => [ 'title' => "Contact Support", 'id' => "Customer number or email address you have with us", 'id-pl' => "e.g. 12345678 or john@kolab.org", 'id-hint' => "Leave blank if you are not a customer yet", 'name' => "Name", 'name-pl' => "how we should call you in our reply", 'email' => "Working email address", 'email-pl' => "make sure we can reach you at this address", 'summary' => "Issue Summary", 'summary-pl' => "one sentence that summarizes your issue", 'expl' => "Issue Explanation", ], 'user' => [ '2fa-hint1' => "This will remove 2-Factor Authentication entitlement as well as the user-configured factors.", '2fa-hint2' => "Please, make sure to confirm the user identity properly.", 'add-beta' => "Enable beta program", 'address' => "Address", 'aliases' => "Aliases", 'aliases-email' => "Email Aliases", 'aliases-none' => "This user has no email aliases.", 'add-bonus' => "Add bonus", 'add-bonus-title' => "Add a bonus to the wallet", 'add-penalty' => "Add penalty", 'add-penalty-title' => "Add a penalty to the wallet", 'auto-payment' => "Auto-payment", 'auto-payment-text' => "Fill up by {amount} when under {balance} using {method}", 'country' => "Country", 'create' => "Create user", 'custno' => "Customer No.", 'delete' => "Delete user", 'delete-account' => "Delete this account?", 'delete-email' => "Delete {email}", 'delete-text' => "Do you really want to delete this user permanently?" . " This will delete all account data and withdraw the permission to access the email account." . " Please note that this action cannot be undone.", 'discount' => "Discount", 'discount-hint' => "applied discount", 'discount-title' => "Account discount", 'distlists' => "Distribution lists", 'domains' => "Domains", 'domains-none' => "There are no domains in this account.", 'ext-email' => "External Email", 'finances' => "Finances", 'greylisting' => "Greylisting", 'greylisting-text' => "Greylisting is a method of defending users against spam. Any incoming mail from an unrecognized sender " . "is temporarily rejected. The originating server should try again after a delay. " . "This time the email will be accepted. Spammers usually do not reattempt mail delivery.", 'list-title' => "User accounts", 'managed-by' => "Managed by", 'new' => "New user account", 'org' => "Organization", 'package' => "Package", 'price' => "Price", 'profile-title' => "Your profile", 'profile-delete' => "Delete account", 'profile-delete-title' => "Delete this account?", 'profile-delete-text1' => "This will delete the account as well as all domains, users and aliases associated with this account.", 'profile-delete-warning' => "This operation is irreversible", 'profile-delete-text2' => "As you will not be able to recover anything after this point, please make sure that you have migrated all data before proceeding.", 'profile-delete-support' => "As we always strive to improve, we would like to ask for 2 minutes of your time. " . "The best tool for improvement is feedback from users, and we would like to ask " . "for a few words about your reasons for leaving our service. Please send your feedback to {email}.", 'profile-delete-contact' => "Also feel free to contact {app} Support with any questions or concerns that you may have in this context.", 'reset-2fa' => "Reset 2-Factor Auth", 'reset-2fa-title' => "2-Factor Authentication Reset", 'resources' => "Resources", 'title' => "User account", 'search' => "User email address or name", 'search-pl' => "User ID, email or domain", 'skureq' => "{sku} requires {list}.", 'subscription' => "Subscription", 'subscriptions' => "Subscriptions", 'subscriptions-none' => "This user has no subscriptions.", 'users' => "Users", 'users-none' => "There are no users in this account.", ], 'wallet' => [ 'add-credit' => "Add credit", 'auto-payment-cancel' => "Cancel auto-payment", 'auto-payment-change' => "Change auto-payment", 'auto-payment-failed' => "The setup of automatic payments failed. Restart the process to enable automatic top-ups.", 'auto-payment-hint' => "Here is how it works: Every time your account runs low, we will charge your preferred payment method for an amount you choose." . " You can cancel or change the auto-payment option at any time.", 'auto-payment-setup' => "Set up auto-payment", 'auto-payment-disabled' => "The configured auto-payment has been disabled. Top up your wallet or raise the auto-payment amount.", 'auto-payment-info' => "Auto-payment is set to fill up your account by {amount} every time your account balance gets under {balance}.", 'auto-payment-inprogress' => "The setup of the automatic payment is still in progress.", 'auto-payment-next' => "Next, you will be redirected to the checkout page, where you can provide your credit card details.", 'auto-payment-disabled-next' => "The auto-payment is disabled. Immediately after you submit new settings we'll enable it and attempt to top up your wallet.", 'auto-payment-update' => "Update auto-payment", 'banktransfer-hint' => "Please note that a bank transfer can take several days to complete.", 'currency-conv' => "Here is how it works: You specify the amount by which you want to top up your wallet in {wc}." . " We will then convert this to {pc}, and on the next page you will be provided with the bank-details to transfer the amount in {pc}.", 'fill-up' => "Fill up by", 'history' => "History", 'month' => "month", 'noperm' => "Only account owners can access a wallet.", 'payment-amount-hint' => "Choose the amount by which you want to top up your wallet.", 'payment-method' => "Method of payment: {method}", 'payment-warning' => "You will be charged for {price}.", 'pending-payments' => "Pending Payments", 'pending-payments-warning' => "You have payments that are still in progress. See the \"Pending Payments\" tab below.", 'pending-payments-none' => "There are no pending payments for this account.", 'receipts' => "Receipts", 'receipts-hint' => "Here you can download receipts (in PDF format) for payments in specified period. Select the period and press the Download button.", 'receipts-none' => "There are no receipts for payments in this account. Please, note that you can download receipts after the month ends.", 'title' => "Account balance", 'top-up' => "Top up your wallet", 'transactions' => "Transactions", 'transactions-none' => "There are no transactions for this account.", 'when-below' => "when account balance is below", ], ]; diff --git a/src/resources/lang/en/validation.php b/src/resources/lang/en/validation.php index 178d2924..761dda11 100644 --- a/src/resources/lang/en/validation.php +++ b/src/resources/lang/en/validation.php @@ -1,177 +1,178 @@ 'The :attribute must be accepted.', 'active_url' => 'The :attribute is not a valid URL.', 'after' => 'The :attribute must be a date after :date.', 'after_or_equal' => 'The :attribute must be a date after or equal to :date.', 'alpha' => 'The :attribute may only contain letters.', 'alpha_dash' => 'The :attribute may only contain letters, numbers, dashes and underscores.', 'alpha_num' => 'The :attribute may only contain letters and numbers.', 'array' => 'The :attribute must be an array.', 'before' => 'The :attribute must be a date before :date.', 'before_or_equal' => 'The :attribute must be a date before or equal to :date.', 'between' => [ 'numeric' => 'The :attribute must be between :min and :max.', 'file' => 'The :attribute must be between :min and :max kilobytes.', 'string' => 'The :attribute must be between :min and :max characters.', 'array' => 'The :attribute must have between :min and :max items.', ], 'boolean' => 'The :attribute field must be true or false.', 'confirmed' => 'The :attribute confirmation does not match.', 'date' => 'The :attribute is not a valid date.', 'date_equals' => 'The :attribute must be a date equal to :date.', 'date_format' => 'The :attribute does not match the format :format.', 'different' => 'The :attribute and :other must be different.', 'digits' => 'The :attribute must be :digits digits.', 'digits_between' => 'The :attribute must be between :min and :max digits.', 'dimensions' => 'The :attribute has invalid image dimensions.', 'distinct' => 'The :attribute field has a duplicate value.', 'email' => 'The :attribute must be a valid email address.', 'ends_with' => 'The :attribute must end with one of the following: :values', 'exists' => 'The selected :attribute is invalid.', 'file' => 'The :attribute must be a file.', 'filled' => 'The :attribute field must have a value.', 'gt' => [ 'numeric' => 'The :attribute must be greater than :value.', 'file' => 'The :attribute must be greater than :value kilobytes.', 'string' => 'The :attribute must be greater than :value characters.', 'array' => 'The :attribute must have more than :value items.', ], 'gte' => [ 'numeric' => 'The :attribute must be greater than or equal :value.', 'file' => 'The :attribute must be greater than or equal :value kilobytes.', 'string' => 'The :attribute must be greater than or equal :value characters.', 'array' => 'The :attribute must have :value items or more.', ], 'image' => 'The :attribute must be an image.', 'in' => 'The selected :attribute is invalid.', 'in_array' => 'The :attribute field does not exist in :other.', 'integer' => 'The :attribute must be an integer.', 'ip' => 'The :attribute must be a valid IP address.', 'ipv4' => 'The :attribute must be a valid IPv4 address.', 'ipv6' => 'The :attribute must be a valid IPv6 address.', 'json' => 'The :attribute must be a valid JSON string.', 'lt' => [ 'numeric' => 'The :attribute must be less than :value.', 'file' => 'The :attribute must be less than :value kilobytes.', 'string' => 'The :attribute must be less than :value characters.', 'array' => 'The :attribute must have less than :value items.', ], 'lte' => [ 'numeric' => 'The :attribute must be less than or equal :value.', 'file' => 'The :attribute must be less than or equal :value kilobytes.', 'string' => 'The :attribute must be less than or equal :value characters.', 'array' => 'The :attribute must not have more than :value items.', ], 'max' => [ 'numeric' => 'The :attribute may not be greater than :max.', 'file' => 'The :attribute may not be greater than :max kilobytes.', 'string' => 'The :attribute may not be greater than :max characters.', 'array' => 'The :attribute may not have more than :max items.', ], 'mimes' => 'The :attribute must be a file of type: :values.', 'mimetypes' => 'The :attribute must be a file of type: :values.', 'min' => [ 'numeric' => 'The :attribute must be at least :min.', 'file' => 'The :attribute must be at least :min kilobytes.', 'string' => 'The :attribute must be at least :min characters.', 'array' => 'The :attribute must have at least :min items.', ], 'not_in' => 'The selected :attribute is invalid.', 'not_regex' => 'The :attribute format is invalid.', 'numeric' => 'The :attribute must be a number.', 'present' => 'The :attribute field must be present.', 'regex' => 'The :attribute format is invalid.', 'required' => 'The :attribute field is required.', 'required_if' => 'The :attribute field is required when :other is :value.', 'required_unless' => 'The :attribute field is required unless :other is in :values.', 'required_with' => 'The :attribute field is required when :values is present.', 'required_with_all' => 'The :attribute field is required when :values are present.', 'required_without' => 'The :attribute field is required when :values is not present.', 'required_without_all' => 'The :attribute field is required when none of :values are present.', 'same' => 'The :attribute and :other must match.', 'size' => [ 'numeric' => 'The :attribute must be :size.', 'file' => 'The :attribute must be :size kilobytes.', 'string' => 'The :attribute must be :size characters.', 'array' => 'The :attribute must contain :size items.', ], 'starts_with' => 'The :attribute must start with one of the following: :values', 'string' => 'The :attribute must be a string.', 'timezone' => 'The :attribute must be a valid zone.', 'unique' => 'The :attribute has already been taken.', 'uploaded' => 'The :attribute failed to upload.', 'url' => 'The :attribute format is invalid.', 'uuid' => 'The :attribute must be a valid UUID.', '2fareq' => 'Second factor code is required.', '2fainvalid' => 'Second factor code is invalid.', 'emailinvalid' => 'The specified email address is invalid.', 'domaininvalid' => 'The specified domain is invalid.', 'domainnotavailable' => 'The specified domain is not available.', 'logininvalid' => 'The specified login is invalid.', 'loginexists' => 'The specified login is not available.', 'domainexists' => 'The specified domain is not available.', 'noemailorphone' => 'The specified text is neither a valid email address nor a phone number.', 'packageinvalid' => 'Invalid package selected.', 'packagerequired' => 'Package is required.', 'usernotexists' => 'Unable to find user.', 'voucherinvalid' => 'The voucher code is invalid or expired.', 'noextemail' => 'This user has no external email address.', 'entryinvalid' => 'The specified :attribute is invalid.', 'entryexists' => 'The specified :attribute is not available.', 'minamount' => 'Minimum amount for a single payment is :amount.', 'minamountdebt' => 'The specified amount does not cover the balance on the account.', 'notalocaluser' => 'The specified email address does not exist.', 'memberislist' => 'A recipient cannot be the same as the list address.', 'listmembersrequired' => 'At least one recipient is required.', 'spf-entry-invalid' => 'The entry format is invalid. Expected a domain name starting with a dot.', 'sp-entry-invalid' => 'The entry format is invalid. Expected an email, domain, or part of it.', + 'acl-entry-invalid' => 'The entry format is invalid. Expected an email address.', 'ipolicy-invalid' => 'The specified invitation policy is invalid.', 'invalid-config-parameter' => 'The requested configuration parameter is not supported.', 'nameexists' => 'The specified name is not available.', 'nameinvalid' => 'The specified name is invalid.', /* |-------------------------------------------------------------------------- | Custom Validation Language Lines |-------------------------------------------------------------------------- | | Here you may specify custom validation messages for attributes using the | convention "attribute.rule" to name the lines. This makes it quick to | specify a specific custom language line for a given attribute rule. | */ 'custom' => [ 'attribute-name' => [ 'rule-name' => 'custom-message', ], ], /* |-------------------------------------------------------------------------- | Custom Validation Attributes |-------------------------------------------------------------------------- | | The following language lines are used to swap our attribute placeholder | with something more reader friendly such as "E-Mail Address" instead | of "email". This simply helps us make our message more expressive. | */ 'attributes' => [], ]; diff --git a/src/resources/themes/forms.scss b/src/resources/themes/forms.scss index ded08e86..de5b08eb 100644 --- a/src/resources/themes/forms.scss +++ b/src/resources/themes/forms.scss @@ -1,147 +1,154 @@ .list-input { & > div { &:not(:last-child) { margin-bottom: -1px; input, a.btn { border-bottom-right-radius: 0; border-bottom-left-radius: 0; } } &:not(:first-child) { input, a.btn { border-top-right-radius: 0; border-top-left-radius: 0; } } } input.is-invalid { z-index: 2; } .btn svg { vertical-align: middle; } } +.acl-input { + select.acl, + select.mod-user { + max-width: fit-content; + } +} + .range-input { display: flex; label { margin-right: 0.5em; min-width: 4em; text-align: right; line-height: 1.7; } } .input-group-activable { &.active { :not(.activable) { display: none; } } &:not(.active) { .activable { display: none; } } // Label is always visible .label { color: $body-color; display: initial !important; } .input-group-text { border-color: transparent; background: transparent; padding-left: 0; &:not(.label) { flex: 1; } } } // An input group with a select and input, where input is displayed // only for some select values .input-group-select { &:not(.selected) { input { display: none; } select { border-bottom-right-radius: .25rem !important; border-top-right-radius: .25rem !important; } } input { border-bottom-right-radius: .25rem !important; border-top-right-radius: .25rem !important; } } .form-control-plaintext .btn-sm { margin-top: -0.25rem; } // Various improvements for mobile @include media-breakpoint-down(sm) { .row.mb-3 { margin-bottom: 0.5rem !important; } .nav-tabs { .nav-link { white-space: nowrap; padding: 0.5rem 0.75rem; } } .tab-content { margin-top: 0.5rem; } .col-form-label { color: #666; font-size: 95%; } .row.plaintext .col-form-label { padding-bottom: 0; } form.read-only.short label { width: 35%; & + * { width: 65%; } } .row.checkbox { position: relative; & > div { padding-top: 0 !important; input { position: absolute; top: 0.5rem; right: 1rem; } } label { padding-right: 2.5rem; } } } diff --git a/src/resources/vue/Admin/SharedFolder.vue b/src/resources/vue/Admin/SharedFolder.vue new file mode 100644 index 00000000..d712c284 --- /dev/null +++ b/src/resources/vue/Admin/SharedFolder.vue @@ -0,0 +1,91 @@ + + + diff --git a/src/resources/vue/Admin/User.vue b/src/resources/vue/Admin/User.vue index 58e16101..98612cad 100644 --- a/src/resources/vue/Admin/User.vue +++ b/src/resources/vue/Admin/User.vue @@ -1,800 +1,842 @@ diff --git a/src/resources/vue/Dashboard.vue b/src/resources/vue/Dashboard.vue index e0246a25..9ac9c1a5 100644 --- a/src/resources/vue/Dashboard.vue +++ b/src/resources/vue/Dashboard.vue @@ -1,71 +1,74 @@ diff --git a/src/resources/vue/Resource/Info.vue b/src/resources/vue/Resource/Info.vue index a0925800..e2d28eeb 100644 --- a/src/resources/vue/Resource/Info.vue +++ b/src/resources/vue/Resource/Info.vue @@ -1,189 +1,189 @@ diff --git a/src/resources/vue/Resource/Info.vue b/src/resources/vue/SharedFolder/Info.vue similarity index 56% copy from src/resources/vue/Resource/Info.vue copy to src/resources/vue/SharedFolder/Info.vue index a0925800..706c6467 100644 --- a/src/resources/vue/Resource/Info.vue +++ b/src/resources/vue/SharedFolder/Info.vue @@ -1,189 +1,177 @@ diff --git a/src/resources/vue/SharedFolder/List.vue b/src/resources/vue/SharedFolder/List.vue new file mode 100644 index 00000000..8553210f --- /dev/null +++ b/src/resources/vue/SharedFolder/List.vue @@ -0,0 +1,60 @@ + + + diff --git a/src/resources/vue/Widgets/AclInput.vue b/src/resources/vue/Widgets/AclInput.vue new file mode 100644 index 00000000..6e03a965 --- /dev/null +++ b/src/resources/vue/Widgets/AclInput.vue @@ -0,0 +1,118 @@ + + + diff --git a/src/resources/vue/Widgets/Status.vue b/src/resources/vue/Widgets/Status.vue index 9d4043dc..5c33688f 100644 --- a/src/resources/vue/Widgets/Status.vue +++ b/src/resources/vue/Widgets/Status.vue @@ -1,203 +1,198 @@ diff --git a/src/routes/api.php b/src/routes/api.php index 51c83fe1..5459a435 100644 --- a/src/routes/api.php +++ b/src/routes/api.php @@ -1,257 +1,263 @@ 'api', 'prefix' => $prefix . 'api/auth' ], function ($router) { Route::post('login', 'API\AuthController@login'); Route::group( ['middleware' => 'auth:api'], function ($router) { Route::get('info', 'API\AuthController@info'); Route::post('info', 'API\AuthController@info'); Route::post('logout', 'API\AuthController@logout'); Route::post('refresh', 'API\AuthController@refresh'); } ); } ); Route::group( [ 'domain' => \config('app.website_domain'), 'middleware' => 'api', 'prefix' => $prefix . 'api/auth' ], function ($router) { Route::post('password-reset/init', 'API\PasswordResetController@init'); Route::post('password-reset/verify', 'API\PasswordResetController@verify'); Route::post('password-reset', 'API\PasswordResetController@reset'); Route::post('signup/init', 'API\SignupController@init'); Route::get('signup/invitations/{id}', 'API\SignupController@invitation'); Route::get('signup/plans', 'API\SignupController@plans'); Route::post('signup/verify', 'API\SignupController@verify'); Route::post('signup', 'API\SignupController@signup'); } ); Route::group( [ 'domain' => \config('app.website_domain'), 'middleware' => 'auth:api', 'prefix' => $prefix . 'api/v4' ], function () { Route::post('companion/register', 'API\V4\CompanionAppsController@register'); Route::post('auth-attempts/{id}/confirm', 'API\V4\AuthAttemptsController@confirm'); Route::post('auth-attempts/{id}/deny', 'API\V4\AuthAttemptsController@deny'); Route::get('auth-attempts/{id}/details', 'API\V4\AuthAttemptsController@details'); Route::get('auth-attempts', 'API\V4\AuthAttemptsController@index'); Route::apiResource('domains', API\V4\DomainsController::class); Route::get('domains/{id}/confirm', 'API\V4\DomainsController@confirm'); Route::get('domains/{id}/skus', 'API\V4\SkusController@domainSkus'); Route::get('domains/{id}/status', 'API\V4\DomainsController@status'); Route::post('domains/{id}/config', 'API\V4\DomainsController@setConfig'); Route::apiResource('groups', API\V4\GroupsController::class); Route::get('groups/{id}/status', 'API\V4\GroupsController@status'); Route::post('groups/{id}/config', 'API\V4\GroupsController@setConfig'); Route::apiResource('packages', API\V4\PackagesController::class); Route::apiResource('resources', API\V4\ResourcesController::class); Route::get('resources/{id}/status', 'API\V4\ResourcesController@status'); Route::post('resources/{id}/config', 'API\V4\ResourcesController@setConfig'); + Route::apiResource('shared-folders', API\V4\SharedFoldersController::class); + Route::get('shared-folders/{id}/status', 'API\V4\SharedFoldersController@status'); + Route::post('shared-folders/{id}/config', 'API\V4\SharedFoldersController@setConfig'); + Route::apiResource('skus', API\V4\SkusController::class); Route::apiResource('users', API\V4\UsersController::class); Route::post('users/{id}/config', 'API\V4\UsersController@setConfig'); Route::get('users/{id}/skus', 'API\V4\SkusController@userSkus'); Route::get('users/{id}/status', 'API\V4\UsersController@status'); Route::apiResource('wallets', API\V4\WalletsController::class); Route::get('wallets/{id}/transactions', 'API\V4\WalletsController@transactions'); Route::get('wallets/{id}/receipts', 'API\V4\WalletsController@receipts'); Route::get('wallets/{id}/receipts/{receipt}', 'API\V4\WalletsController@receiptDownload'); Route::post('payments', 'API\V4\PaymentsController@store'); //Route::delete('payments', 'API\V4\PaymentsController@cancel'); Route::get('payments/mandate', 'API\V4\PaymentsController@mandate'); Route::post('payments/mandate', 'API\V4\PaymentsController@mandateCreate'); Route::put('payments/mandate', 'API\V4\PaymentsController@mandateUpdate'); Route::delete('payments/mandate', 'API\V4\PaymentsController@mandateDelete'); Route::get('payments/methods', 'API\V4\PaymentsController@paymentMethods'); Route::get('payments/pending', 'API\V4\PaymentsController@payments'); Route::get('payments/has-pending', 'API\V4\PaymentsController@hasPayments'); Route::get('openvidu/rooms', 'API\V4\OpenViduController@index'); Route::post('openvidu/rooms/{id}/close', 'API\V4\OpenViduController@closeRoom'); Route::post('openvidu/rooms/{id}/config', 'API\V4\OpenViduController@setRoomConfig'); // FIXME: I'm not sure about this one, should we use DELETE request maybe? Route::post('openvidu/rooms/{id}/connections/{conn}/dismiss', 'API\V4\OpenViduController@dismissConnection'); Route::put('openvidu/rooms/{id}/connections/{conn}', 'API\V4\OpenViduController@updateConnection'); Route::post('openvidu/rooms/{id}/request/{reqid}/accept', 'API\V4\OpenViduController@acceptJoinRequest'); Route::post('openvidu/rooms/{id}/request/{reqid}/deny', 'API\V4\OpenViduController@denyJoinRequest'); } ); // Note: In Laravel 7.x we could just use withoutMiddleware() instead of a separate group Route::group( [ 'domain' => \config('app.website_domain'), 'prefix' => $prefix . 'api/v4' ], function () { Route::post('openvidu/rooms/{id}', 'API\V4\OpenViduController@joinRoom'); Route::post('openvidu/rooms/{id}/connections', 'API\V4\OpenViduController@createConnection'); // FIXME: I'm not sure about this one, should we use DELETE request maybe? Route::post('openvidu/rooms/{id}/connections/{conn}/dismiss', 'API\V4\OpenViduController@dismissConnection'); Route::put('openvidu/rooms/{id}/connections/{conn}', 'API\V4\OpenViduController@updateConnection'); Route::post('openvidu/rooms/{id}/request/{reqid}/accept', 'API\V4\OpenViduController@acceptJoinRequest'); Route::post('openvidu/rooms/{id}/request/{reqid}/deny', 'API\V4\OpenViduController@denyJoinRequest'); } ); Route::group( [ 'domain' => \config('app.website_domain'), 'middleware' => 'api', 'prefix' => $prefix . 'api/v4' ], function ($router) { Route::post('support/request', 'API\V4\SupportController@request'); } ); Route::group( [ 'domain' => \config('app.website_domain'), 'prefix' => $prefix . 'api/webhooks' ], function () { Route::post('payment/{provider}', 'API\V4\PaymentsController@webhook'); Route::post('meet/openvidu', 'API\V4\OpenViduController@webhook'); } ); if (\config('app.with_services')) { Route::group( [ 'domain' => 'services.' . \config('app.website_domain'), 'prefix' => $prefix . 'api/webhooks' ], function () { Route::get('nginx', 'API\V4\NGINXController@authenticate'); Route::post('policy/greylist', 'API\V4\PolicyController@greylist'); Route::post('policy/ratelimit', 'API\V4\PolicyController@ratelimit'); Route::post('policy/spf', 'API\V4\PolicyController@senderPolicyFramework'); } ); } if (\config('app.with_admin')) { Route::group( [ 'domain' => 'admin.' . \config('app.website_domain'), 'middleware' => ['auth:api', 'admin'], 'prefix' => $prefix . 'api/v4', ], function () { Route::apiResource('domains', API\V4\Admin\DomainsController::class); Route::get('domains/{id}/skus', 'API\V4\Admin\SkusController@domainSkus'); Route::post('domains/{id}/suspend', 'API\V4\Admin\DomainsController@suspend'); Route::post('domains/{id}/unsuspend', 'API\V4\Admin\DomainsController@unsuspend'); Route::apiResource('groups', API\V4\Admin\GroupsController::class); Route::post('groups/{id}/suspend', 'API\V4\Admin\GroupsController@suspend'); Route::post('groups/{id}/unsuspend', 'API\V4\Admin\GroupsController@unsuspend'); Route::apiResource('resources', API\V4\Admin\ResourcesController::class); + Route::apiResource('shared-folders', API\V4\Admin\SharedFoldersController::class); Route::apiResource('skus', API\V4\Admin\SkusController::class); Route::apiResource('users', API\V4\Admin\UsersController::class); Route::get('users/{id}/discounts', 'API\V4\Reseller\DiscountsController@userDiscounts'); Route::post('users/{id}/reset2FA', 'API\V4\Admin\UsersController@reset2FA'); Route::get('users/{id}/skus', 'API\V4\Admin\SkusController@userSkus'); Route::post('users/{id}/skus/{sku}', 'API\V4\Admin\UsersController@setSku'); Route::post('users/{id}/suspend', 'API\V4\Admin\UsersController@suspend'); Route::post('users/{id}/unsuspend', 'API\V4\Admin\UsersController@unsuspend'); Route::apiResource('wallets', API\V4\Admin\WalletsController::class); Route::post('wallets/{id}/one-off', 'API\V4\Admin\WalletsController@oneOff'); Route::get('wallets/{id}/transactions', 'API\V4\Admin\WalletsController@transactions'); Route::get('stats/chart/{chart}', 'API\V4\Admin\StatsController@chart'); } ); } if (\config('app.with_reseller')) { Route::group( [ 'domain' => 'reseller.' . \config('app.website_domain'), 'middleware' => ['auth:api', 'reseller'], 'prefix' => $prefix . 'api/v4', ], function () { Route::apiResource('domains', API\V4\Reseller\DomainsController::class); Route::get('domains/{id}/skus', 'API\V4\Reseller\SkusController@domainSkus'); Route::post('domains/{id}/suspend', 'API\V4\Reseller\DomainsController@suspend'); Route::post('domains/{id}/unsuspend', 'API\V4\Reseller\DomainsController@unsuspend'); Route::apiResource('groups', API\V4\Reseller\GroupsController::class); Route::post('groups/{id}/suspend', 'API\V4\Reseller\GroupsController@suspend'); Route::post('groups/{id}/unsuspend', 'API\V4\Reseller\GroupsController@unsuspend'); Route::apiResource('invitations', API\V4\Reseller\InvitationsController::class); Route::post('invitations/{id}/resend', 'API\V4\Reseller\InvitationsController@resend'); Route::post('payments', 'API\V4\Reseller\PaymentsController@store'); Route::get('payments/mandate', 'API\V4\Reseller\PaymentsController@mandate'); Route::post('payments/mandate', 'API\V4\Reseller\PaymentsController@mandateCreate'); Route::put('payments/mandate', 'API\V4\Reseller\PaymentsController@mandateUpdate'); Route::delete('payments/mandate', 'API\V4\Reseller\PaymentsController@mandateDelete'); Route::get('payments/methods', 'API\V4\Reseller\PaymentsController@paymentMethods'); Route::get('payments/pending', 'API\V4\Reseller\PaymentsController@payments'); Route::get('payments/has-pending', 'API\V4\Reseller\PaymentsController@hasPayments'); Route::apiResource('resources', API\V4\Reseller\ResourcesController::class); + Route::apiResource('shared-folders', API\V4\Reseller\SharedFoldersController::class); Route::apiResource('skus', API\V4\Reseller\SkusController::class); Route::apiResource('users', API\V4\Reseller\UsersController::class); Route::get('users/{id}/discounts', 'API\V4\Reseller\DiscountsController@userDiscounts'); Route::post('users/{id}/reset2FA', 'API\V4\Reseller\UsersController@reset2FA'); Route::get('users/{id}/skus', 'API\V4\Reseller\SkusController@userSkus'); Route::post('users/{id}/skus/{sku}', 'API\V4\Admin\UsersController@setSku'); Route::post('users/{id}/suspend', 'API\V4\Reseller\UsersController@suspend'); Route::post('users/{id}/unsuspend', 'API\V4\Reseller\UsersController@unsuspend'); Route::apiResource('wallets', API\V4\Reseller\WalletsController::class); Route::post('wallets/{id}/one-off', 'API\V4\Reseller\WalletsController@oneOff'); Route::get('wallets/{id}/receipts', 'API\V4\Reseller\WalletsController@receipts'); Route::get('wallets/{id}/receipts/{receipt}', 'API\V4\Reseller\WalletsController@receiptDownload'); Route::get('wallets/{id}/transactions', 'API\V4\Reseller\WalletsController@transactions'); Route::get('stats/chart/{chart}', 'API\V4\Reseller\StatsController@chart'); } ); } diff --git a/src/tests/Browser/Admin/ResourceTest.php b/src/tests/Browser/Admin/ResourceTest.php index 9016af53..4e914410 100644 --- a/src/tests/Browser/Admin/ResourceTest.php +++ b/src/tests/Browser/Admin/ResourceTest.php @@ -1,95 +1,98 @@ browse(function (Browser $browser) { $user = $this->getTestUser('john@kolab.org'); $resource = $this->getTestResource('resource-test1@kolab.org'); $browser->visit('/resource/' . $resource->id)->on(new Home()); }); } /** * Test resource info page */ public function testInfo(): void { Queue::fake(); $this->browse(function (Browser $browser) { $user = $this->getTestUser('john@kolab.org'); $resource = $this->getTestResource('resource-test1@kolab.org'); $resource->setSetting('invitation_policy', 'accept'); + $resource->status = Resource::STATUS_NEW | Resource::STATUS_ACTIVE + | Resource::STATUS_LDAP_READY | Resource::STATUS_IMAP_READY; + $resource->save(); $resource_page = new ResourcePage($resource->id); $user_page = new UserPage($user->id); // Goto the resource page $browser->visit(new Home()) ->submitLogon('jeroen@jeroen.jeroen', \App\Utils::generatePassphrase(), true) ->on(new Dashboard()) ->visit($user_page) ->on($user_page) ->click('@nav #tab-resources') ->pause(1000) ->click('@user-resources table tbody tr:first-child td:first-child a') ->on($resource_page) ->assertSeeIn('@resource-info .card-title', $resource->email) ->with('@resource-info form', function (Browser $browser) use ($resource) { $browser->assertElementsCount('.row', 3) ->assertSeeIn('.row:nth-child(1) label', 'ID (Created)') ->assertSeeIn('.row:nth-child(1) #resourceid', "{$resource->id} ({$resource->created_at})") ->assertSeeIn('.row:nth-child(2) label', 'Status') ->assertSeeIn('.row:nth-child(2) #status.text-success', 'Active') ->assertSeeIn('.row:nth-child(3) label', 'Name') ->assertSeeIn('.row:nth-child(3) #name', $resource->name); }) ->assertElementsCount('ul.nav-tabs', 1) ->assertSeeIn('ul.nav-tabs .nav-link', 'Settings') ->with('@resource-settings form', function (Browser $browser) { $browser->assertElementsCount('.row', 1) ->assertSeeIn('.row:nth-child(1) label', 'Invitation policy') ->assertSeeIn('.row:nth-child(1) #invitation_policy', 'accept'); }); // Test invalid resource identifier $browser->visit('/resource/abc')->assertErrorPage(404); }); } } diff --git a/src/tests/Browser/Admin/ResourceTest.php b/src/tests/Browser/Admin/SharedFolderTest.php similarity index 50% copy from src/tests/Browser/Admin/ResourceTest.php copy to src/tests/Browser/Admin/SharedFolderTest.php index 9016af53..7e1dcb93 100644 --- a/src/tests/Browser/Admin/ResourceTest.php +++ b/src/tests/Browser/Admin/SharedFolderTest.php @@ -1,95 +1,101 @@ browse(function (Browser $browser) { $user = $this->getTestUser('john@kolab.org'); - $resource = $this->getTestResource('resource-test1@kolab.org'); + $folder = $this->getTestSharedFolder('folder-event@kolab.org'); - $browser->visit('/resource/' . $resource->id)->on(new Home()); + $browser->visit('/shared-folder/' . $folder->id)->on(new Home()); }); } /** - * Test resource info page + * Test shared folder info page */ public function testInfo(): void { Queue::fake(); $this->browse(function (Browser $browser) { $user = $this->getTestUser('john@kolab.org'); - $resource = $this->getTestResource('resource-test1@kolab.org'); - $resource->setSetting('invitation_policy', 'accept'); + $folder = $this->getTestSharedFolder('folder-event@kolab.org'); + $folder->setConfig(['acl' => ['anyone, read-only', 'jack@kolab.org, read-write']]); + $folder->status = SharedFolder::STATUS_NEW | SharedFolder::STATUS_ACTIVE + | SharedFolder::STATUS_LDAP_READY | SharedFolder::STATUS_IMAP_READY; + $folder->save(); - $resource_page = new ResourcePage($resource->id); + $folder_page = new SharedFolderPage($folder->id); $user_page = new UserPage($user->id); - // Goto the resource page + // Goto the folder page $browser->visit(new Home()) ->submitLogon('jeroen@jeroen.jeroen', \App\Utils::generatePassphrase(), true) ->on(new Dashboard()) ->visit($user_page) ->on($user_page) - ->click('@nav #tab-resources') + ->click('@nav #tab-shared-folders') ->pause(1000) - ->click('@user-resources table tbody tr:first-child td:first-child a') - ->on($resource_page) - ->assertSeeIn('@resource-info .card-title', $resource->email) - ->with('@resource-info form', function (Browser $browser) use ($resource) { - $browser->assertElementsCount('.row', 3) + ->click('@user-shared-folders table tbody tr:first-child td:first-child a') + ->on($folder_page) + ->assertSeeIn('@folder-info .card-title', $folder->email) + ->with('@folder-info form', function (Browser $browser) use ($folder) { + $browser->assertElementsCount('.row', 4) ->assertSeeIn('.row:nth-child(1) label', 'ID (Created)') - ->assertSeeIn('.row:nth-child(1) #resourceid', "{$resource->id} ({$resource->created_at})") + ->assertSeeIn('.row:nth-child(1) #folderid', "{$folder->id} ({$folder->created_at})") ->assertSeeIn('.row:nth-child(2) label', 'Status') ->assertSeeIn('.row:nth-child(2) #status.text-success', 'Active') ->assertSeeIn('.row:nth-child(3) label', 'Name') - ->assertSeeIn('.row:nth-child(3) #name', $resource->name); + ->assertSeeIn('.row:nth-child(3) #name', $folder->name) + ->assertSeeIn('.row:nth-child(4) label', 'Type') + ->assertSeeIn('.row:nth-child(4) #type', 'Calendar'); }) ->assertElementsCount('ul.nav-tabs', 1) ->assertSeeIn('ul.nav-tabs .nav-link', 'Settings') - ->with('@resource-settings form', function (Browser $browser) { + ->with('@folder-settings form', function (Browser $browser) { $browser->assertElementsCount('.row', 1) - ->assertSeeIn('.row:nth-child(1) label', 'Invitation policy') - ->assertSeeIn('.row:nth-child(1) #invitation_policy', 'accept'); + ->assertSeeIn('.row:nth-child(1) label', 'Access rights') + ->assertSeeIn('.row:nth-child(1) #acl', 'anyone: read-only') + ->assertSeeIn('.row:nth-child(1) #acl', 'jack@kolab.org: read-write'); }); - // Test invalid resource identifier - $browser->visit('/resource/abc')->assertErrorPage(404); + // Test invalid shared folder identifier + $browser->visit('/shared-folder/abc')->assertErrorPage(404); }); } } diff --git a/src/tests/Browser/Admin/UserTest.php b/src/tests/Browser/Admin/UserTest.php index bb46a597..310b2a4c 100644 --- a/src/tests/Browser/Admin/UserTest.php +++ b/src/tests/Browser/Admin/UserTest.php @@ -1,573 +1,604 @@ getTestUser('john@kolab.org'); $john->setSettings([ 'phone' => '+48123123123', 'external_email' => 'john.doe.external@gmail.com', ]); if ($john->isSuspended()) { User::where('email', $john->email)->update(['status' => $john->status - User::STATUS_SUSPENDED]); } $wallet = $john->wallets()->first(); $wallet->discount()->dissociate(); $wallet->save(); Entitlement::where('cost', '>=', 5000)->delete(); $this->deleteTestGroup('group-test@kolab.org'); $this->clearMeetEntitlements(); } /** * {@inheritDoc} */ public function tearDown(): void { $john = $this->getTestUser('john@kolab.org'); $john->setSettings([ 'phone' => null, 'external_email' => 'john.doe.external@gmail.com', ]); if ($john->isSuspended()) { User::where('email', $john->email)->update(['status' => $john->status - User::STATUS_SUSPENDED]); } $wallet = $john->wallets()->first(); $wallet->discount()->dissociate(); $wallet->save(); Entitlement::where('cost', '>=', 5000)->delete(); $this->deleteTestGroup('group-test@kolab.org'); $this->clearMeetEntitlements(); parent::tearDown(); } /** * Test user info page (unauthenticated) */ public function testUserUnauth(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $jack = $this->getTestUser('jack@kolab.org'); $browser->visit('/user/' . $jack->id)->on(new Home()); }); } /** * Test user info page */ public function testUserInfo(): void { $this->browse(function (Browser $browser) { $jack = $this->getTestUser('jack@kolab.org'); $page = new UserPage($jack->id); $browser->visit(new Home()) ->submitLogon('jeroen@jeroen.jeroen', \App\Utils::generatePassphrase(), true) ->on(new Dashboard()) ->visit($page) ->on($page); // Assert main info box content $browser->assertSeeIn('@user-info .card-title', $jack->email) ->with('@user-info form', function (Browser $browser) use ($jack) { $browser->assertElementsCount('.row', 7) ->assertSeeIn('.row:nth-child(1) label', 'Managed by') ->assertSeeIn('.row:nth-child(1) #manager a', 'john@kolab.org') ->assertSeeIn('.row:nth-child(2) label', 'ID (Created)') ->assertSeeIn('.row:nth-child(2) #userid', "{$jack->id} ({$jack->created_at})") ->assertSeeIn('.row:nth-child(3) label', 'Status') ->assertSeeIn('.row:nth-child(3) #status span.text-success', 'Active') ->assertSeeIn('.row:nth-child(4) label', 'First Name') ->assertSeeIn('.row:nth-child(4) #first_name', 'Jack') ->assertSeeIn('.row:nth-child(5) label', 'Last Name') ->assertSeeIn('.row:nth-child(5) #last_name', 'Daniels') ->assertSeeIn('.row:nth-child(6) label', 'External Email') ->assertMissing('.row:nth-child(6) #external_email a') ->assertSeeIn('.row:nth-child(7) label', 'Country') ->assertSeeIn('.row:nth-child(7) #country', 'United States'); }); // Some tabs are loaded in background, wait a second $browser->pause(500) - ->assertElementsCount('@nav a', 8); + ->assertElementsCount('@nav a', 9); // Note: Finances tab is tested in UserFinancesTest.php $browser->assertSeeIn('@nav #tab-finances', 'Finances'); // Assert Aliases tab $browser->assertSeeIn('@nav #tab-aliases', 'Aliases (1)') ->click('@nav #tab-aliases') ->whenAvailable('@user-aliases', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 1) ->assertSeeIn('table tbody tr:first-child td:first-child', 'jack.daniels@kolab.org') ->assertMissing('table tfoot'); }); // Assert Subscriptions tab $browser->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (3)') ->click('@nav #tab-subscriptions') ->with('@user-subscriptions', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 3) ->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'User Mailbox') ->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '5,00 CHF') ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 5 GB') ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF') ->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Groupware Features') ->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '4,90 CHF') ->assertMissing('table tfoot') ->assertMissing('#reset2fa'); }); // Assert Domains tab $browser->assertSeeIn('@nav #tab-domains', 'Domains (0)') ->click('@nav #tab-domains') ->with('@user-domains', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'There are no domains in this account.'); }); // Assert Users tab $browser->assertSeeIn('@nav #tab-users', 'Users (0)') ->click('@nav #tab-users') ->with('@user-users', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'There are no users in this account.'); }); // Assert Distribution lists tab $browser->assertSeeIn('@nav #tab-distlists', 'Distribution lists (0)') ->click('@nav #tab-distlists') ->with('@user-distlists', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'There are no distribution lists in this account.'); }); // Assert Resources tab $browser->assertSeeIn('@nav #tab-resources', 'Resources (0)') ->click('@nav #tab-resources') ->with('@user-resources', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'There are no resources in this account.'); }); + // Assert Shared folders tab + $browser->assertSeeIn('@nav #tab-shared-folders', 'Shared folders (0)') + ->click('@nav #tab-shared-folders') + ->with('@user-shared-folders', function (Browser $browser) { + $browser->assertElementsCount('table tbody tr', 0) + ->assertSeeIn('table tfoot tr td', 'There are no shared folders in this account.'); + }); + // Assert Settings tab $browser->assertSeeIn('@nav #tab-settings', 'Settings') ->click('@nav #tab-settings') ->whenAvailable('@user-settings form', function (Browser $browser) { $browser->assertElementsCount('.row', 1) ->assertSeeIn('.row:first-child label', 'Greylisting') ->assertSeeIn('.row:first-child .text-success', 'enabled'); }); }); } /** * Test user info page (continue) * * @depends testUserInfo */ public function testUserInfo2(): void { $this->browse(function (Browser $browser) { $john = $this->getTestUser('john@kolab.org'); $page = new UserPage($john->id); $discount = Discount::where('code', 'TEST')->first(); $wallet = $john->wallet(); $wallet->discount()->associate($discount); $wallet->debit(2010); $wallet->save(); $group = $this->getTestGroup('group-test@kolab.org', ['name' => 'Test Group']); $group->assignToWallet($john->wallets->first()); $john->setSetting('greylist_enabled', null); // Click the managed-by link on Jack's page $browser->click('@user-info #manager a') ->on($page); // Assert main info box content $browser->assertSeeIn('@user-info .card-title', $john->email) ->with('@user-info form', function (Browser $browser) use ($john) { $ext_email = $john->getSetting('external_email'); $browser->assertElementsCount('.row', 9) ->assertSeeIn('.row:nth-child(1) label', 'ID (Created)') ->assertSeeIn('.row:nth-child(1) #userid', "{$john->id} ({$john->created_at})") ->assertSeeIn('.row:nth-child(2) label', 'Status') ->assertSeeIn('.row:nth-child(2) #status span.text-success', 'Active') ->assertSeeIn('.row:nth-child(3) label', 'First Name') ->assertSeeIn('.row:nth-child(3) #first_name', 'John') ->assertSeeIn('.row:nth-child(4) label', 'Last Name') ->assertSeeIn('.row:nth-child(4) #last_name', 'Doe') ->assertSeeIn('.row:nth-child(5) label', 'Organization') ->assertSeeIn('.row:nth-child(5) #organization', 'Kolab Developers') ->assertSeeIn('.row:nth-child(6) label', 'Phone') ->assertSeeIn('.row:nth-child(6) #phone', $john->getSetting('phone')) ->assertSeeIn('.row:nth-child(7) label', 'External Email') ->assertSeeIn('.row:nth-child(7) #external_email a', $ext_email) ->assertAttribute('.row:nth-child(7) #external_email a', 'href', "mailto:$ext_email") ->assertSeeIn('.row:nth-child(8) label', 'Address') ->assertSeeIn('.row:nth-child(8) #billing_address', $john->getSetting('billing_address')) ->assertSeeIn('.row:nth-child(9) label', 'Country') ->assertSeeIn('.row:nth-child(9) #country', 'United States'); }); // Some tabs are loaded in background, wait a second $browser->pause(500) - ->assertElementsCount('@nav a', 8); + ->assertElementsCount('@nav a', 9); // Note: Finances tab is tested in UserFinancesTest.php $browser->assertSeeIn('@nav #tab-finances', 'Finances'); // Assert Aliases tab $browser->assertSeeIn('@nav #tab-aliases', 'Aliases (1)') ->click('@nav #tab-aliases') ->whenAvailable('@user-aliases', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 1) ->assertSeeIn('table tbody tr:first-child td:first-child', 'john.doe@kolab.org') ->assertMissing('table tfoot'); }); // Assert Subscriptions tab $browser->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (3)') ->click('@nav #tab-subscriptions') ->with('@user-subscriptions', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 3) ->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'User Mailbox') ->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '4,50 CHF/month¹') ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 5 GB') ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF/month¹') ->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Groupware Features') ->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '4,41 CHF/month¹') ->assertMissing('table tfoot') ->assertSeeIn('table + .hint', '¹ applied discount: 10% - Test voucher'); }); // Assert Domains tab $browser->assertSeeIn('@nav #tab-domains', 'Domains (1)') ->click('@nav #tab-domains') ->with('@user-domains table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 1) ->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'kolab.org') ->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-success') ->assertMissing('tfoot'); }); + // Assert Users tab + $browser->assertSeeIn('@nav #tab-users', 'Users (4)') + ->click('@nav #tab-users') + ->with('@user-users table', function (Browser $browser) { + $browser->assertElementsCount('tbody tr', 4) + ->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'jack@kolab.org') + ->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-success') + ->assertSeeIn('tbody tr:nth-child(2) td:first-child a', 'joe@kolab.org') + ->assertVisible('tbody tr:nth-child(2) td:first-child svg.text-success') + ->assertSeeIn('tbody tr:nth-child(3) td:first-child span', 'john@kolab.org') + ->assertVisible('tbody tr:nth-child(3) td:first-child svg.text-success') + ->assertSeeIn('tbody tr:nth-child(4) td:first-child a', 'ned@kolab.org') + ->assertVisible('tbody tr:nth-child(4) td:first-child svg.text-success') + ->assertMissing('tfoot'); + }); + // Assert Distribution lists tab $browser->assertSeeIn('@nav #tab-distlists', 'Distribution lists (1)') ->click('@nav #tab-distlists') ->with('@user-distlists table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 1) ->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'Test Group') ->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-danger') ->assertSeeIn('tbody tr:nth-child(1) td:last-child a', 'group-test@kolab.org') ->assertMissing('tfoot'); }); // Assert Resources tab $browser->assertSeeIn('@nav #tab-resources', 'Resources (2)') ->click('@nav #tab-resources') ->with('@user-resources', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 2) ->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'Conference Room #1') ->assertSeeIn('table tbody tr:nth-child(1) td:last-child', 'resource-test1@kolab.org') ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Conference Room #2') ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', 'resource-test2@kolab.org') ->assertMissing('table tfoot'); }); - // Assert Users tab - $browser->assertSeeIn('@nav #tab-users', 'Users (4)') - ->click('@nav #tab-users') - ->with('@user-users table', function (Browser $browser) { - $browser->assertElementsCount('tbody tr', 4) - ->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'jack@kolab.org') - ->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-success') - ->assertSeeIn('tbody tr:nth-child(2) td:first-child a', 'joe@kolab.org') - ->assertVisible('tbody tr:nth-child(2) td:first-child svg.text-success') - ->assertSeeIn('tbody tr:nth-child(3) td:first-child span', 'john@kolab.org') - ->assertVisible('tbody tr:nth-child(3) td:first-child svg.text-success') - ->assertSeeIn('tbody tr:nth-child(4) td:first-child a', 'ned@kolab.org') - ->assertVisible('tbody tr:nth-child(4) td:first-child svg.text-success') - ->assertMissing('tfoot'); + // Assert Shared folders tab + $browser->assertSeeIn('@nav #tab-shared-folders', 'Shared folders (2)') + ->click('@nav #tab-shared-folders') + ->with('@user-shared-folders', function (Browser $browser) { + $browser->assertElementsCount('table tbody tr', 2) + ->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'Calendar') + ->assertSeeIn('table tbody tr:nth-child(1) td:nth-child(2)', 'Calendar') + ->assertSeeIn('table tbody tr:nth-child(1) td:last-child', 'folder-event@kolab.org') + ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Contacts') + ->assertSeeIn('table tbody tr:nth-child(2) td:nth-child(2)', 'Address Book') + ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', 'folder-contact@kolab.org') + ->assertMissing('table tfoot'); }); }); // Now we go to Ned's info page, he's a controller on John's wallet $this->browse(function (Browser $browser) { $ned = $this->getTestUser('ned@kolab.org'); $beta_sku = Sku::withEnvTenantContext()->where('title', 'beta')->first(); $storage_sku = Sku::withEnvTenantContext()->where('title', 'storage')->first(); $wallet = $ned->wallet(); // Add an extra storage and beta entitlement with different prices Entitlement::create([ 'wallet_id' => $wallet->id, 'sku_id' => $beta_sku->id, 'cost' => 5010, 'entitleable_id' => $ned->id, 'entitleable_type' => User::class ]); Entitlement::create([ 'wallet_id' => $wallet->id, 'sku_id' => $storage_sku->id, 'cost' => 5000, 'entitleable_id' => $ned->id, 'entitleable_type' => User::class ]); $page = new UserPage($ned->id); $ned->setSetting('greylist_enabled', 'false'); - $browser->click('@user-users tbody tr:nth-child(4) td:first-child a') + $browser->click('@nav #tab-users') + ->click('@user-users tbody tr:nth-child(4) td:first-child a') ->on($page); // Assert main info box content $browser->assertSeeIn('@user-info .card-title', $ned->email) ->with('@user-info form', function (Browser $browser) use ($ned) { $browser->assertSeeIn('.row:nth-child(2) label', 'ID (Created)') ->assertSeeIn('.row:nth-child(2) #userid', "{$ned->id} ({$ned->created_at})"); }); // Some tabs are loaded in background, wait a second $browser->pause(500) - ->assertElementsCount('@nav a', 8); + ->assertElementsCount('@nav a', 9); // Note: Finances tab is tested in UserFinancesTest.php $browser->assertSeeIn('@nav #tab-finances', 'Finances'); // Assert Aliases tab $browser->assertSeeIn('@nav #tab-aliases', 'Aliases (0)') ->click('@nav #tab-aliases') ->whenAvailable('@user-aliases', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'This user has no email aliases.'); }); // Assert Subscriptions tab, we expect John's discount here $browser->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (6)') ->click('@nav #tab-subscriptions') ->with('@user-subscriptions', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 6) ->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'User Mailbox') ->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '4,50 CHF/month¹') ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 6 GB') ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '45,00 CHF/month¹') ->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Groupware Features') ->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '4,41 CHF/month¹') ->assertSeeIn('table tbody tr:nth-child(4) td:first-child', 'Activesync') ->assertSeeIn('table tbody tr:nth-child(4) td:last-child', '0,00 CHF/month¹') ->assertSeeIn('table tbody tr:nth-child(5) td:first-child', '2-Factor Authentication') ->assertSeeIn('table tbody tr:nth-child(5) td:last-child', '0,00 CHF/month¹') ->assertSeeIn('table tbody tr:nth-child(6) td:first-child', 'Private Beta (invitation only)') ->assertSeeIn('table tbody tr:nth-child(6) td:last-child', '45,09 CHF/month¹') ->assertMissing('table tfoot') ->assertSeeIn('table + .hint', '¹ applied discount: 10% - Test voucher') ->assertSeeIn('#reset2fa', 'Reset 2-Factor Auth') ->assertMissing('#addbetasku'); }); // We don't expect John's domains here $browser->assertSeeIn('@nav #tab-domains', 'Domains (0)') ->click('@nav #tab-domains') ->with('@user-domains', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'There are no domains in this account.'); }); // We don't expect John's users here $browser->assertSeeIn('@nav #tab-users', 'Users (0)') ->click('@nav #tab-users') ->with('@user-users', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'There are no users in this account.'); }); // We don't expect John's distribution lists here $browser->assertSeeIn('@nav #tab-distlists', 'Distribution lists (0)') ->click('@nav #tab-distlists') ->with('@user-distlists', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'There are no distribution lists in this account.'); }); // We don't expect John's resources here $browser->assertSeeIn('@nav #tab-resources', 'Resources (0)') ->click('@nav #tab-resources') ->with('@user-resources', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'There are no resources in this account.'); }); + // We don't expect John's folders here + $browser->assertSeeIn('@nav #tab-shared-folders', 'Shared folders (0)') + ->click('@nav #tab-shared-folders') + ->with('@user-shared-folders', function (Browser $browser) { + $browser->assertElementsCount('table tbody tr', 0) + ->assertSeeIn('table tfoot tr td', 'There are no shared folders in this account.'); + }); + // Assert Settings tab $browser->assertSeeIn('@nav #tab-settings', 'Settings') ->click('@nav #tab-settings') ->whenAvailable('@user-settings form', function (Browser $browser) { $browser->assertElementsCount('.row', 1) ->assertSeeIn('.row:first-child label', 'Greylisting') ->assertSeeIn('.row:first-child .text-danger', 'disabled'); }); }); } /** * Test editing an external email * * @depends testUserInfo2 */ public function testExternalEmail(): void { $this->browse(function (Browser $browser) { $john = $this->getTestUser('john@kolab.org'); $browser->visit(new UserPage($john->id)) ->waitFor('@user-info #external_email button') ->click('@user-info #external_email button') // Test dialog content, and closing it with Cancel button ->with(new Dialog('#email-dialog'), function (Browser $browser) { $browser->assertSeeIn('@title', 'External Email') ->assertFocused('@body input') ->assertValue('@body input', 'john.doe.external@gmail.com') ->assertSeeIn('@button-cancel', 'Cancel') ->assertSeeIn('@button-action', 'Submit') ->click('@button-cancel'); }) ->assertMissing('#email-dialog') ->click('@user-info #external_email button') // Test email validation error handling, and email update ->with(new Dialog('#email-dialog'), function (Browser $browser) { $browser->type('@body input', 'test') ->click('@button-action') ->waitFor('@body input.is-invalid') ->assertSeeIn( '@body input + .invalid-feedback', 'The external email must be a valid email address.' ) ->assertToast(Toast::TYPE_ERROR, 'Form validation error') ->type('@body input', 'test@test.com') ->click('@button-action'); }) ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.') ->assertSeeIn('@user-info #external_email a', 'test@test.com') ->click('@user-info #external_email button') ->with(new Dialog('#email-dialog'), function (Browser $browser) { $browser->assertValue('@body input', 'test@test.com') ->assertMissing('@body input.is-invalid') ->assertMissing('@body input + .invalid-feedback') ->click('@button-cancel'); }) ->assertSeeIn('@user-info #external_email a', 'test@test.com'); // $john->getSetting() may not work here as it uses internal cache // read the value form database $current_ext_email = $john->settings()->where('key', 'external_email')->first()->value; $this->assertSame('test@test.com', $current_ext_email); }); } /** * Test suspending/unsuspending the user */ public function testSuspendAndUnsuspend(): void { $this->browse(function (Browser $browser) { $john = $this->getTestUser('john@kolab.org'); $browser->visit(new UserPage($john->id)) ->assertVisible('@user-info #button-suspend') ->assertMissing('@user-info #button-unsuspend') ->click('@user-info #button-suspend') ->assertToast(Toast::TYPE_SUCCESS, 'User suspended successfully.') ->assertSeeIn('@user-info #status span.text-warning', 'Suspended') ->assertMissing('@user-info #button-suspend') ->click('@user-info #button-unsuspend') ->assertToast(Toast::TYPE_SUCCESS, 'User unsuspended successfully.') ->assertSeeIn('@user-info #status span.text-success', 'Active') ->assertVisible('@user-info #button-suspend') ->assertMissing('@user-info #button-unsuspend'); }); } /** * Test resetting 2FA for the user */ public function testReset2FA(): void { $this->browse(function (Browser $browser) { $this->deleteTestUser('userstest1@kolabnow.com'); $user = $this->getTestUser('userstest1@kolabnow.com'); $sku2fa = Sku::withEnvTenantContext()->where('title', '2fa')->first(); $user->assignSku($sku2fa); SecondFactor::seed('userstest1@kolabnow.com'); $browser->visit(new UserPage($user->id)) ->click('@nav #tab-subscriptions') ->with('@user-subscriptions', function (Browser $browser) use ($sku2fa) { $browser->waitFor('#reset2fa') ->assertVisible('#sku' . $sku2fa->id); }) ->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (1)') ->click('#reset2fa') ->with(new Dialog('#reset-2fa-dialog'), function (Browser $browser) { $browser->assertSeeIn('@title', '2-Factor Authentication Reset') ->assertSeeIn('@button-cancel', 'Cancel') ->assertSeeIn('@button-action', 'Reset') ->click('@button-action'); }) ->assertToast(Toast::TYPE_SUCCESS, '2-Factor authentication reset successfully.') ->assertMissing('#sku' . $sku2fa->id) ->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (0)'); }); } /** * Test adding the beta SKU for the user */ public function testAddBetaSku(): void { $this->browse(function (Browser $browser) { $this->deleteTestUser('userstest1@kolabnow.com'); $user = $this->getTestUser('userstest1@kolabnow.com'); $sku = Sku::withEnvTenantContext()->where('title', 'beta')->first(); $browser->visit(new UserPage($user->id)) ->click('@nav #tab-subscriptions') ->waitFor('@user-subscriptions #addbetasku') ->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (0)') ->assertSeeIn('#addbetasku', 'Enable beta program') ->click('#addbetasku') ->assertToast(Toast::TYPE_SUCCESS, 'The subscription added successfully.') ->waitFor('#sku' . $sku->id) ->assertSeeIn("#sku{$sku->id} td:first-child", 'Private Beta (invitation only)') ->assertSeeIn("#sku{$sku->id} td:last-child", '0,00 CHF/month') ->assertMissing('#addbetasku') ->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (1)'); }); } } diff --git a/src/tests/Browser/Components/AclInput.php b/src/tests/Browser/Components/AclInput.php new file mode 100644 index 00000000..d0500624 --- /dev/null +++ b/src/tests/Browser/Components/AclInput.php @@ -0,0 +1,141 @@ +selector = $selector; + } + + /** + * Get the root selector for the component. + * + * @return string + */ + public function selector() + { + return $this->selector; + } + + /** + * Assert that the browser page contains the component. + * + * @param \Laravel\Dusk\Browser $browser + * + * @return void + */ + public function assert($browser) + { + $browser->assertVisible($this->selector) + ->assertVisible("{$this->selector} @input") + ->assertVisible("{$this->selector} @add-btn") + ->assertSelectHasOptions("{$this->selector} @mod-select", ['user', 'anyone']) + ->assertSelectHasOptions("{$this->selector} @acl-select", ['read-only', 'read-write', 'full']); + } + + /** + * Get the element shortcuts for the component. + * + * @return array + */ + public function elements() + { + return [ + '@add-btn' => '.input-group:first-child a.btn', + '@input' => '.input-group:first-child input', + '@acl-select' => '.input-group:first-child select.acl', + '@mod-select' => '.input-group:first-child select.mod', + ]; + } + + /** + * Assert acl input content + */ + public function assertAclValue($browser, array $list) + { + if (empty($list)) { + $browser->assertMissing('.input-group:not(:first-child)'); + return; + } + + foreach ($list as $idx => $value) { + $selector = '.input-group:nth-child(' . ($idx + 2) . ')'; + list($ident, $acl) = preg_split('/\s*,\s*/', $value); + + $input = $ident == 'anyone' ? 'input:read-only' : 'input:not(:read-only)'; + + $browser->assertVisible("$selector $input") + ->assertVisible("$selector select") + ->assertVisible("$selector a.btn") + ->assertValue("$selector $input", $ident) + ->assertSelected("$selector select", $acl); + } + } + + /** + * Add acl entry + */ + public function addAclEntry($browser, string $value) + { + list($ident, $acl) = preg_split('/\s*,\s*/', $value); + + $browser->select('@mod-select', $ident == 'anyone' ? 'anyone' : 'user') + ->select('@acl-select', $acl); + + if ($ident == 'anyone') { + $browser->assertValue('@input', '')->assertMissing('@input'); + } else { + $browser->type('@input', $ident); + } + + $browser->click('@add-btn') + ->assertSelected('@mod-select', 'user') + ->assertSelected('@acl-select', 'read-only') + ->assertValue('@input', ''); + } + + /** + * Remove acl entry + */ + public function removeAclEntry($browser, int $num) + { + $selector = '.input-group:nth-child(' . ($num + 1) . ') a.btn'; + $browser->click($selector); + } + + /** + * Update acl entry + */ + public function updateAclEntry($browser, int $num, $value) + { + list($ident, $acl) = preg_split('/\s*,\s*/', $value); + + $selector = '.input-group:nth-child(' . ($num + 1) . ')'; + + $browser->select("$selector select.acl", $acl) + ->type("$selector input", $ident); + } + + /** + * Assert an error message on the widget + */ + public function assertFormError($browser, int $num, string $msg, bool $focused = false) + { + $selector = '.input-group:nth-child(' . ($num + 1) . ') input.is-invalid'; + + $browser->waitFor($selector) + ->assertSeeIn(' + .invalid-feedback', $msg); + + if ($focused) { + $browser->assertFocused($selector); + } + } +} diff --git a/src/tests/Browser/Pages/Admin/SharedFolder.php b/src/tests/Browser/Pages/Admin/SharedFolder.php new file mode 100644 index 00000000..0985e5aa --- /dev/null +++ b/src/tests/Browser/Pages/Admin/SharedFolder.php @@ -0,0 +1,57 @@ +folderId = $id; + } + + /** + * Get the URL for the page. + * + * @return string + */ + public function url(): string + { + return '/shared-folder/' . $this->folderId; + } + + /** + * Assert that the browser is on the page. + * + * @param \Laravel\Dusk\Browser $browser The browser object + * + * @return void + */ + public function assert($browser): void + { + $browser->waitForLocation($this->url()) + ->waitFor('@folder-info'); + } + + /** + * Get the element shortcuts for the page. + * + * @return array + */ + public function elements(): array + { + return [ + '@app' => '#app', + '@folder-info' => '#folder-info', + '@folder-settings' => '#folder-settings', + ]; + } +} diff --git a/src/tests/Browser/Pages/Admin/User.php b/src/tests/Browser/Pages/Admin/User.php index 4ad83025..c58b774b 100644 --- a/src/tests/Browser/Pages/Admin/User.php +++ b/src/tests/Browser/Pages/Admin/User.php @@ -1,66 +1,67 @@ userid = $userid; } /** * Get the URL for the page. * * @return string */ public function url(): string { return '/user/' . $this->userid; } /** * Assert that the browser is on the page. * * @param \Laravel\Dusk\Browser $browser The browser object * * @return void */ public function assert($browser): void { $browser->waitForLocation($this->url()) ->waitUntilMissing('@app .app-loader') ->waitFor('@user-info'); } /** * Get the element shortcuts for the page. * * @return array */ public function elements(): array { return [ '@app' => '#app', '@user-info' => '#user-info', '@nav' => 'ul.nav-tabs', '@user-finances' => '#user-finances', '@user-aliases' => '#user-aliases', '@user-subscriptions' => '#user-subscriptions', '@user-distlists' => '#user-distlists', '@user-domains' => '#user-domains', '@user-resources' => '#user-resources', + '@user-shared-folders' => '#user-shared-folders', '@user-users' => '#user-users', '@user-settings' => '#user-settings', ]; } } diff --git a/src/tests/Browser/Pages/SharedFolderInfo.php b/src/tests/Browser/Pages/SharedFolderInfo.php new file mode 100644 index 00000000..e29ae488 --- /dev/null +++ b/src/tests/Browser/Pages/SharedFolderInfo.php @@ -0,0 +1,47 @@ +waitFor('@general') + ->waitUntilMissing('.app-loader'); + } + + /** + * Get the element shortcuts for the page. + * + * @return array + */ + public function elements(): array + { + return [ + '@app' => '#app', + '@general' => '#general', + '@nav' => 'ul.nav-tabs', + '@settings' => '#settings', + '@status' => '#status-box', + ]; + } +} diff --git a/src/tests/Browser/Pages/SharedFolderList.php b/src/tests/Browser/Pages/SharedFolderList.php new file mode 100644 index 00000000..c155b288 --- /dev/null +++ b/src/tests/Browser/Pages/SharedFolderList.php @@ -0,0 +1,45 @@ +assertPathIs($this->url()) + ->waitUntilMissing('@app .app-loader') + ->assertSeeIn('#folder-list .card-title', 'Shared folders'); + } + + /** + * Get the element shortcuts for the page. + * + * @return array + */ + public function elements(): array + { + return [ + '@app' => '#app', + '@table' => '#folder-list table', + ]; + } +} diff --git a/src/tests/Browser/Reseller/ResourceTest.php b/src/tests/Browser/Reseller/ResourceTest.php index 6f3e6d83..4eea3c15 100644 --- a/src/tests/Browser/Reseller/ResourceTest.php +++ b/src/tests/Browser/Reseller/ResourceTest.php @@ -1,95 +1,98 @@ browse(function (Browser $browser) { $user = $this->getTestUser('john@kolab.org'); $resource = $this->getTestResource('resource-test1@kolab.org'); $browser->visit('/resource/' . $resource->id)->on(new Home()); }); } /** * Test distribution list info page */ public function testInfo(): void { Queue::fake(); $this->browse(function (Browser $browser) { $user = $this->getTestUser('john@kolab.org'); $resource = $this->getTestResource('resource-test1@kolab.org'); $resource->setSetting('invitation_policy', 'accept'); + $resource->status = Resource::STATUS_NEW | Resource::STATUS_ACTIVE + | Resource::STATUS_LDAP_READY | Resource::STATUS_IMAP_READY; + $resource->save(); $resource_page = new ResourcePage($resource->id); $user_page = new UserPage($user->id); // Goto the distlist page $browser->visit(new Home()) ->submitLogon('reseller@' . \config('app.domain'), \App\Utils::generatePassphrase(), true) ->on(new Dashboard()) ->visit($user_page) ->on($user_page) ->click('@nav #tab-resources') ->pause(1000) ->click('@user-resources table tbody tr:first-child td:first-child a') ->on($resource_page) ->assertSeeIn('@resource-info .card-title', $resource->email) ->with('@resource-info form', function (Browser $browser) use ($resource) { $browser->assertElementsCount('.row', 3) ->assertSeeIn('.row:nth-child(1) label', 'ID (Created)') ->assertSeeIn('.row:nth-child(1) #resourceid', "{$resource->id} ({$resource->created_at})") ->assertSeeIn('.row:nth-child(2) label', 'Status') ->assertSeeIn('.row:nth-child(2) #status.text-success', 'Active') ->assertSeeIn('.row:nth-child(3) label', 'Name') ->assertSeeIn('.row:nth-child(3) #name', $resource->name); }) ->assertElementsCount('ul.nav-tabs', 1) ->assertSeeIn('ul.nav-tabs .nav-link', 'Settings') ->with('@resource-settings form', function (Browser $browser) { $browser->assertElementsCount('.row', 1) ->assertSeeIn('.row:nth-child(1) label', 'Invitation policy') ->assertSeeIn('.row:nth-child(1) #invitation_policy', 'accept'); }); // Test invalid resource identifier $browser->visit('/resource/abc')->assertErrorPage(404); }); } } diff --git a/src/tests/Browser/Reseller/ResourceTest.php b/src/tests/Browser/Reseller/SharedFolderTest.php similarity index 50% copy from src/tests/Browser/Reseller/ResourceTest.php copy to src/tests/Browser/Reseller/SharedFolderTest.php index 6f3e6d83..2e0b8125 100644 --- a/src/tests/Browser/Reseller/ResourceTest.php +++ b/src/tests/Browser/Reseller/SharedFolderTest.php @@ -1,95 +1,101 @@ browse(function (Browser $browser) { $user = $this->getTestUser('john@kolab.org'); - $resource = $this->getTestResource('resource-test1@kolab.org'); + $folder = $this->getTestSharedFolder('folder-event@kolab.org'); - $browser->visit('/resource/' . $resource->id)->on(new Home()); + $browser->visit('/shared-folder/' . $folder->id)->on(new Home()); }); } /** - * Test distribution list info page + * Test shared folder info page */ public function testInfo(): void { Queue::fake(); $this->browse(function (Browser $browser) { $user = $this->getTestUser('john@kolab.org'); - $resource = $this->getTestResource('resource-test1@kolab.org'); - $resource->setSetting('invitation_policy', 'accept'); + $folder = $this->getTestSharedFolder('folder-event@kolab.org'); + $folder->setConfig(['acl' => ['anyone, read-only', 'jack@kolab.org, read-write']]); + $folder->status = SharedFolder::STATUS_NEW | SharedFolder::STATUS_ACTIVE + | SharedFolder::STATUS_LDAP_READY | SharedFolder::STATUS_IMAP_READY; + $folder->save(); - $resource_page = new ResourcePage($resource->id); + $folder_page = new SharedFolderPage($folder->id); $user_page = new UserPage($user->id); - // Goto the distlist page + // Goto the folder page $browser->visit(new Home()) ->submitLogon('reseller@' . \config('app.domain'), \App\Utils::generatePassphrase(), true) ->on(new Dashboard()) ->visit($user_page) ->on($user_page) - ->click('@nav #tab-resources') + ->click('@nav #tab-shared-folders') ->pause(1000) - ->click('@user-resources table tbody tr:first-child td:first-child a') - ->on($resource_page) - ->assertSeeIn('@resource-info .card-title', $resource->email) - ->with('@resource-info form', function (Browser $browser) use ($resource) { - $browser->assertElementsCount('.row', 3) + ->click('@user-shared-folders table tbody tr:first-child td:first-child a') + ->on($folder_page) + ->assertSeeIn('@folder-info .card-title', $folder->email) + ->with('@folder-info form', function (Browser $browser) use ($folder) { + $browser->assertElementsCount('.row', 4) ->assertSeeIn('.row:nth-child(1) label', 'ID (Created)') - ->assertSeeIn('.row:nth-child(1) #resourceid', "{$resource->id} ({$resource->created_at})") + ->assertSeeIn('.row:nth-child(1) #folderid', "{$folder->id} ({$folder->created_at})") ->assertSeeIn('.row:nth-child(2) label', 'Status') ->assertSeeIn('.row:nth-child(2) #status.text-success', 'Active') ->assertSeeIn('.row:nth-child(3) label', 'Name') - ->assertSeeIn('.row:nth-child(3) #name', $resource->name); + ->assertSeeIn('.row:nth-child(3) #name', $folder->name) + ->assertSeeIn('.row:nth-child(4) label', 'Type') + ->assertSeeIn('.row:nth-child(4) #type', 'Calendar'); }) ->assertElementsCount('ul.nav-tabs', 1) ->assertSeeIn('ul.nav-tabs .nav-link', 'Settings') - ->with('@resource-settings form', function (Browser $browser) { + ->with('@folder-settings form', function (Browser $browser) { $browser->assertElementsCount('.row', 1) - ->assertSeeIn('.row:nth-child(1) label', 'Invitation policy') - ->assertSeeIn('.row:nth-child(1) #invitation_policy', 'accept'); + ->assertSeeIn('.row:nth-child(1) label', 'Access rights') + ->assertSeeIn('.row:nth-child(1) #acl', 'anyone: read-only') + ->assertSeeIn('.row:nth-child(1) #acl', 'jack@kolab.org: read-write'); }); - // Test invalid resource identifier - $browser->visit('/resource/abc')->assertErrorPage(404); + // Test invalid shared folder identifier + $browser->visit('/shared-folder/abc')->assertErrorPage(404); }); } } diff --git a/src/tests/Browser/Reseller/UserTest.php b/src/tests/Browser/Reseller/UserTest.php index 8df9ec96..c891975e 100644 --- a/src/tests/Browser/Reseller/UserTest.php +++ b/src/tests/Browser/Reseller/UserTest.php @@ -1,546 +1,577 @@ getTestUser('john@kolab.org'); $john->setSettings([ 'phone' => '+48123123123', 'external_email' => 'john.doe.external@gmail.com', ]); if ($john->isSuspended()) { User::where('email', $john->email)->update(['status' => $john->status - User::STATUS_SUSPENDED]); } $wallet = $john->wallets()->first(); $wallet->discount()->dissociate(); $wallet->save(); $this->deleteTestGroup('group-test@kolab.org'); $this->clearMeetEntitlements(); } /** * {@inheritDoc} */ public function tearDown(): void { $john = $this->getTestUser('john@kolab.org'); $john->setSettings([ 'phone' => null, 'external_email' => 'john.doe.external@gmail.com', ]); if ($john->isSuspended()) { User::where('email', $john->email)->update(['status' => $john->status - User::STATUS_SUSPENDED]); } $wallet = $john->wallets()->first(); $wallet->discount()->dissociate(); $wallet->save(); $this->deleteTestGroup('group-test@kolab.org'); $this->clearMeetEntitlements(); parent::tearDown(); } /** * Test user info page (unauthenticated) */ public function testUserUnauth(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $jack = $this->getTestUser('jack@kolab.org'); $browser->visit('/user/' . $jack->id)->on(new Home()); }); } /** * Test user info page */ public function testUserInfo(): void { $this->browse(function (Browser $browser) { $jack = $this->getTestUser('jack@kolab.org'); $page = new UserPage($jack->id); $browser->visit(new Home()) ->submitLogon('reseller@' . \config('app.domain'), \App\Utils::generatePassphrase(), true) ->on(new Dashboard()) ->visit($page) ->on($page); // Assert main info box content $browser->assertSeeIn('@user-info .card-title', $jack->email) ->with('@user-info form', function (Browser $browser) use ($jack) { $browser->assertElementsCount('.row', 7) ->assertSeeIn('.row:nth-child(1) label', 'Managed by') ->assertSeeIn('.row:nth-child(1) #manager a', 'john@kolab.org') ->assertSeeIn('.row:nth-child(2) label', 'ID (Created)') ->assertSeeIn('.row:nth-child(2) #userid', "{$jack->id} ({$jack->created_at})") ->assertSeeIn('.row:nth-child(3) label', 'Status') ->assertSeeIn('.row:nth-child(3) #status span.text-success', 'Active') ->assertSeeIn('.row:nth-child(4) label', 'First Name') ->assertSeeIn('.row:nth-child(4) #first_name', 'Jack') ->assertSeeIn('.row:nth-child(5) label', 'Last Name') ->assertSeeIn('.row:nth-child(5) #last_name', 'Daniels') ->assertSeeIn('.row:nth-child(6) label', 'External Email') ->assertMissing('.row:nth-child(6) #external_email a') ->assertSeeIn('.row:nth-child(7) label', 'Country') ->assertSeeIn('.row:nth-child(7) #country', 'United States'); }); // Some tabs are loaded in background, wait a second $browser->pause(500) - ->assertElementsCount('@nav a', 8); + ->assertElementsCount('@nav a', 9); // Note: Finances tab is tested in UserFinancesTest.php $browser->assertSeeIn('@nav #tab-finances', 'Finances'); // Assert Aliases tab $browser->assertSeeIn('@nav #tab-aliases', 'Aliases (1)') ->click('@nav #tab-aliases') ->whenAvailable('@user-aliases', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 1) ->assertSeeIn('table tbody tr:first-child td:first-child', 'jack.daniels@kolab.org') ->assertMissing('table tfoot'); }); // Assert Subscriptions tab $browser->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (3)') ->click('@nav #tab-subscriptions') ->with('@user-subscriptions', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 3) ->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'User Mailbox') ->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '5,00 CHF/month') ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 5 GB') ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF/month') ->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Groupware Features') ->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '4,90 CHF/month') ->assertMissing('table tfoot') ->assertMissing('#reset2fa'); }); // Assert Domains tab $browser->assertSeeIn('@nav #tab-domains', 'Domains (0)') ->click('@nav #tab-domains') ->with('@user-domains', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'There are no domains in this account.'); }); // Assert Users tab $browser->assertSeeIn('@nav #tab-users', 'Users (0)') ->click('@nav #tab-users') ->with('@user-users', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'There are no users in this account.'); }); // Assert Distribution lists tab $browser->assertSeeIn('@nav #tab-distlists', 'Distribution lists (0)') ->click('@nav #tab-distlists') ->with('@user-distlists', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'There are no distribution lists in this account.'); }); // Assert Resources tab $browser->assertSeeIn('@nav #tab-resources', 'Resources (0)') ->click('@nav #tab-resources') ->with('@user-resources', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'There are no resources in this account.'); }); + // Assert Shared folders tab + $browser->assertSeeIn('@nav #tab-shared-folders', 'Shared folders (0)') + ->click('@nav #tab-shared-folders') + ->with('@user-shared-folders', function (Browser $browser) { + $browser->assertElementsCount('table tbody tr', 0) + ->assertSeeIn('table tfoot tr td', 'There are no shared folders in this account.'); + }); + // Assert Settings tab $browser->assertSeeIn('@nav #tab-settings', 'Settings') ->click('@nav #tab-settings') ->whenAvailable('@user-settings form', function (Browser $browser) { $browser->assertElementsCount('.row', 1) ->assertSeeIn('.row:first-child label', 'Greylisting') ->assertSeeIn('.row:first-child .text-success', 'enabled'); }); }); } /** * Test user info page (continue) * * @depends testUserInfo */ public function testUserInfo2(): void { $this->browse(function (Browser $browser) { $john = $this->getTestUser('john@kolab.org'); $page = new UserPage($john->id); $discount = Discount::where('code', 'TEST')->first(); $wallet = $john->wallet(); $wallet->discount()->associate($discount); $wallet->debit(2010); $wallet->save(); $group = $this->getTestGroup('group-test@kolab.org', ['name' => 'Test Group']); $group->assignToWallet($john->wallets->first()); // Click the managed-by link on Jack's page $browser->click('@user-info #manager a') ->on($page); // Assert main info box content $browser->assertSeeIn('@user-info .card-title', $john->email) ->with('@user-info form', function (Browser $browser) use ($john) { $ext_email = $john->getSetting('external_email'); $browser->assertElementsCount('.row', 9) ->assertSeeIn('.row:nth-child(1) label', 'ID (Created)') ->assertSeeIn('.row:nth-child(1) #userid', "{$john->id} ({$john->created_at})") ->assertSeeIn('.row:nth-child(2) label', 'Status') ->assertSeeIn('.row:nth-child(2) #status span.text-success', 'Active') ->assertSeeIn('.row:nth-child(3) label', 'First Name') ->assertSeeIn('.row:nth-child(3) #first_name', 'John') ->assertSeeIn('.row:nth-child(4) label', 'Last Name') ->assertSeeIn('.row:nth-child(4) #last_name', 'Doe') ->assertSeeIn('.row:nth-child(5) label', 'Organization') ->assertSeeIn('.row:nth-child(5) #organization', 'Kolab Developers') ->assertSeeIn('.row:nth-child(6) label', 'Phone') ->assertSeeIn('.row:nth-child(6) #phone', $john->getSetting('phone')) ->assertSeeIn('.row:nth-child(7) label', 'External Email') ->assertSeeIn('.row:nth-child(7) #external_email a', $ext_email) ->assertAttribute('.row:nth-child(7) #external_email a', 'href', "mailto:$ext_email") ->assertSeeIn('.row:nth-child(8) label', 'Address') ->assertSeeIn('.row:nth-child(8) #billing_address', $john->getSetting('billing_address')) ->assertSeeIn('.row:nth-child(9) label', 'Country') ->assertSeeIn('.row:nth-child(9) #country', 'United States'); }); // Some tabs are loaded in background, wait a second $browser->pause(500) - ->assertElementsCount('@nav a', 8); + ->assertElementsCount('@nav a', 9); // Note: Finances tab is tested in UserFinancesTest.php $browser->assertSeeIn('@nav #tab-finances', 'Finances'); // Assert Aliases tab $browser->assertSeeIn('@nav #tab-aliases', 'Aliases (1)') ->click('@nav #tab-aliases') ->whenAvailable('@user-aliases', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 1) ->assertSeeIn('table tbody tr:first-child td:first-child', 'john.doe@kolab.org') ->assertMissing('table tfoot'); }); // Assert Subscriptions tab $browser->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (3)') ->click('@nav #tab-subscriptions') ->with('@user-subscriptions', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 3) ->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'User Mailbox') ->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '4,50 CHF/month¹') ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 5 GB') ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF/month¹') ->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Groupware Features') ->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '4,41 CHF/month¹') ->assertMissing('table tfoot') ->assertSeeIn('table + .hint', '¹ applied discount: 10% - Test voucher'); }); + // Assert Users tab + $browser->assertSeeIn('@nav #tab-users', 'Users (4)') + ->click('@nav #tab-users') + ->with('@user-users table', function (Browser $browser) { + $browser->assertElementsCount('tbody tr', 4) + ->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'jack@kolab.org') + ->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-success') + ->assertSeeIn('tbody tr:nth-child(2) td:first-child a', 'joe@kolab.org') + ->assertVisible('tbody tr:nth-child(2) td:first-child svg.text-success') + ->assertSeeIn('tbody tr:nth-child(3) td:first-child span', 'john@kolab.org') + ->assertVisible('tbody tr:nth-child(3) td:first-child svg.text-success') + ->assertSeeIn('tbody tr:nth-child(4) td:first-child a', 'ned@kolab.org') + ->assertVisible('tbody tr:nth-child(4) td:first-child svg.text-success') + ->assertMissing('tfoot'); + }); + // Assert Domains tab $browser->assertSeeIn('@nav #tab-domains', 'Domains (1)') ->click('@nav #tab-domains') ->with('@user-domains table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 1) ->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'kolab.org') ->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-success') ->assertMissing('tfoot'); }); // Assert Distribution lists tab $browser->assertSeeIn('@nav #tab-distlists', 'Distribution lists (1)') ->click('@nav #tab-distlists') ->with('@user-distlists table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 1) ->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'Test Group') ->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-danger') ->assertSeeIn('tbody tr:nth-child(1) td:last-child a', 'group-test@kolab.org') ->assertMissing('tfoot'); }); // Assert Resources tab $browser->assertSeeIn('@nav #tab-resources', 'Resources (2)') ->click('@nav #tab-resources') ->with('@user-resources', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 2) ->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'Conference Room #1') ->assertSeeIn('table tbody tr:nth-child(1) td:last-child', 'resource-test1@kolab.org') ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Conference Room #2') ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', 'resource-test2@kolab.org') ->assertMissing('table tfoot'); }); - // Assert Users tab - $browser->assertSeeIn('@nav #tab-users', 'Users (4)') - ->click('@nav #tab-users') - ->with('@user-users table', function (Browser $browser) { - $browser->assertElementsCount('tbody tr', 4) - ->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'jack@kolab.org') - ->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-success') - ->assertSeeIn('tbody tr:nth-child(2) td:first-child a', 'joe@kolab.org') - ->assertVisible('tbody tr:nth-child(2) td:first-child svg.text-success') - ->assertSeeIn('tbody tr:nth-child(3) td:first-child span', 'john@kolab.org') - ->assertVisible('tbody tr:nth-child(3) td:first-child svg.text-success') - ->assertSeeIn('tbody tr:nth-child(4) td:first-child a', 'ned@kolab.org') - ->assertVisible('tbody tr:nth-child(4) td:first-child svg.text-success') - ->assertMissing('tfoot'); + // Assert Shared folders tab + $browser->assertSeeIn('@nav #tab-shared-folders', 'Shared folders (2)') + ->click('@nav #tab-shared-folders') + ->with('@user-shared-folders', function (Browser $browser) { + $browser->assertElementsCount('table tbody tr', 2) + ->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'Calendar') + ->assertSeeIn('table tbody tr:nth-child(1) td:nth-child(2)', 'Calendar') + ->assertSeeIn('table tbody tr:nth-child(1) td:last-child', 'folder-event@kolab.org') + ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Contacts') + ->assertSeeIn('table tbody tr:nth-child(2) td:nth-child(2)', 'Address Book') + ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', 'folder-contact@kolab.org') + ->assertMissing('table tfoot'); }); }); // Now we go to Ned's info page, he's a controller on John's wallet $this->browse(function (Browser $browser) { $ned = $this->getTestUser('ned@kolab.org'); $ned->setSetting('greylist_enabled', 'false'); $page = new UserPage($ned->id); - $browser->click('@user-users tbody tr:nth-child(4) td:first-child a') + $browser->click('@nav #tab-users') + ->click('@user-users tbody tr:nth-child(4) td:first-child a') ->on($page); // Assert main info box content $browser->assertSeeIn('@user-info .card-title', $ned->email) ->with('@user-info form', function (Browser $browser) use ($ned) { $browser->assertSeeIn('.row:nth-child(2) label', 'ID (Created)') ->assertSeeIn('.row:nth-child(2) #userid', "{$ned->id} ({$ned->created_at})"); }); // Some tabs are loaded in background, wait a second $browser->pause(500) - ->assertElementsCount('@nav a', 8); + ->assertElementsCount('@nav a', 9); // Note: Finances tab is tested in UserFinancesTest.php $browser->assertSeeIn('@nav #tab-finances', 'Finances'); // Assert Aliases tab $browser->assertSeeIn('@nav #tab-aliases', 'Aliases (0)') ->click('@nav #tab-aliases') ->whenAvailable('@user-aliases', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'This user has no email aliases.'); }); // Assert Subscriptions tab, we expect John's discount here $browser->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (5)') ->click('@nav #tab-subscriptions') ->with('@user-subscriptions', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 5) ->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'User Mailbox') ->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '4,50 CHF/month¹') ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 5 GB') ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF/month¹') ->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Groupware Features') ->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '4,41 CHF/month¹') ->assertSeeIn('table tbody tr:nth-child(4) td:first-child', 'Activesync') ->assertSeeIn('table tbody tr:nth-child(4) td:last-child', '0,00 CHF/month¹') ->assertSeeIn('table tbody tr:nth-child(5) td:first-child', '2-Factor Authentication') ->assertSeeIn('table tbody tr:nth-child(5) td:last-child', '0,00 CHF/month¹') ->assertMissing('table tfoot') ->assertSeeIn('table + .hint', '¹ applied discount: 10% - Test voucher') ->assertSeeIn('#reset2fa', 'Reset 2-Factor Auth'); }); // We don't expect John's domains here $browser->assertSeeIn('@nav #tab-domains', 'Domains (0)') ->click('@nav #tab-domains') ->with('@user-domains', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'There are no domains in this account.'); }); // We don't expect John's users here $browser->assertSeeIn('@nav #tab-users', 'Users (0)') ->click('@nav #tab-users') ->with('@user-users', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'There are no users in this account.'); }); // We don't expect John's distribution lists here $browser->assertSeeIn('@nav #tab-distlists', 'Distribution lists (0)') ->click('@nav #tab-distlists') ->with('@user-distlists', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'There are no distribution lists in this account.'); }); // Assert Resources tab $browser->assertSeeIn('@nav #tab-resources', 'Resources (0)') ->click('@nav #tab-resources') ->with('@user-resources', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'There are no resources in this account.'); }); + // Assert Shared folders tab + $browser->assertSeeIn('@nav #tab-shared-folders', 'Shared folders (0)') + ->click('@nav #tab-shared-folders') + ->with('@user-shared-folders', function (Browser $browser) { + $browser->assertElementsCount('table tbody tr', 0) + ->assertSeeIn('table tfoot tr td', 'There are no shared folders in this account.'); + }); + // Assert Settings tab $browser->assertSeeIn('@nav #tab-settings', 'Settings') ->click('@nav #tab-settings') ->whenAvailable('@user-settings form', function (Browser $browser) { $browser->assertElementsCount('.row', 1) ->assertSeeIn('.row:first-child label', 'Greylisting') ->assertSeeIn('.row:first-child .text-danger', 'disabled'); }); }); } /** * Test editing an external email * * @depends testUserInfo2 */ public function testExternalEmail(): void { $this->browse(function (Browser $browser) { $john = $this->getTestUser('john@kolab.org'); $browser->visit(new UserPage($john->id)) ->waitFor('@user-info #external_email button') ->click('@user-info #external_email button') // Test dialog content, and closing it with Cancel button ->with(new Dialog('#email-dialog'), function (Browser $browser) { $browser->assertSeeIn('@title', 'External Email') ->assertFocused('@body input') ->assertValue('@body input', 'john.doe.external@gmail.com') ->assertSeeIn('@button-cancel', 'Cancel') ->assertSeeIn('@button-action', 'Submit') ->click('@button-cancel'); }) ->assertMissing('#email-dialog') ->click('@user-info #external_email button') // Test email validation error handling, and email update ->with(new Dialog('#email-dialog'), function (Browser $browser) { $browser->type('@body input', 'test') ->click('@button-action') ->waitFor('@body input.is-invalid') ->assertSeeIn( '@body input + .invalid-feedback', 'The external email must be a valid email address.' ) ->assertToast(Toast::TYPE_ERROR, 'Form validation error') ->type('@body input', 'test@test.com') ->click('@button-action'); }) ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.') ->assertSeeIn('@user-info #external_email a', 'test@test.com') ->click('@user-info #external_email button') ->with(new Dialog('#email-dialog'), function (Browser $browser) { $browser->assertValue('@body input', 'test@test.com') ->assertMissing('@body input.is-invalid') ->assertMissing('@body input + .invalid-feedback') ->click('@button-cancel'); }) ->assertSeeIn('@user-info #external_email a', 'test@test.com'); // $john->getSetting() may not work here as it uses internal cache // read the value form database $current_ext_email = $john->settings()->where('key', 'external_email')->first()->value; $this->assertSame('test@test.com', $current_ext_email); }); } /** * Test suspending/unsuspending the user */ public function testSuspendAndUnsuspend(): void { $this->browse(function (Browser $browser) { $john = $this->getTestUser('john@kolab.org'); $browser->visit(new UserPage($john->id)) ->assertVisible('@user-info #button-suspend') ->assertMissing('@user-info #button-unsuspend') ->click('@user-info #button-suspend') ->assertToast(Toast::TYPE_SUCCESS, 'User suspended successfully.') ->assertSeeIn('@user-info #status span.text-warning', 'Suspended') ->assertMissing('@user-info #button-suspend') ->click('@user-info #button-unsuspend') ->assertToast(Toast::TYPE_SUCCESS, 'User unsuspended successfully.') ->assertSeeIn('@user-info #status span.text-success', 'Active') ->assertVisible('@user-info #button-suspend') ->assertMissing('@user-info #button-unsuspend'); }); } /** * Test resetting 2FA for the user */ public function testReset2FA(): void { $this->browse(function (Browser $browser) { $this->deleteTestUser('userstest1@kolabnow.com'); $user = $this->getTestUser('userstest1@kolabnow.com'); $sku2fa = Sku::withEnvTenantContext()->where(['title' => '2fa'])->first(); $user->assignSku($sku2fa); SecondFactor::seed('userstest1@kolabnow.com'); $browser->visit(new UserPage($user->id)) ->click('@nav #tab-subscriptions') ->with('@user-subscriptions', function (Browser $browser) use ($sku2fa) { $browser->waitFor('#reset2fa') ->assertVisible('#sku' . $sku2fa->id); }) ->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (1)') ->click('#reset2fa') ->with(new Dialog('#reset-2fa-dialog'), function (Browser $browser) { $browser->assertSeeIn('@title', '2-Factor Authentication Reset') ->assertSeeIn('@button-cancel', 'Cancel') ->assertSeeIn('@button-action', 'Reset') ->click('@button-action'); }) ->assertToast(Toast::TYPE_SUCCESS, '2-Factor authentication reset successfully.') ->assertMissing('#sku' . $sku2fa->id) ->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (0)'); }); } /** * Test adding the beta SKU for the user */ public function testAddBetaSku(): void { $this->browse(function (Browser $browser) { $this->deleteTestUser('userstest1@kolabnow.com'); $user = $this->getTestUser('userstest1@kolabnow.com'); $sku = Sku::withEnvTenantContext()->where('title', 'beta')->first(); $browser->visit(new UserPage($user->id)) ->click('@nav #tab-subscriptions') ->waitFor('@user-subscriptions #addbetasku') ->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (0)') ->assertSeeIn('#addbetasku', 'Enable beta program') ->click('#addbetasku') ->assertToast(Toast::TYPE_SUCCESS, 'The subscription added successfully.') ->waitFor('#sku' . $sku->id) ->assertSeeIn("#sku{$sku->id} td:first-child", 'Private Beta (invitation only)') ->assertSeeIn("#sku{$sku->id} td:last-child", '0,00 CHF/month') ->assertMissing('#addbetasku') ->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (1)'); }); } } diff --git a/src/tests/Browser/SharedFolderTest.php b/src/tests/Browser/SharedFolderTest.php new file mode 100644 index 00000000..aea65f7c --- /dev/null +++ b/src/tests/Browser/SharedFolderTest.php @@ -0,0 +1,333 @@ +delete(); + $this->clearBetaEntitlements(); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + SharedFolder::whereNotIn('email', ['folder-event@kolab.org', 'folder-contact@kolab.org'])->delete(); + $this->clearBetaEntitlements(); + + parent::tearDown(); + } + + /** + * Test shared folder info page (unauthenticated) + */ + public function testInfoUnauth(): void + { + // Test that the page requires authentication + $this->browse(function (Browser $browser) { + $browser->visit('/shared-folder/abc')->on(new Home()); + }); + } + + /** + * Test shared folder list page (unauthenticated) + */ + public function testListUnauth(): void + { + // Test that the page requires authentication + $this->browse(function (Browser $browser) { + $browser->visit('/shared-folders')->on(new Home()); + }); + } + + /** + * Test shared folders list page + */ + public function testList(): void + { + // Log on the user + $this->browse(function (Browser $browser) { + $browser->visit(new Home()) + ->submitLogon('john@kolab.org', 'simple123', true) + ->on(new Dashboard()) + ->assertMissing('@links .link-shared-folders'); + }); + + // Test that shared folders lists page is not accessible without the 'beta-shared-folders' entitlement + $this->browse(function (Browser $browser) { + $browser->visit('/shared-folders') + ->assertErrorPage(403); + }); + + // Add beta+beta-shared-folders entitlements + $john = $this->getTestUser('john@kolab.org'); + $this->addBetaEntitlement($john, 'beta-shared-folders'); + // Make sure the first folder is active + $folder = $this->getTestSharedFolder('folder-event@kolab.org'); + $folder->status = SharedFolder::STATUS_NEW | SharedFolder::STATUS_ACTIVE + | SharedFolder::STATUS_LDAP_READY | SharedFolder::STATUS_IMAP_READY; + $folder->save(); + + // Test shared folders lists page + $this->browse(function (Browser $browser) { + $browser->visit(new Dashboard()) + ->assertSeeIn('@links .link-shared-folders', 'Shared folders') + ->click('@links .link-shared-folders') + ->on(new SharedFolderList()) + ->whenAvailable('@table', function (Browser $browser) { + $browser->waitFor('tbody tr') + ->assertSeeIn('thead tr th:nth-child(1)', 'Name') + ->assertSeeIn('thead tr th:nth-child(2)', 'Type') + ->assertSeeIn('thead tr th:nth-child(3)', 'Email Address') + ->assertElementsCount('tbody tr', 2) + ->assertSeeIn('tbody tr:nth-child(1) td:nth-child(1) a', 'Calendar') + ->assertSeeIn('tbody tr:nth-child(1) td:nth-child(2)', 'Calendar') + ->assertSeeIn('tbody tr:nth-child(1) td:nth-child(3) a', 'folder-event@kolab.org') + ->assertText('tbody tr:nth-child(1) td:nth-child(1) svg.text-success title', 'Active') + ->assertSeeIn('tbody tr:nth-child(2) td:nth-child(1) a', 'Contacts') + ->assertSeeIn('tbody tr:nth-child(2) td:nth-child(2)', 'Address Book') + ->assertSeeIn('tbody tr:nth-child(2) td:nth-child(3) a', 'folder-contact@kolab.org') + ->assertMissing('tfoot'); + }); + }); + } + + /** + * Test shared folder creation/editing/deleting + * + * @depends testList + */ + public function testCreateUpdateDelete(): void + { + // Test that the page is not available accessible without the 'beta-shared-folders' entitlement + $this->browse(function (Browser $browser) { + $browser->visit('/shared-folder/new') + ->assertErrorPage(403); + }); + + // Add beta+beta-shared-folders entitlements + $john = $this->getTestUser('john@kolab.org'); + $this->addBetaEntitlement($john, 'beta-shared-folders'); + + $this->browse(function (Browser $browser) { + // Create a folder + $browser->visit(new SharedFolderList()) + ->assertSeeIn('button.create-folder', 'Create folder') + ->click('button.create-folder') + ->on(new SharedFolderInfo()) + ->assertSeeIn('#folder-info .card-title', 'New shared folder') + ->assertSeeIn('@nav #tab-general', 'General') + ->assertMissing('@nav #tab-settings') + ->with('@general', function (Browser $browser) { + // Assert form content + $browser->assertMissing('#status') + ->assertFocused('#name') + ->assertSeeIn('div.row:nth-child(1) label', 'Name') + ->assertValue('div.row:nth-child(1) input[type=text]', '') + ->assertSeeIn('div.row:nth-child(2) label', 'Type') + ->assertSelectHasOptions( + 'div.row:nth-child(2) select', + ['mail', 'event', 'task', 'contact', 'note', 'file'] + ) + ->assertValue('div.row:nth-child(2) select', 'mail') + ->assertSeeIn('div.row:nth-child(3) label', 'Domain') + ->assertSelectHasOptions('div.row:nth-child(3) select', ['kolab.org']) + ->assertValue('div.row:nth-child(3) select', 'kolab.org') + ->assertSeeIn('button[type=submit]', 'Submit'); + }) + // Test error conditions + ->type('#name', str_repeat('A', 192)) + ->click('@general button[type=submit]') + ->waitFor('#name + .invalid-feedback') + ->assertSeeIn('#name + .invalid-feedback', 'The name may not be greater than 191 characters.') + ->assertFocused('#name') + ->assertToast(Toast::TYPE_ERROR, 'Form validation error') + // Test successful folder creation + ->type('#name', 'Test Folder') + ->select('#type', 'event') + ->click('@general button[type=submit]') + ->assertToast(Toast::TYPE_SUCCESS, 'Shared folder created successfully.') + ->on(new SharedFolderList()) + ->assertElementsCount('@table tbody tr', 3); + + // Test folder update + $browser->click('@table tr:nth-child(3) td:first-child a') + ->on(new SharedFolderInfo()) + ->assertSeeIn('#folder-info .card-title', 'Shared folder') + ->with('@general', function (Browser $browser) { + // Assert form content + $browser->assertFocused('#name') + ->assertSeeIn('div.row:nth-child(1) label', 'Status') + ->assertSeeIn('div.row:nth-child(1) span.text-danger', 'Not Ready') + ->assertSeeIn('div.row:nth-child(2) label', 'Name') + ->assertValue('div.row:nth-child(2) input[type=text]', 'Test Folder') + ->assertSeeIn('div.row:nth-child(3) label', 'Type') + ->assertSelected('div.row:nth-child(3) select:disabled', 'event') + ->assertSeeIn('div.row:nth-child(4) label', 'Email Address') + ->assertAttributeRegExp( + 'div.row:nth-child(4) input[type=text]:disabled', + 'value', + '/^event-[0-9]+@kolab\.org$/' + ) + ->assertSeeIn('button[type=submit]', 'Submit'); + }) + // Test error handling + ->type('#name', str_repeat('A', 192)) + ->click('@general button[type=submit]') + ->waitFor('#name + .invalid-feedback') + ->assertSeeIn('#name + .invalid-feedback', 'The name may not be greater than 191 characters.') + ->assertVisible('#name.is-invalid') + ->assertFocused('#name') + ->assertToast(Toast::TYPE_ERROR, 'Form validation error') + // Test successful update + ->type('#name', 'Test Folder Update') + ->click('@general button[type=submit]') + ->assertToast(Toast::TYPE_SUCCESS, 'Shared folder updated successfully.') + ->on(new SharedFolderList()) + ->assertElementsCount('@table tbody tr', 3) + ->assertSeeIn('@table tr:nth-child(3) td:first-child a', 'Test Folder Update'); + + $this->assertSame(1, SharedFolder::where('name', 'Test Folder Update')->count()); + + // Test folder deletion + $browser->click('@table tr:nth-child(3) td:first-child a') + ->on(new SharedFolderInfo()) + ->assertSeeIn('button.button-delete', 'Delete folder') + ->click('button.button-delete') + ->assertToast(Toast::TYPE_SUCCESS, 'Shared folder deleted successfully.') + ->on(new SharedFolderList()) + ->assertElementsCount('@table tbody tr', 2); + + $this->assertNull(SharedFolder::where('name', 'Test Folder Update')->first()); + }); + } + + /** + * Test shared folder status + * + * @depends testList + */ + public function testStatus(): void + { + $john = $this->getTestUser('john@kolab.org'); + $this->addBetaEntitlement($john, 'beta-shared-folders'); + $folder = $this->getTestSharedFolder('folder-event@kolab.org'); + $folder->status = SharedFolder::STATUS_NEW | SharedFolder::STATUS_ACTIVE | SharedFolder::STATUS_LDAP_READY; + $folder->created_at = \now(); + $folder->save(); + + $this->assertFalse($folder->isImapReady()); + + $this->browse(function ($browser) use ($folder) { + // Test auto-refresh + $browser->visit('/shared-folder/' . $folder->id) + ->on(new SharedFolderInfo()) + ->with(new Status(), function ($browser) { + $browser->assertSeeIn('@body', 'We are preparing the shared folder') + ->assertProgress(85, 'Creating a shared folder...', 'pending') + ->assertMissing('@refresh-button') + ->assertMissing('@refresh-text') + ->assertMissing('#status-link') + ->assertMissing('#status-verify'); + }); + + $folder->status |= SharedFolder::STATUS_IMAP_READY; + $folder->save(); + + // Test Verify button + $browser->waitUntilMissing('@status', 10); + }); + + // TODO: Test all shared folder statuses on the list + } + + /** + * Test shared folder settings + */ + public function testSettings(): void + { + $john = $this->getTestUser('john@kolab.org'); + $this->addBetaEntitlement($john, 'beta-shared-folders'); + $folder = $this->getTestSharedFolder('folder-event@kolab.org'); + $folder->setSetting('acl', null); + + $this->browse(function ($browser) use ($folder) { + $aclInput = new AclInput('@settings #acl'); + // Test auto-refresh + $browser->visit('/shared-folder/' . $folder->id) + ->on(new SharedFolderInfo()) + ->assertSeeIn('@nav #tab-general', 'General') + ->assertSeeIn('@nav #tab-settings', 'Settings') + ->click('@nav #tab-settings') + ->with('@settings form', function (Browser $browser) { + // Assert form content + $browser->assertSeeIn('div.row:nth-child(1) label', 'Access rights') + ->assertSeeIn('div.row:nth-child(1) #acl-hint', 'permissions') + ->assertSeeIn('button[type=submit]', 'Submit'); + }) + // Test the AclInput widget + ->with($aclInput, function (Browser $browser) { + $browser->assertAclValue([]) + ->addAclEntry('anyone, read-only') + ->addAclEntry('test, read-write') + ->addAclEntry('john@kolab.org, full') + ->assertAclValue([ + 'anyone, read-only', + 'test, read-write', + 'john@kolab.org, full', + ]); + }) + // Test error handling + ->click('@settings button[type=submit]') + ->with($aclInput, function (Browser $browser) { + $browser->assertFormError(2, 'The specified email address is invalid.'); + }) + ->assertToast(Toast::TYPE_ERROR, 'Form validation error') + // Test successful update + ->with($aclInput, function (Browser $browser) { + $browser->removeAclEntry(2) + ->assertAclValue([ + 'anyone, read-only', + 'john@kolab.org, full', + ]) + ->updateAclEntry(2, 'jack@kolab.org, read-write') + ->assertAclValue([ + 'anyone, read-only', + 'jack@kolab.org, read-write', + ]); + }) + ->click('@settings button[type=submit]') + ->assertToast(Toast::TYPE_SUCCESS, 'Shared folder settings updated successfully.') + ->assertMissing('.invalid-feedback') + // Refresh the page and check if everything was saved + ->refresh() + ->on(new SharedFolderInfo()) + ->click('@nav #tab-settings') + ->with($aclInput, function (Browser $browser) { + $browser->assertAclValue([ + 'anyone, read-only', + 'jack@kolab.org, read-write', + ]); + }); + }); + } +} diff --git a/src/tests/Browser/UsersTest.php b/src/tests/Browser/UsersTest.php index e48d25ed..89b22525 100644 --- a/src/tests/Browser/UsersTest.php +++ b/src/tests/Browser/UsersTest.php @@ -1,797 +1,806 @@ 'John', 'last_name' => 'Doe', 'organization' => 'Kolab Developers', ]; /** * {@inheritDoc} */ public function setUp(): void { parent::setUp(); $this->deleteTestUser('julia.roberts@kolab.org'); $john = User::where('email', 'john@kolab.org')->first(); $john->setSettings($this->profile); UserAlias::where('user_id', $john->id) ->where('alias', 'john.test@kolab.org')->delete(); $activesync_sku = Sku::withEnvTenantContext()->where('title', 'activesync')->first(); $storage_sku = Sku::withEnvTenantContext()->where('title', 'storage')->first(); Entitlement::where('entitleable_id', $john->id)->where('sku_id', $activesync_sku->id)->delete(); Entitlement::where('cost', '>=', 5000)->delete(); Entitlement::where('cost', '=', 25)->where('sku_id', $storage_sku->id)->delete(); $wallet = $john->wallets()->first(); $wallet->discount()->dissociate(); $wallet->currency = 'CHF'; $wallet->save(); $this->clearBetaEntitlements(); $this->clearMeetEntitlements(); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('julia.roberts@kolab.org'); $john = User::where('email', 'john@kolab.org')->first(); $john->setSettings($this->profile); UserAlias::where('user_id', $john->id) ->where('alias', 'john.test@kolab.org')->delete(); $activesync_sku = Sku::withEnvTenantContext()->where('title', 'activesync')->first(); $storage_sku = Sku::withEnvTenantContext()->where('title', 'storage')->first(); Entitlement::where('entitleable_id', $john->id)->where('sku_id', $activesync_sku->id)->delete(); Entitlement::where('cost', '>=', 5000)->delete(); Entitlement::where('cost', '=', 25)->where('sku_id', $storage_sku->id)->delete(); $wallet = $john->wallets()->first(); $wallet->discount()->dissociate(); $wallet->save(); $this->clearBetaEntitlements(); $this->clearMeetEntitlements(); parent::tearDown(); } /** * Test user account editing page (not profile page) */ public function testInfo(): void { $this->browse(function (Browser $browser) { $user = User::where('email', 'john@kolab.org')->first(); // Test that the page requires authentication $browser->visit('/user/' . $user->id) ->on(new Home()) ->submitLogon('john@kolab.org', 'simple123', false) ->on(new UserInfo()) ->assertSeeIn('#user-info .card-title', 'User account') ->with('@general', function (Browser $browser) { // Assert form content $browser->assertSeeIn('div.row:nth-child(1) label', 'Status') ->assertSeeIn('div.row:nth-child(1) #status', 'Active') ->assertFocused('div.row:nth-child(2) input') ->assertSeeIn('div.row:nth-child(2) label', 'First Name') ->assertValue('div.row:nth-child(2) input[type=text]', $this->profile['first_name']) ->assertSeeIn('div.row:nth-child(3) label', 'Last Name') ->assertValue('div.row:nth-child(3) input[type=text]', $this->profile['last_name']) ->assertSeeIn('div.row:nth-child(4) label', 'Organization') ->assertValue('div.row:nth-child(4) input[type=text]', $this->profile['organization']) ->assertSeeIn('div.row:nth-child(5) label', 'Email') ->assertValue('div.row:nth-child(5) input[type=text]', 'john@kolab.org') ->assertDisabled('div.row:nth-child(5) input[type=text]') ->assertSeeIn('div.row:nth-child(6) label', 'Email Aliases') ->assertVisible('div.row:nth-child(6) .list-input') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->assertListInputValue(['john.doe@kolab.org']) ->assertValue('@input', ''); }) ->assertSeeIn('div.row:nth-child(7) label', 'Password') ->assertValue('div.row:nth-child(7) input[type=password]', '') ->assertSeeIn('div.row:nth-child(8) label', 'Confirm Password') ->assertValue('div.row:nth-child(8) input[type=password]', '') ->assertSeeIn('button[type=submit]', 'Submit') // Clear some fields and submit ->vueClear('#first_name') ->vueClear('#last_name') ->click('button[type=submit]'); }) ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.') ->on(new UserList()) ->click('@table tr:nth-child(3) a') ->on(new UserInfo()) ->assertSeeIn('#user-info .card-title', 'User account') ->with('@general', function (Browser $browser) { // Test error handling (password) $browser->type('#password', 'aaaaaa') ->vueClear('#password_confirmation') ->click('button[type=submit]') ->waitFor('#password + .invalid-feedback') ->assertSeeIn('#password + .invalid-feedback', 'The password confirmation does not match.') ->assertFocused('#password') ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); // TODO: Test password change // Test form error handling (aliases) $browser->vueClear('#password') ->vueClear('#password_confirmation') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->addListEntry('invalid address'); }) ->click('button[type=submit]') ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); $browser->with(new ListInput('#aliases'), function (Browser $browser) { $browser->assertFormError(2, 'The specified alias is invalid.', false); }); // Test adding aliases $browser->with(new ListInput('#aliases'), function (Browser $browser) { $browser->removeListEntry(2) ->addListEntry('john.test@kolab.org'); }) ->click('button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.'); }) ->on(new UserList()) ->click('@table tr:nth-child(3) a') ->on(new UserInfo()); $john = User::where('email', 'john@kolab.org')->first(); $alias = UserAlias::where('user_id', $john->id)->where('alias', 'john.test@kolab.org')->first(); $this->assertTrue(!empty($alias)); // Test subscriptions $browser->with('@general', function (Browser $browser) { $browser->assertSeeIn('div.row:nth-child(9) label', 'Subscriptions') ->assertVisible('@skus.row:nth-child(9)') ->with('@skus', function ($browser) { $browser->assertElementsCount('tbody tr', 6) // Mailbox SKU ->assertSeeIn('tbody tr:nth-child(1) td.name', 'User Mailbox') ->assertSeeIn('tbody tr:nth-child(1) td.price', '5,00 CHF/month') ->assertChecked('tbody tr:nth-child(1) td.selection input') ->assertDisabled('tbody tr:nth-child(1) td.selection input') ->assertTip( 'tbody tr:nth-child(1) td.buttons button', 'Just a mailbox' ) // Storage SKU ->assertSeeIn('tbody tr:nth-child(2) td.name', 'Storage Quota') ->assertSeeIn('tr:nth-child(2) td.price', '0,00 CHF/month') ->assertChecked('tbody tr:nth-child(2) td.selection input') ->assertDisabled('tbody tr:nth-child(2) td.selection input') ->assertTip( 'tbody tr:nth-child(2) td.buttons button', 'Some wiggle room' ) ->with(new QuotaInput('tbody tr:nth-child(2) .range-input'), function ($browser) { $browser->assertQuotaValue(5)->setQuotaValue(6); }) ->assertSeeIn('tr:nth-child(2) td.price', '0,25 CHF/month') // groupware SKU ->assertSeeIn('tbody tr:nth-child(3) td.name', 'Groupware Features') ->assertSeeIn('tbody tr:nth-child(3) td.price', '4,90 CHF/month') ->assertChecked('tbody tr:nth-child(3) td.selection input') ->assertEnabled('tbody tr:nth-child(3) td.selection input') ->assertTip( 'tbody tr:nth-child(3) td.buttons button', 'Groupware functions like Calendar, Tasks, Notes, etc.' ) // ActiveSync SKU ->assertSeeIn('tbody tr:nth-child(4) td.name', 'Activesync') ->assertSeeIn('tbody tr:nth-child(4) td.price', '0,00 CHF/month') ->assertNotChecked('tbody tr:nth-child(4) td.selection input') ->assertEnabled('tbody tr:nth-child(4) td.selection input') ->assertTip( 'tbody tr:nth-child(4) td.buttons button', 'Mobile synchronization' ) // 2FA SKU ->assertSeeIn('tbody tr:nth-child(5) td.name', '2-Factor Authentication') ->assertSeeIn('tbody tr:nth-child(5) td.price', '0,00 CHF/month') ->assertNotChecked('tbody tr:nth-child(5) td.selection input') ->assertEnabled('tbody tr:nth-child(5) td.selection input') ->assertTip( 'tbody tr:nth-child(5) td.buttons button', 'Two factor authentication for webmail and administration panel' ) // Meet SKU ->assertSeeIn('tbody tr:nth-child(6) td.name', 'Voice & Video Conferencing (public beta)') ->assertSeeIn('tbody tr:nth-child(6) td.price', '0,00 CHF/month') ->assertNotChecked('tbody tr:nth-child(6) td.selection input') ->assertEnabled('tbody tr:nth-child(6) td.selection input') ->assertTip( 'tbody tr:nth-child(6) td.buttons button', 'Video conferencing tool' ) ->click('tbody tr:nth-child(4) td.selection input'); }) ->assertMissing('@skus table + .hint') ->click('button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.'); }) ->on(new UserList()) ->click('@table tr:nth-child(3) a') ->on(new UserInfo()); $expected = ['activesync', 'groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage']; $this->assertEntitlements($john, $expected); // Test subscriptions interaction $browser->with('@general', function (Browser $browser) { $browser->with('@skus', function ($browser) { // Uncheck 'groupware', expect activesync unchecked $browser->click('#sku-input-groupware') ->assertNotChecked('#sku-input-groupware') ->assertNotChecked('#sku-input-activesync') ->assertEnabled('#sku-input-activesync') ->assertNotReadonly('#sku-input-activesync') // Check 'activesync', expect an alert ->click('#sku-input-activesync') ->assertDialogOpened('Activesync requires Groupware Features.') ->acceptDialog() ->assertNotChecked('#sku-input-activesync') // Check 'meet', expect an alert ->click('#sku-input-meet') ->assertDialogOpened('Voice & Video Conferencing (public beta) requires Groupware Features.') ->acceptDialog() ->assertNotChecked('#sku-input-meet') // Check '2FA', expect 'activesync' unchecked and readonly ->click('#sku-input-2fa') ->assertChecked('#sku-input-2fa') ->assertNotChecked('#sku-input-activesync') ->assertReadonly('#sku-input-activesync') // Uncheck '2FA' ->click('#sku-input-2fa') ->assertNotChecked('#sku-input-2fa') ->assertNotReadonly('#sku-input-activesync'); }); }); }); } /** * Test user settings tab * * @depends testInfo */ public function testUserSettings(): void { $john = $this->getTestUser('john@kolab.org'); $john->setSetting('greylist_enabled', null); $this->browse(function (Browser $browser) use ($john) { $browser->visit('/user/' . $john->id) ->on(new UserInfo()) ->assertElementsCount('@nav a', 2) ->assertSeeIn('@nav #tab-general', 'General') ->assertSeeIn('@nav #tab-settings', 'Settings') ->click('@nav #tab-settings') ->with('#settings form', function (Browser $browser) { $browser->assertSeeIn('div.row:nth-child(1) label', 'Greylisting') ->click('div.row:nth-child(1) input[type=checkbox]:checked') ->click('button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'User settings updated successfully.'); }); }); $this->assertSame('false', $john->fresh()->getSetting('greylist_enabled')); } /** * Test user adding page * * @depends testInfo */ public function testNewUser(): void { $this->browse(function (Browser $browser) { $browser->visit(new UserList()) ->assertSeeIn('button.create-user', 'Create user') ->click('button.create-user') ->on(new UserInfo()) ->assertSeeIn('#user-info .card-title', 'New user account') ->with('@general', function (Browser $browser) { // Assert form content $browser->assertFocused('div.row:nth-child(1) input') ->assertSeeIn('div.row:nth-child(1) label', 'First Name') ->assertValue('div.row:nth-child(1) input[type=text]', '') ->assertSeeIn('div.row:nth-child(2) label', 'Last Name') ->assertValue('div.row:nth-child(2) input[type=text]', '') ->assertSeeIn('div.row:nth-child(3) label', 'Organization') ->assertValue('div.row:nth-child(3) input[type=text]', '') ->assertSeeIn('div.row:nth-child(4) label', 'Email') ->assertValue('div.row:nth-child(4) input[type=text]', '') ->assertEnabled('div.row:nth-child(4) input[type=text]') ->assertSeeIn('div.row:nth-child(5) label', 'Email Aliases') ->assertVisible('div.row:nth-child(5) .list-input') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->assertListInputValue([]) ->assertValue('@input', ''); }) ->assertSeeIn('div.row:nth-child(6) label', 'Password') ->assertValue('div.row:nth-child(6) input[type=password]', '') ->assertSeeIn('div.row:nth-child(7) label', 'Confirm Password') ->assertValue('div.row:nth-child(7) input[type=password]', '') ->assertSeeIn('div.row:nth-child(8) label', 'Package') // assert packages list widget, select "Lite Account" ->with('@packages', function ($browser) { $browser->assertElementsCount('tbody tr', 2) ->assertSeeIn('tbody tr:nth-child(1)', 'Groupware Account') ->assertSeeIn('tbody tr:nth-child(2)', 'Lite Account') ->assertSeeIn('tbody tr:nth-child(1) .price', '9,90 CHF/month') ->assertSeeIn('tbody tr:nth-child(2) .price', '5,00 CHF/month') ->assertChecked('tbody tr:nth-child(1) input') ->click('tbody tr:nth-child(2) input') ->assertNotChecked('tbody tr:nth-child(1) input') ->assertChecked('tbody tr:nth-child(2) input'); }) ->assertMissing('@packages table + .hint') ->assertSeeIn('button[type=submit]', 'Submit'); // Test browser-side required fields and error handling $browser->click('button[type=submit]') ->assertFocused('#email') ->type('#email', 'invalid email') ->click('button[type=submit]') ->assertFocused('#password') ->type('#password', 'simple123') ->click('button[type=submit]') ->assertFocused('#password_confirmation') ->type('#password_confirmation', 'simple') ->click('button[type=submit]') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') ->assertSeeIn('#email + .invalid-feedback', 'The specified email is invalid.') ->assertSeeIn('#password + .invalid-feedback', 'The password confirmation does not match.'); }); // Test form error handling (aliases) $browser->with('@general', function (Browser $browser) { $browser->type('#email', 'julia.roberts@kolab.org') ->type('#password_confirmation', 'simple123') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->addListEntry('invalid address'); }) ->click('button[type=submit]') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->assertFormError(1, 'The specified alias is invalid.', false); }); }); // Successful account creation $browser->with('@general', function (Browser $browser) { $browser->type('#first_name', 'Julia') ->type('#last_name', 'Roberts') ->type('#organization', 'Test Org') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->removeListEntry(1) ->addListEntry('julia.roberts2@kolab.org'); }) ->click('button[type=submit]'); }) ->assertToast(Toast::TYPE_SUCCESS, 'User created successfully.') // check redirection to users list ->on(new UserList()) ->whenAvailable('@table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 5) ->assertSeeIn('tbody tr:nth-child(4) a', 'julia.roberts@kolab.org'); }); $julia = User::where('email', 'julia.roberts@kolab.org')->first(); $alias = UserAlias::where('user_id', $julia->id)->where('alias', 'julia.roberts2@kolab.org')->first(); $this->assertTrue(!empty($alias)); $this->assertEntitlements($julia, ['mailbox', 'storage', 'storage', 'storage', 'storage', 'storage']); $this->assertSame('Julia', $julia->getSetting('first_name')); $this->assertSame('Roberts', $julia->getSetting('last_name')); $this->assertSame('Test Org', $julia->getSetting('organization')); // Some additional tests for the list input widget $browser->click('@table tbody tr:nth-child(4) a') ->on(new UserInfo()) ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->assertListInputValue(['julia.roberts2@kolab.org']) ->addListEntry('invalid address') ->type('.input-group:nth-child(2) input', '@kolab.org') ->keys('.input-group:nth-child(2) input', '{enter}'); }) // TODO: Investigate why this click does not work, for now we // submit the form with Enter key above //->click('@general button[type=submit]') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->assertVisible('.input-group:nth-child(2) input.is-invalid') ->assertVisible('.input-group:nth-child(3) input.is-invalid') ->type('.input-group:nth-child(2) input', 'julia.roberts3@kolab.org') ->type('.input-group:nth-child(3) input', 'julia.roberts4@kolab.org') ->keys('.input-group:nth-child(3) input', '{enter}'); }) // TODO: Investigate why this click does not work, for now we // submit the form with Enter key above //->click('@general button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.'); $julia = User::where('email', 'julia.roberts@kolab.org')->first(); $aliases = $julia->aliases()->orderBy('alias')->get()->pluck('alias')->all(); $this->assertSame(['julia.roberts3@kolab.org', 'julia.roberts4@kolab.org'], $aliases); }); } /** * Test user delete * * @depends testNewUser */ public function testDeleteUser(): void { // First create a new user $john = $this->getTestUser('john@kolab.org'); $julia = $this->getTestUser('julia.roberts@kolab.org'); $package_kolab = \App\Package::where('title', 'kolab')->first(); $john->assignPackage($package_kolab, $julia); // Test deleting non-controller user $this->browse(function (Browser $browser) use ($julia) { $browser->visit('/user/' . $julia->id) ->on(new UserInfo()) ->assertSeeIn('button.button-delete', 'Delete user') ->click('button.button-delete') ->with(new Dialog('#delete-warning'), function (Browser $browser) { $browser->assertSeeIn('@title', 'Delete julia.roberts@kolab.org') ->assertFocused('@button-cancel') ->assertSeeIn('@button-cancel', 'Cancel') ->assertSeeIn('@button-action', 'Delete') ->click('@button-cancel'); }) ->waitUntilMissing('#delete-warning') ->click('button.button-delete') ->with(new Dialog('#delete-warning'), function (Browser $browser) { $browser->click('@button-action'); }) ->waitUntilMissing('#delete-warning') ->assertToast(Toast::TYPE_SUCCESS, 'User deleted successfully.') ->on(new UserList()) ->with('@table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 4) ->assertSeeIn('tbody tr:nth-child(1) a', 'jack@kolab.org') ->assertSeeIn('tbody tr:nth-child(2) a', 'joe@kolab.org') ->assertSeeIn('tbody tr:nth-child(3) a', 'john@kolab.org') ->assertSeeIn('tbody tr:nth-child(4) a', 'ned@kolab.org'); }); $julia = User::where('email', 'julia.roberts@kolab.org')->first(); $this->assertTrue(empty($julia)); }); // Test that non-controller user cannot see/delete himself on the users list $this->browse(function (Browser $browser) { $browser->visit('/logout') ->on(new Home()) ->submitLogon('jack@kolab.org', 'simple123', true) ->visit('/users') ->assertErrorPage(403); }); // Test that controller user (Ned) can see all the users $this->browse(function (Browser $browser) { $browser->visit('/logout') ->on(new Home()) ->submitLogon('ned@kolab.org', 'simple123', true) ->visit(new UserList()) ->whenAvailable('@table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 4); }); // TODO: Test the delete action in details }); // TODO: Test what happens with the logged in user session after he's been deleted by another user } /** * Test discounted sku/package prices in the UI */ public function testDiscountedPrices(): void { // Add 10% discount $discount = Discount::where('code', 'TEST')->first(); $john = User::where('email', 'john@kolab.org')->first(); $wallet = $john->wallet(); $wallet->discount()->associate($discount); $wallet->save(); // SKUs on user edit page $this->browse(function (Browser $browser) { $browser->visit('/logout') ->on(new Home()) ->submitLogon('john@kolab.org', 'simple123', true) ->visit(new UserList()) ->waitFor('@table tr:nth-child(2)') ->click('@table tr:nth-child(2) a') // joe@kolab.org ->on(new UserInfo()) ->with('@general', function (Browser $browser) { $browser->whenAvailable('@skus', function (Browser $browser) { $quota_input = new QuotaInput('tbody tr:nth-child(2) .range-input'); $browser->waitFor('tbody tr') ->assertElementsCount('tbody tr', 6) // Mailbox SKU ->assertSeeIn('tbody tr:nth-child(1) td.price', '4,50 CHF/month¹') // Storage SKU ->assertSeeIn('tr:nth-child(2) td.price', '0,00 CHF/month¹') ->with($quota_input, function (Browser $browser) { $browser->setQuotaValue(100); }) ->assertSeeIn('tr:nth-child(2) td.price', '21,37 CHF/month¹') // groupware SKU ->assertSeeIn('tbody tr:nth-child(3) td.price', '4,41 CHF/month¹') // ActiveSync SKU ->assertSeeIn('tbody tr:nth-child(4) td.price', '0,00 CHF/month¹') // 2FA SKU ->assertSeeIn('tbody tr:nth-child(5) td.price', '0,00 CHF/month¹'); }) ->assertSeeIn('@skus table + .hint', '¹ applied discount: 10% - Test voucher'); }); }); // Packages on new user page $this->browse(function (Browser $browser) { $browser->visit(new UserList()) ->click('button.create-user') ->on(new UserInfo()) ->with('@general', function (Browser $browser) { $browser->whenAvailable('@packages', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 2) ->assertSeeIn('tbody tr:nth-child(1) .price', '8,91 CHF/month¹') // Groupware ->assertSeeIn('tbody tr:nth-child(2) .price', '4,50 CHF/month¹'); // Lite }) ->assertSeeIn('@packages table + .hint', '¹ applied discount: 10% - Test voucher'); }); }); // Test using entitlement cost instead of the SKU cost $this->browse(function (Browser $browser) use ($wallet) { $joe = User::where('email', 'joe@kolab.org')->first(); $beta_sku = Sku::withEnvTenantContext()->where('title', 'beta')->first(); $storage_sku = Sku::withEnvTenantContext()->where('title', 'storage')->first(); // Add an extra storage and beta entitlement with different prices Entitlement::create([ 'wallet_id' => $wallet->id, 'sku_id' => $beta_sku->id, 'cost' => 5010, 'entitleable_id' => $joe->id, 'entitleable_type' => User::class ]); Entitlement::create([ 'wallet_id' => $wallet->id, 'sku_id' => $storage_sku->id, 'cost' => 5000, 'entitleable_id' => $joe->id, 'entitleable_type' => User::class ]); $browser->visit('/user/' . $joe->id) ->on(new UserInfo()) ->with('@general', function (Browser $browser) { $browser->whenAvailable('@skus', function (Browser $browser) { $quota_input = new QuotaInput('tbody tr:nth-child(2) .range-input'); $browser->waitFor('tbody tr') // Beta SKU ->assertSeeIn('tbody tr:nth-child(7) td.price', '45,09 CHF/month¹') // Storage SKU ->assertSeeIn('tr:nth-child(2) td.price', '45,00 CHF/month¹') ->with($quota_input, function (Browser $browser) { $browser->setQuotaValue(7); }) ->assertSeeIn('tr:nth-child(2) td.price', '45,22 CHF/month¹') ->with($quota_input, function (Browser $browser) { $browser->setQuotaValue(5); }) ->assertSeeIn('tr:nth-child(2) td.price', '0,00 CHF/month¹'); }) ->assertSeeIn('@skus table + .hint', '¹ applied discount: 10% - Test voucher'); }); }); } /** * Test non-default currency in the UI */ public function testCurrency(): void { // Add 10% discount $john = User::where('email', 'john@kolab.org')->first(); $wallet = $john->wallet(); $wallet->balance = -1000; $wallet->currency = 'EUR'; $wallet->save(); // On Dashboard and the wallet page $this->browse(function (Browser $browser) { $browser->visit('/logout') ->on(new Home()) ->submitLogon('john@kolab.org', 'simple123', true) ->on(new Dashboard()) ->assertSeeIn('@links .link-wallet .badge', '-10,00 €') ->click('@links .link-wallet') ->on(new WalletPage()) ->assertSeeIn('#wallet .card-title', 'Account balance -10,00 €'); }); // SKUs on user edit page $this->browse(function (Browser $browser) { $browser->visit(new UserList()) ->waitFor('@table tr:nth-child(2)') ->click('@table tr:nth-child(2) a') // joe@kolab.org ->on(new UserInfo()) ->with('@general', function (Browser $browser) { $browser->whenAvailable('@skus', function (Browser $browser) { $quota_input = new QuotaInput('tbody tr:nth-child(2) .range-input'); $browser->waitFor('tbody tr') ->assertElementsCount('tbody tr', 6) // Mailbox SKU ->assertSeeIn('tbody tr:nth-child(1) td.price', '5,00 €/month') // Storage SKU ->assertSeeIn('tr:nth-child(2) td.price', '0,00 €/month') ->with($quota_input, function (Browser $browser) { $browser->setQuotaValue(100); }) ->assertSeeIn('tr:nth-child(2) td.price', '23,75 €/month'); }); }); }); // Packages on new user page $this->browse(function (Browser $browser) { $browser->visit(new UserList()) ->click('button.create-user') ->on(new UserInfo()) ->with('@general', function (Browser $browser) { $browser->whenAvailable('@packages', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 2) ->assertSeeIn('tbody tr:nth-child(1) .price', '9,90 €/month') // Groupware ->assertSeeIn('tbody tr:nth-child(2) .price', '5,00 €/month'); // Lite }); }); }); } /** * Test beta entitlements * * @depends testInfo */ public function testBetaEntitlements(): void { $this->browse(function (Browser $browser) { $john = User::where('email', 'john@kolab.org')->first(); $sku = Sku::withEnvTenantContext()->where('title', 'beta')->first(); $john->assignSku($sku); $browser->visit('/user/' . $john->id) ->on(new UserInfo()) ->with('@skus', function ($browser) { - $browser->assertElementsCount('tbody tr', 9) + $browser->assertElementsCount('tbody tr', 10) // Meet SKU ->assertSeeIn('tbody tr:nth-child(6) td.name', 'Voice & Video Conferencing (public beta)') ->assertSeeIn('tr:nth-child(6) td.price', '0,00 CHF/month') ->assertNotChecked('tbody tr:nth-child(6) td.selection input') ->assertEnabled('tbody tr:nth-child(6) td.selection input') ->assertTip( 'tbody tr:nth-child(6) td.buttons button', 'Video conferencing tool' ) // Beta SKU ->assertSeeIn('tbody tr:nth-child(7) td.name', 'Private Beta (invitation only)') ->assertSeeIn('tbody tr:nth-child(7) td.price', '0,00 CHF/month') ->assertChecked('tbody tr:nth-child(7) td.selection input') ->assertEnabled('tbody tr:nth-child(7) td.selection input') ->assertTip( 'tbody tr:nth-child(7) td.buttons button', 'Access to the private beta program subscriptions' ) // Resources SKU ->assertSeeIn('tbody tr:nth-child(8) td.name', 'Calendaring resources') ->assertSeeIn('tr:nth-child(8) td.price', '0,00 CHF/month') ->assertNotChecked('tbody tr:nth-child(8) td.selection input') ->assertEnabled('tbody tr:nth-child(8) td.selection input') ->assertTip( 'tbody tr:nth-child(8) td.buttons button', 'Access to calendaring resources' ) - // Distlist SKU - ->assertSeeIn('tbody tr:nth-child(9) td.name', 'Distribution lists') + // Shared folders SKU + ->assertSeeIn('tbody tr:nth-child(9) td.name', 'Shared folders') ->assertSeeIn('tr:nth-child(9) td.price', '0,00 CHF/month') ->assertNotChecked('tbody tr:nth-child(9) td.selection input') ->assertEnabled('tbody tr:nth-child(9) td.selection input') ->assertTip( 'tbody tr:nth-child(9) td.buttons button', + 'Access to shared folders' + ) + // Distlist SKU + ->assertSeeIn('tbody tr:nth-child(10) td.name', 'Distribution lists') + ->assertSeeIn('tr:nth-child(10) td.price', '0,00 CHF/month') + ->assertNotChecked('tbody tr:nth-child(10) td.selection input') + ->assertEnabled('tbody tr:nth-child(10) td.selection input') + ->assertTip( + 'tbody tr:nth-child(10) td.buttons button', 'Access to mail distribution lists' ) // Check Distlist, Uncheck Beta, expect Distlist unchecked ->click('#sku-input-distlist') ->click('#sku-input-beta') ->assertNotChecked('#sku-input-beta') ->assertNotChecked('#sku-input-distlist') // Click Distlist expect an alert ->click('#sku-input-distlist') ->assertDialogOpened('Distribution lists requires Private Beta (invitation only).') ->acceptDialog() // Enable Beta and Distlist and submit ->click('#sku-input-beta') ->click('#sku-input-distlist'); }) ->click('@general button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.'); $expected = [ 'beta', 'distlist', 'groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage' ]; $this->assertEntitlements($john, $expected); $browser->visit('/user/' . $john->id) ->on(new UserInfo()) ->waitFor('#sku-input-beta') ->click('#sku-input-beta') ->click('@general button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.'); $expected = [ 'groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage' ]; $this->assertEntitlements($john, $expected); }); // TODO: Test that the Distlist SKU is not available for users that aren't a group account owners // TODO: Test that entitlements change has immediate effect on the available items in dashboard // i.e. does not require a page reload nor re-login. } } diff --git a/src/tests/Feature/Backends/IMAPTest.php b/src/tests/Feature/Backends/IMAPTest.php index de3a4805..c28241de 100644 --- a/src/tests/Feature/Backends/IMAPTest.php +++ b/src/tests/Feature/Backends/IMAPTest.php @@ -1,52 +1,41 @@ assertTrue($result); - // TODO: Mocking rcube_imap_generic is not that nice, - // Find a way to be sure some testing account has folders - // initialized, and some other not, so we can make assertions - // on the verifyAccount() result - - $this->markTestIncomplete(); - } - - /** - * Test verifying IMAP account existence (non-existing account) - * - * @group imap - */ - public function testVerifyAccountNonExisting(): void - { + // non-existing user $this->expectException(\Exception::class); - IMAP::verifyAccount('non-existing@domain.tld'); } /** * 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); - // TODO: Test with an existing shared folder - $this->markTestIncomplete(); + // existing + $result = IMAP::verifySharedFolder('shared/Calendar@kolab.org'); + $this->assertTrue($result); } } diff --git a/src/tests/Feature/Backends/LDAPTest.php b/src/tests/Feature/Backends/LDAPTest.php index f61a88e3..5c0bdbe2 100644 --- a/src/tests/Feature/Backends/LDAPTest.php +++ b/src/tests/Feature/Backends/LDAPTest.php @@ -1,520 +1,624 @@ ldap_config = [ 'ldap.hosts' => \config('ldap.hosts'), ]; $this->deleteTestUser('user-ldap-test@' . \config('app.domain')); $this->deleteTestDomain('testldap.com'); $this->deleteTestGroup('group@kolab.org'); $this->deleteTestResource('test-resource@kolab.org'); + $this->deleteTestSharedFolder('test-folder@kolab.org'); // TODO: Remove group members } /** * {@inheritDoc} */ public function tearDown(): void { \config($this->ldap_config); $this->deleteTestUser('user-ldap-test@' . \config('app.domain')); $this->deleteTestDomain('testldap.com'); $this->deleteTestGroup('group@kolab.org'); $this->deleteTestResource('test-resource@kolab.org'); + $this->deleteTestSharedFolder('test-folder@kolab.org'); // TODO: Remove group members parent::tearDown(); } /** * Test handling connection errors * * @group ldap */ public function testConnectException(): void { \config(['ldap.hosts' => 'non-existing.host']); $this->expectException(\Exception::class); LDAP::connect(); } /** * Test creating/updating/deleting a domain record * * @group ldap */ public function testDomain(): void { Queue::fake(); $domain = $this->getTestDomain('testldap.com', [ 'type' => Domain::TYPE_EXTERNAL, 'status' => Domain::STATUS_NEW | Domain::STATUS_ACTIVE, ]); // Create the domain LDAP::createDomain($domain); $ldap_domain = LDAP::getDomain($domain->namespace); $expected = [ 'associateddomain' => $domain->namespace, 'inetdomainstatus' => $domain->status, 'objectclass' => [ 'top', 'domainrelatedobject', 'inetdomain' ], ]; foreach ($expected as $attr => $value) { $this->assertEquals($value, isset($ldap_domain[$attr]) ? $ldap_domain[$attr] : null); } // TODO: Test other attributes, aci, roles/ous // Update the domain $domain->status |= User::STATUS_LDAP_READY; LDAP::updateDomain($domain); $expected['inetdomainstatus'] = $domain->status; $ldap_domain = LDAP::getDomain($domain->namespace); foreach ($expected as $attr => $value) { $this->assertEquals($value, isset($ldap_domain[$attr]) ? $ldap_domain[$attr] : null); } // Delete the domain LDAP::deleteDomain($domain); $this->assertSame(null, LDAP::getDomain($domain->namespace)); } /** * Test creating/updating/deleting a group record * * @group ldap */ public function testGroup(): void { Queue::fake(); $root_dn = \config('ldap.hosted.root_dn'); $group = $this->getTestGroup('group@kolab.org', [ 'members' => ['member1@testldap.com', 'member2@testldap.com'] ]); $group->setSetting('sender_policy', '["test.com"]'); // Create the group LDAP::createGroup($group); $ldap_group = LDAP::getGroup($group->email); $expected = [ 'cn' => 'group', 'dn' => 'cn=group,ou=Groups,ou=kolab.org,' . $root_dn, 'mail' => $group->email, 'objectclass' => [ 'top', 'groupofuniquenames', 'kolabgroupofuniquenames' ], 'kolaballowsmtpsender' => 'test.com', 'uniquemember' => [ 'uid=member1@testldap.com,ou=People,ou=kolab.org,' . $root_dn, 'uid=member2@testldap.com,ou=People,ou=kolab.org,' . $root_dn, ], ]; foreach ($expected as $attr => $value) { $this->assertEquals($value, isset($ldap_group[$attr]) ? $ldap_group[$attr] : null, "Group $attr attribute"); } // Update members $group->members = ['member3@testldap.com']; $group->save(); $group->setSetting('sender_policy', '["test.com","-"]'); LDAP::updateGroup($group); // TODO: Should we force this to be always an array? $expected['uniquemember'] = 'uid=member3@testldap.com,ou=People,ou=kolab.org,' . $root_dn; $expected['kolaballowsmtpsender'] = ['test.com', '-']; $ldap_group = LDAP::getGroup($group->email); foreach ($expected as $attr => $value) { $this->assertEquals($value, isset($ldap_group[$attr]) ? $ldap_group[$attr] : null, "Group $attr attribute"); } $this->assertSame(['member3@testldap.com'], $group->fresh()->members); // Update members (add non-existing local member, expect it to be aot-removed from the group) // Update group name and sender_policy $group->members = ['member3@testldap.com', 'member-local@kolab.org']; $group->name = 'Te(=ść)1'; $group->save(); $group->setSetting('sender_policy', null); LDAP::updateGroup($group); // TODO: Should we force this to be always an array? $expected['uniquemember'] = 'uid=member3@testldap.com,ou=People,ou=kolab.org,' . $root_dn; $expected['kolaballowsmtpsender'] = null; $expected['dn'] = 'cn=Te(\\3dść)1,ou=Groups,ou=kolab.org,' . $root_dn; $expected['cn'] = 'Te(=ść)1'; $ldap_group = LDAP::getGroup($group->email); foreach ($expected as $attr => $value) { $this->assertEquals($value, isset($ldap_group[$attr]) ? $ldap_group[$attr] : null, "Group $attr attribute"); } $this->assertSame(['member3@testldap.com'], $group->fresh()->members); // We called save() twice, and setSettings() three times, // this is making sure that there's no job executed by the LDAP backend Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 5); // Delete the group LDAP::deleteGroup($group); $this->assertSame(null, LDAP::getGroup($group->email)); } /** * Test creating/updating/deleting a resource record * * @group ldap */ public function testResource(): void { Queue::fake(); $root_dn = \config('ldap.hosted.root_dn'); $resource = $this->getTestResource('test-resource@kolab.org', ['name' => 'Test1']); $resource->setSetting('invitation_policy', null); // Make sure the resource does not exist // LDAP::deleteResource($resource); // Create the resource LDAP::createResource($resource); $ldap_resource = LDAP::getResource($resource->email); $expected = [ 'cn' => 'Test1', 'dn' => 'cn=Test1,ou=Resources,ou=kolab.org,' . $root_dn, 'mail' => $resource->email, 'objectclass' => [ 'top', 'kolabresource', 'kolabsharedfolder', 'mailrecipient', ], 'kolabfoldertype' => 'event', 'kolabtargetfolder' => 'shared/Resources/Test1@kolab.org', 'kolabinvitationpolicy' => null, 'owner' => null, ]; foreach ($expected as $attr => $value) { $ldap_value = isset($ldap_resource[$attr]) ? $ldap_resource[$attr] : null; $this->assertEquals($value, $ldap_value, "Resource $attr attribute"); } // Update resource name and invitation_policy $resource->name = 'Te(=ść)1'; $resource->save(); $resource->setSetting('invitation_policy', 'manual:john@kolab.org'); LDAP::updateResource($resource); $expected['kolabtargetfolder'] = 'shared/Resources/Te(=ść)1@kolab.org'; $expected['kolabinvitationpolicy'] = 'ACT_MANUAL'; $expected['owner'] = 'uid=john@kolab.org,ou=People,ou=kolab.org,' . $root_dn; $expected['dn'] = 'cn=Te(\\3dść)1,ou=Resources,ou=kolab.org,' . $root_dn; $expected['cn'] = 'Te(=ść)1'; $ldap_resource = LDAP::getResource($resource->email); foreach ($expected as $attr => $value) { $ldap_value = isset($ldap_resource[$attr]) ? $ldap_resource[$attr] : null; $this->assertEquals($value, $ldap_value, "Resource $attr attribute"); } // Delete the resource LDAP::deleteResource($resource); $this->assertSame(null, LDAP::getResource($resource->email)); } + /** + * Test creating/updating/deleting a shared folder record + * + * @group ldap + */ + public function testSharedFolder(): void + { + Queue::fake(); + + $root_dn = \config('ldap.hosted.root_dn'); + $folder = $this->getTestSharedFolder('test-folder@kolab.org', ['type' => 'event']); + $folder->setSetting('acl', null); + + // Make sure the shared folder does not exist + // LDAP::deleteSharedFolder($folder); + + // Create the shared folder + LDAP::createSharedFolder($folder); + + $ldap_folder = LDAP::getSharedFolder($folder->email); + + $expected = [ + 'cn' => 'test-folder', + 'dn' => 'cn=test-folder,ou=Shared Folders,ou=kolab.org,' . $root_dn, + 'mail' => $folder->email, + 'objectclass' => [ + 'top', + 'kolabsharedfolder', + 'mailrecipient', + ], + 'kolabfoldertype' => 'event', + 'kolabtargetfolder' => 'shared/test-folder@kolab.org', + 'acl' => null, + ]; + + foreach ($expected as $attr => $value) { + $ldap_value = isset($ldap_folder[$attr]) ? $ldap_folder[$attr] : null; + $this->assertEquals($value, $ldap_value, "Shared folder $attr attribute"); + } + + // Update folder name and acl + $folder->name = 'Te(=ść)1'; + $folder->save(); + $folder->setSetting('acl', '["john@kolab.org, read-write","anyone, read-only"]'); + + LDAP::updateSharedFolder($folder); + + $expected['kolabtargetfolder'] = 'shared/Te(=ść)1@kolab.org'; + $expected['acl'] = ['john@kolab.org, read-write', 'anyone, read-only']; + $expected['dn'] = 'cn=Te(\\3dść)1,ou=Shared Folders,ou=kolab.org,' . $root_dn; + $expected['cn'] = 'Te(=ść)1'; + + $ldap_folder = LDAP::getSharedFolder($folder->email); + + foreach ($expected as $attr => $value) { + $ldap_value = isset($ldap_folder[$attr]) ? $ldap_folder[$attr] : null; + $this->assertEquals($value, $ldap_value, "Shared folder $attr attribute"); + } + + // Delete the resource + LDAP::deleteSharedFolder($folder); + + $this->assertSame(null, LDAP::getSharedFolder($folder->email)); + } + /** * Test creating/editing/deleting a user record * * @group ldap */ public function testUser(): void { Queue::fake(); $user = $this->getTestUser('user-ldap-test@' . \config('app.domain')); LDAP::createUser($user); $ldap_user = LDAP::getUser($user->email); $expected = [ 'objectclass' => [ 'top', 'inetorgperson', 'inetuser', 'kolabinetorgperson', 'mailrecipient', 'person', 'organizationalPerson', ], 'mail' => $user->email, 'uid' => $user->email, 'nsroledn' => [ 'cn=imap-user,' . \config('ldap.hosted.root_dn') ], 'cn' => 'unknown', 'displayname' => '', 'givenname' => '', 'sn' => 'unknown', 'inetuserstatus' => $user->status, 'mailquota' => null, 'o' => '', 'alias' => null, ]; foreach ($expected as $attr => $value) { $this->assertEquals($value, isset($ldap_user[$attr]) ? $ldap_user[$attr] : null); } // Add aliases, and change some user settings, and entitlements $user->setSettings([ 'first_name' => 'Firstname', 'last_name' => 'Lastname', 'organization' => 'Org', 'country' => 'PL', ]); $user->status |= User::STATUS_IMAP_READY; $user->save(); $aliases = ['t1-' . $user->email, 't2-' . $user->email]; $user->setAliases($aliases); $package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $user->assignPackage($package_kolab); LDAP::updateUser($user->fresh()); $expected['alias'] = $aliases; $expected['o'] = 'Org'; $expected['displayname'] = 'Lastname, Firstname'; $expected['givenname'] = 'Firstname'; $expected['cn'] = 'Firstname Lastname'; $expected['sn'] = 'Lastname'; $expected['inetuserstatus'] = $user->status; $expected['mailquota'] = 5242880; $expected['nsroledn'] = null; $ldap_user = LDAP::getUser($user->email); foreach ($expected as $attr => $value) { $this->assertEquals($value, isset($ldap_user[$attr]) ? $ldap_user[$attr] : null); } // Update entitlements $sku_activesync = \App\Sku::withEnvTenantContext()->where('title', 'activesync')->first(); $sku_groupware = \App\Sku::withEnvTenantContext()->where('title', 'groupware')->first(); $user->assignSku($sku_activesync, 1); Entitlement::where(['sku_id' => $sku_groupware->id, 'entitleable_id' => $user->id])->delete(); LDAP::updateUser($user->fresh()); $expected_roles = [ 'activesync-user', 'imap-user' ]; $ldap_user = LDAP::getUser($user->email); $this->assertCount(2, $ldap_user['nsroledn']); $ldap_roles = array_map( function ($role) { if (preg_match('/^cn=([a-z0-9-]+)/', $role, $m)) { return $m[1]; } else { return $role; } }, $ldap_user['nsroledn'] ); $this->assertSame($expected_roles, $ldap_roles); // Delete the user LDAP::deleteUser($user); $this->assertSame(null, LDAP::getUser($user->email)); } /** * Test handling errors on a resource creation * * @group ldap */ public function testCreateResourceException(): void { $this->expectException(\Exception::class); $this->expectExceptionMessageMatches('/Failed to create resource/'); $resource = new Resource([ 'email' => 'test-non-existing-ldap@non-existing.org', 'name' => 'Test', - 'status' => User::STATUS_ACTIVE, + 'status' => Resource::STATUS_ACTIVE, ]); LDAP::createResource($resource); } /** * Test handling errors on a group creation * * @group ldap */ public function testCreateGroupException(): void { $this->expectException(\Exception::class); $this->expectExceptionMessageMatches('/Failed to create group/'); $group = new Group([ 'name' => 'test', 'email' => 'test@testldap.com', 'status' => Group::STATUS_NEW | Group::STATUS_ACTIVE, ]); LDAP::createGroup($group); } + /** + * Test handling errors on a shared folder creation + * + * @group ldap + */ + public function testCreateSharedFolderException(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessageMatches('/Failed to create shared folder/'); + + $folder = new SharedFolder([ + 'email' => 'test-non-existing-ldap@non-existing.org', + 'name' => 'Test', + 'status' => SharedFolder::STATUS_ACTIVE, + ]); + + LDAP::createSharedFolder($folder); + } + /** * Test handling errors on user creation * * @group ldap */ public function testCreateUserException(): void { $this->expectException(\Exception::class); $this->expectExceptionMessageMatches('/Failed to create user/'); $user = new User([ 'email' => 'test-non-existing-ldap@non-existing.org', 'status' => User::STATUS_ACTIVE, ]); LDAP::createUser($user); } /** * Test handling update of a non-existing domain * * @group ldap */ public function testUpdateDomainException(): void { $this->expectException(\Exception::class); $this->expectExceptionMessageMatches('/domain not found/'); $domain = new Domain([ 'namespace' => 'testldap.com', 'type' => Domain::TYPE_EXTERNAL, 'status' => Domain::STATUS_NEW | Domain::STATUS_ACTIVE, ]); LDAP::updateDomain($domain); } /** * Test handling update of a non-existing group * * @group ldap */ public function testUpdateGroupException(): void { $this->expectException(\Exception::class); $this->expectExceptionMessageMatches('/group not found/'); $group = new Group([ 'name' => 'test', 'email' => 'test@testldap.com', 'status' => Group::STATUS_NEW | Group::STATUS_ACTIVE, ]); LDAP::updateGroup($group); } /** * Test handling update of a non-existing resource * * @group ldap */ public function testUpdateResourceException(): void { $this->expectException(\Exception::class); $this->expectExceptionMessageMatches('/resource not found/'); $resource = new Resource([ 'email' => 'test-resource@kolab.org', ]); LDAP::updateResource($resource); } + /** + * Test handling update of a non-existing shared folder + * + * @group ldap + */ + public function testUpdateSharedFolderException(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessageMatches('/folder not found/'); + + $folder = new SharedFolder([ + 'email' => 'test-folder-unknown@kolab.org', + ]); + + LDAP::updateSharedFolder($folder); + } + /** * Test handling update of a non-existing user * * @group ldap */ public function testUpdateUserException(): void { $this->expectException(\Exception::class); $this->expectExceptionMessageMatches('/user not found/'); $user = new User([ 'email' => 'test-non-existing-ldap@kolab.org', 'status' => User::STATUS_ACTIVE, ]); LDAP::updateUser($user); } } diff --git a/src/tests/Feature/Controller/Admin/SharedFoldersTest.php b/src/tests/Feature/Controller/Admin/SharedFoldersTest.php new file mode 100644 index 00000000..56a0094c --- /dev/null +++ b/src/tests/Feature/Controller/Admin/SharedFoldersTest.php @@ -0,0 +1,149 @@ +getTestUser('john@kolab.org'); + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $folder = $this->getTestSharedFolder('folder-event@kolab.org'); + + // Non-admin user + $response = $this->actingAs($user)->get("api/v4/shared-folders"); + $response->assertStatus(403); + + // Search with no search criteria + $response = $this->actingAs($admin)->get("api/v4/shared-folders"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(0, $json['count']); + $this->assertSame([], $json['list']); + $this->assertSame("0 shared folders have been found.", $json['message']); + + // Search with no matches expected + $response = $this->actingAs($admin)->get("api/v4/shared-folders?search=john@kolab.org"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(0, $json['count']); + $this->assertSame([], $json['list']); + + // Search by email + $response = $this->actingAs($admin)->get("api/v4/shared-folders?search={$folder->email}"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(1, $json['count']); + $this->assertCount(1, $json['list']); + $this->assertSame($folder->email, $json['list'][0]['email']); + + // Search by owner + $response = $this->actingAs($admin)->get("api/v4/shared-folders?owner={$user->id}"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(2, $json['count']); + $this->assertCount(2, $json['list']); + $this->assertSame("2 shared folders have been found.", $json['message']); + $this->assertSame($folder->email, $json['list'][0]['email']); + $this->assertSame($folder->name, $json['list'][0]['name']); + + // Search by owner (Ned is a controller on John's wallets, + // here we expect only folders assigned to Ned's wallet(s)) + $ned = $this->getTestUser('ned@kolab.org'); + $response = $this->actingAs($admin)->get("api/v4/shared-folders?owner={$ned->id}"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(0, $json['count']); + $this->assertCount(0, $json['list']); + } + + /** + * Test fetching shared folder info (GET /api/v4/shared-folders/) + */ + public function testShow(): void + { + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $user = $this->getTestUser('john@kolab.org'); + $folder = $this->getTestSharedFolder('folder-event@kolab.org'); + + // Only admins can access it + $response = $this->actingAs($user)->get("api/v4/shared-folders/{$folder->id}"); + $response->assertStatus(403); + + $response = $this->actingAs($admin)->get("api/v4/shared-folders/{$folder->id}"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertEquals($folder->id, $json['id']); + $this->assertEquals($folder->email, $json['email']); + $this->assertEquals($folder->name, $json['name']); + $this->assertEquals($folder->type, $json['type']); + } + + /** + * Test fetching shared folder status (GET /api/v4/shared-folders//status) + */ + public function testStatus(): void + { + Queue::fake(); // disable jobs + + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $folder = $this->getTestSharedFolder('folder-event@kolab.org'); + + // This end-point does not exist for admins + $response = $this->actingAs($admin)->get("/api/v4/shared-folders/{$folder->id}/status"); + $response->assertStatus(404); + } + + /** + * Test shared folder creating (POST /api/v4/shared-folders) + */ + public function testStore(): void + { + $user = $this->getTestUser('john@kolab.org'); + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + + // Test unauthorized access to admin API + $response = $this->actingAs($user)->post("/api/v4/shared-folders", []); + $response->assertStatus(403); + + // Admin can't create shared folders + $response = $this->actingAs($admin)->post("/api/v4/shared-folders", []); + $response->assertStatus(404); + } +} diff --git a/src/tests/Feature/Controller/Admin/SkusTest.php b/src/tests/Feature/Controller/Admin/SkusTest.php index ee5b7b34..82152625 100644 --- a/src/tests/Feature/Controller/Admin/SkusTest.php +++ b/src/tests/Feature/Controller/Admin/SkusTest.php @@ -1,124 +1,124 @@ delete(); $this->clearBetaEntitlements(); $this->clearMeetEntitlements(); } /** * {@inheritDoc} */ public function tearDown(): void { Sku::where('title', 'test')->delete(); $this->clearBetaEntitlements(); $this->clearMeetEntitlements(); parent::tearDown(); } /** * Test fetching SKUs list for a domain (GET /domains//skus) */ public function testDomainSkus(): void { $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $user = $this->getTestUser('john@kolab.org'); $domain = $this->getTestDOmain('kolab.org'); // Unauth access not allowed $response = $this->get("api/v4/domains/{$domain->id}/skus"); $response->assertStatus(401); // Non-admin access not allowed $response = $this->actingAs($user)->get("api/v4/domains/{$domain->id}/skus"); $response->assertStatus(403); $response = $this->actingAs($admin)->get("api/v4/domains/{$domain->id}/skus"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(1, $json); // Note: Details are tested where we test API\V4\SkusController } /** * Test fetching SKUs list */ public function testIndex(): void { $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $user = $this->getTestUser('john@kolab.org'); $sku = Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); // Unauth access not allowed $response = $this->get("api/v4/skus"); $response->assertStatus(401); // User access not allowed on admin API $response = $this->actingAs($user)->get("api/v4/skus"); $response->assertStatus(403); $response = $this->actingAs($admin)->get("api/v4/skus"); $response->assertStatus(200); $json = $response->json(); - $this->assertCount(11, $json); + $this->assertCount(13, $json); $this->assertSame(100, $json[0]['prio']); $this->assertSame($sku->id, $json[0]['id']); $this->assertSame($sku->title, $json[0]['title']); $this->assertSame($sku->name, $json[0]['name']); $this->assertSame($sku->description, $json[0]['description']); $this->assertSame($sku->cost, $json[0]['cost']); $this->assertSame($sku->units_free, $json[0]['units_free']); $this->assertSame($sku->period, $json[0]['period']); $this->assertSame($sku->active, $json[0]['active']); $this->assertSame('user', $json[0]['type']); $this->assertSame('mailbox', $json[0]['handler']); } /** * Test fetching SKUs list for a user (GET /users//skus) */ public function testUserSkus(): void { $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $user = $this->getTestUser('john@kolab.org'); // Unauth access not allowed $response = $this->get("api/v4/users/{$user->id}/skus"); $response->assertStatus(401); // Non-admin access not allowed $response = $this->actingAs($user)->get("api/v4/users/{$user->id}/skus"); $response->assertStatus(403); $response = $this->actingAs($admin)->get("api/v4/users/{$user->id}/skus"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(6, $json); // Note: Details are tested where we test API\V4\SkusController } } diff --git a/src/tests/Feature/Controller/Admin/UsersTest.php b/src/tests/Feature/Controller/Admin/UsersTest.php index 39d00527..02159f94 100644 --- a/src/tests/Feature/Controller/Admin/UsersTest.php +++ b/src/tests/Feature/Controller/Admin/UsersTest.php @@ -1,501 +1,512 @@ deleteTestUser('UsersControllerTest1@userscontroller.com'); $this->deleteTestUser('test@testsearch.com'); $this->deleteTestDomain('testsearch.com'); $this->deleteTestGroup('group-test@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $jack->setSetting('external_email', null); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('UsersControllerTest1@userscontroller.com'); $this->deleteTestUser('test@testsearch.com'); $this->deleteTestDomain('testsearch.com'); $jack = $this->getTestUser('jack@kolab.org'); $jack->setSetting('external_email', null); parent::tearDown(); } /** * Test user deleting (DELETE /api/v4/users/) */ public function testDestroy(): void { $john = $this->getTestUser('john@kolab.org'); $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); // Test unauth access $response = $this->delete("api/v4/users/{$user->id}"); $response->assertStatus(401); // The end-point does not exist $response = $this->actingAs($admin)->delete("api/v4/users/{$user->id}"); $response->assertStatus(404); } /** * Test users searching (/api/v4/users) */ public function testIndex(): void { $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $group = $this->getTestGroup('group-test@kolab.org'); $group->assignToWallet($user->wallets->first()); // Non-admin user $response = $this->actingAs($user)->get("api/v4/users"); $response->assertStatus(403); // Search with no search criteria $response = $this->actingAs($admin)->get("api/v4/users"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertSame([], $json['list']); // Search with no matches expected $response = $this->actingAs($admin)->get("api/v4/users?search=abcd1234efgh5678"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertSame([], $json['list']); // Search by domain $response = $this->actingAs($admin)->get("api/v4/users?search=kolab.org"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($user->id, $json['list'][0]['id']); $this->assertSame($user->email, $json['list'][0]['email']); // Search by user ID $response = $this->actingAs($admin)->get("api/v4/users?search={$user->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($user->id, $json['list'][0]['id']); $this->assertSame($user->email, $json['list'][0]['email']); // Search by email (primary) $response = $this->actingAs($admin)->get("api/v4/users?search=john@kolab.org"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($user->id, $json['list'][0]['id']); $this->assertSame($user->email, $json['list'][0]['email']); // Search by email (alias) $response = $this->actingAs($admin)->get("api/v4/users?search=john.doe@kolab.org"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($user->id, $json['list'][0]['id']); $this->assertSame($user->email, $json['list'][0]['email']); // Search by email (external), expect two users in a result $jack = $this->getTestUser('jack@kolab.org'); $jack->setSetting('external_email', 'john.doe.external@gmail.com'); $response = $this->actingAs($admin)->get("api/v4/users?search=john.doe.external@gmail.com"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(2, $json['count']); $this->assertCount(2, $json['list']); $emails = array_column($json['list'], 'email'); $this->assertContains($user->email, $emails); $this->assertContains($jack->email, $emails); // Search by owner $response = $this->actingAs($admin)->get("api/v4/users?owner={$user->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(4, $json['count']); $this->assertCount(4, $json['list']); // Search by owner (Ned is a controller on John's wallets, // here we expect only users assigned to Ned's wallet(s)) $ned = $this->getTestUser('ned@kolab.org'); $response = $this->actingAs($admin)->get("api/v4/users?owner={$ned->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertCount(0, $json['list']); // Search by distribution list email $response = $this->actingAs($admin)->get("api/v4/users?search=group-test@kolab.org"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($user->id, $json['list'][0]['id']); $this->assertSame($user->email, $json['list'][0]['email']); // Search by resource email $response = $this->actingAs($admin)->get("api/v4/users?search=resource-test1@kolab.org"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($user->id, $json['list'][0]['id']); $this->assertSame($user->email, $json['list'][0]['email']); + // Search by shared folder email + $response = $this->actingAs($admin)->get("api/v4/users?search=folder-event@kolab.org"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(1, $json['count']); + $this->assertCount(1, $json['list']); + $this->assertSame($user->id, $json['list'][0]['id']); + $this->assertSame($user->email, $json['list'][0]['email']); + // Deleted users/domains $domain = $this->getTestDomain('testsearch.com', ['type' => \App\Domain::TYPE_EXTERNAL]); $user = $this->getTestUser('test@testsearch.com'); $plan = \App\Plan::where('title', 'group')->first(); $user->assignPlan($plan, $domain); $user->setAliases(['alias@testsearch.com']); $wallet = $user->wallets()->first(); $wallet->setSetting('mollie_id', 'cst_nonsense'); \App\Payment::create( [ 'id' => 'tr_nonsense', 'wallet_id' => $wallet->id, 'status' => 'paid', 'amount' => 1337, 'description' => 'nonsense transaction for testing', 'provider' => 'self', 'type' => 'oneoff', 'currency' => 'CHF', 'currency_amount' => 1337 ] ); Queue::fake(); $user->delete(); $response = $this->actingAs($admin)->get("api/v4/users?search=test@testsearch.com"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($user->id, $json['list'][0]['id']); $this->assertSame($user->email, $json['list'][0]['email']); $this->assertTrue($json['list'][0]['isDeleted']); $response = $this->actingAs($admin)->get("api/v4/users?search=alias@testsearch.com"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($user->id, $json['list'][0]['id']); $this->assertSame($user->email, $json['list'][0]['email']); $this->assertTrue($json['list'][0]['isDeleted']); $response = $this->actingAs($admin)->get("api/v4/users?search=testsearch.com"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($user->id, $json['list'][0]['id']); $this->assertSame($user->email, $json['list'][0]['email']); $this->assertTrue($json['list'][0]['isDeleted']); $response = $this->actingAs($admin)->get("api/v4/users?search={$wallet->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($user->id, $json['list'][0]['id']); $this->assertSame($user->email, $json['list'][0]['email']); $this->assertTrue($json['list'][0]['isDeleted']); $response = $this->actingAs($admin)->get("api/v4/users?search=tr_nonsense"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($user->id, $json['list'][0]['id']); $this->assertSame($user->email, $json['list'][0]['email']); $this->assertTrue($json['list'][0]['isDeleted']); $response = $this->actingAs($admin)->get("api/v4/users?search=cst_nonsense"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($user->id, $json['list'][0]['id']); $this->assertSame($user->email, $json['list'][0]['email']); $this->assertTrue($json['list'][0]['isDeleted']); } /** * Test reseting 2FA (POST /api/v4/users//reset2FA) */ public function testReset2FA(): void { Queue::fake(); $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $sku2fa = Sku::withEnvTenantContext()->where(['title' => '2fa'])->first(); $user->assignSku($sku2fa); SecondFactor::seed('userscontrollertest1@userscontroller.com'); // Test unauthorized access to admin API $response = $this->actingAs($user)->post("/api/v4/users/{$user->id}/reset2FA", []); $response->assertStatus(403); $entitlements = $user->fresh()->entitlements()->where('sku_id', $sku2fa->id)->get(); $this->assertCount(1, $entitlements); $sf = new SecondFactor($user); $this->assertCount(1, $sf->factors()); // Test reseting 2FA $response = $this->actingAs($admin)->post("/api/v4/users/{$user->id}/reset2FA", []); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("2-Factor authentication reset successfully.", $json['message']); $this->assertCount(2, $json); $entitlements = $user->fresh()->entitlements()->where('sku_id', $sku2fa->id)->get(); $this->assertCount(0, $entitlements); $sf = new SecondFactor($user); $this->assertCount(0, $sf->factors()); } /** * Test adding beta SKU (POST /api/v4/users//skus/beta) */ public function testAddBetaSku(): void { Queue::fake(); $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $sku = Sku::withEnvTenantContext()->where(['title' => 'beta'])->first(); // Test unauthorized access to admin API $response = $this->actingAs($user)->post("/api/v4/users/{$user->id}/skus/beta", []); $response->assertStatus(403); // For now we allow only the beta sku $response = $this->actingAs($admin)->post("/api/v4/users/{$user->id}/skus/mailbox", []); $response->assertStatus(404); $entitlements = $user->entitlements()->where('sku_id', $sku->id)->get(); $this->assertCount(0, $entitlements); // Test adding the beta sku $response = $this->actingAs($admin)->post("/api/v4/users/{$user->id}/skus/beta", []); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("The subscription added successfully.", $json['message']); $this->assertSame(0, $json['sku']['cost']); $this->assertSame($sku->id, $json['sku']['id']); $this->assertSame($sku->name, $json['sku']['name']); $this->assertCount(3, $json); $entitlements = $user->entitlements()->where('sku_id', $sku->id)->get(); $this->assertCount(1, $entitlements); // Test adding the beta sku again, expect an error $response = $this->actingAs($admin)->post("/api/v4/users/{$user->id}/skus/beta", []); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame("The subscription already exists.", $json['message']); $this->assertCount(2, $json); $entitlements = $user->entitlements()->where('sku_id', $sku->id)->get(); $this->assertCount(1, $entitlements); } /** * Test user creation (POST /api/v4/users) */ public function testStore(): void { $admin = $this->getTestUser('jeroen@jeroen.jeroen'); // The end-point does not exist $response = $this->actingAs($admin)->post("/api/v4/users", []); $response->assertStatus(404); } /** * Test user suspending (POST /api/v4/users//suspend) */ public function testSuspend(): void { Queue::fake(); // disable jobs $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); // Test unauthorized access to admin API $response = $this->actingAs($user)->post("/api/v4/users/{$user->id}/suspend", []); $response->assertStatus(403); $this->assertFalse($user->isSuspended()); // Test suspending the user $response = $this->actingAs($admin)->post("/api/v4/users/{$user->id}/suspend", []); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("User suspended successfully.", $json['message']); $this->assertCount(2, $json); $this->assertTrue($user->fresh()->isSuspended()); } /** * Test user un-suspending (POST /api/v4/users//unsuspend) */ public function testUnsuspend(): void { Queue::fake(); // disable jobs $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); // Test unauthorized access to admin API $response = $this->actingAs($user)->post("/api/v4/users/{$user->id}/unsuspend", []); $response->assertStatus(403); $this->assertFalse($user->isSuspended()); $user->suspend(); $this->assertTrue($user->isSuspended()); // Test suspending the user $response = $this->actingAs($admin)->post("/api/v4/users/{$user->id}/unsuspend", []); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("User unsuspended successfully.", $json['message']); $this->assertCount(2, $json); $this->assertFalse($user->fresh()->isSuspended()); } /** * Test user update (PUT /api/v4/users/) */ public function testUpdate(): void { $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); // Test unauthorized access to admin API $response = $this->actingAs($user)->put("/api/v4/users/{$user->id}", []); $response->assertStatus(403); // Test updatig the user data (empty data) $response = $this->actingAs($admin)->put("/api/v4/users/{$user->id}", []); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("User data updated successfully.", $json['message']); $this->assertCount(2, $json); // Test error handling $post = ['external_email' => 'aaa']; $response = $this->actingAs($admin)->put("/api/v4/users/{$user->id}", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame("The external email must be a valid email address.", $json['errors']['external_email'][0]); $this->assertCount(2, $json); // Test real update $post = ['external_email' => 'modified@test.com']; $response = $this->actingAs($admin)->put("/api/v4/users/{$user->id}", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("User data updated successfully.", $json['message']); $this->assertCount(2, $json); $this->assertSame('modified@test.com', $user->getSetting('external_email')); } } diff --git a/src/tests/Feature/Controller/Reseller/SharedFoldersTest.php b/src/tests/Feature/Controller/Reseller/SharedFoldersTest.php new file mode 100644 index 00000000..5b720315 --- /dev/null +++ b/src/tests/Feature/Controller/Reseller/SharedFoldersTest.php @@ -0,0 +1,180 @@ +getTestUser('john@kolab.org'); + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); + $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local'); + $folder = $this->getTestSharedFolder('folder-event@kolab.org'); + + // Non-admin user + $response = $this->actingAs($user)->get("api/v4/shared-folders"); + $response->assertStatus(403); + + // Admin user + $response = $this->actingAs($admin)->get("api/v4/shared-folders"); + $response->assertStatus(403); + + // Search with no search criteria + $response = $this->actingAs($reseller1)->get("api/v4/shared-folders"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(0, $json['count']); + $this->assertSame([], $json['list']); + + // Search with no matches expected + $response = $this->actingAs($reseller1)->get("api/v4/shared-folders?search=john@kolab.org"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(0, $json['count']); + $this->assertSame([], $json['list']); + + // Search by email + $response = $this->actingAs($reseller1)->get("api/v4/shared-folders?search={$folder->email}"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(1, $json['count']); + $this->assertCount(1, $json['list']); + $this->assertSame($folder->email, $json['list'][0]['email']); + + // Search by owner + $response = $this->actingAs($reseller1)->get("api/v4/shared-folders?owner={$user->id}"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(2, $json['count']); + $this->assertCount(2, $json['list']); + $this->assertSame($folder->email, $json['list'][0]['email']); + $this->assertSame($folder->name, $json['list'][0]['name']); + + // Search by owner (Ned is a controller on John's wallets, + // here we expect only folders assigned to Ned's wallet(s)) + $ned = $this->getTestUser('ned@kolab.org'); + $response = $this->actingAs($reseller1)->get("api/v4/shared-folders?owner={$ned->id}"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(0, $json['count']); + $this->assertCount(0, $json['list']); + + $response = $this->actingAs($reseller2)->get("api/v4/shared-folders?search={$folder->email}"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(0, $json['count']); + $this->assertSame([], $json['list']); + + $response = $this->actingAs($reseller2)->get("api/v4/shared-folders?owner={$user->id}"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(0, $json['count']); + $this->assertSame([], $json['list']); + } + + /** + * Test fetching shared folder info (GET /api/v4/shared-folders/) + */ + public function testShow(): void + { + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $user = $this->getTestUser('john@kolab.org'); + $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); + $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local'); + $folder = $this->getTestSharedFolder('folder-event@kolab.org'); + + // Only resellers can access it + $response = $this->actingAs($user)->get("api/v4/shared-folders/{$folder->id}"); + $response->assertStatus(403); + + $response = $this->actingAs($admin)->get("api/v4/shared-folders/{$folder->id}"); + $response->assertStatus(403); + + $response = $this->actingAs($reseller2)->get("api/v4/shared-folders/{$folder->id}"); + $response->assertStatus(404); + + $response = $this->actingAs($reseller1)->get("api/v4/shared-folders/{$folder->id}"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertEquals($folder->id, $json['id']); + $this->assertEquals($folder->email, $json['email']); + $this->assertEquals($folder->name, $json['name']); + } + + /** + * Test fetching shared folder status (GET /api/v4/shared-folders//status) + */ + public function testStatus(): void + { + Queue::fake(); // disable jobs + + $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); + $folder = $this->getTestSharedFolder('folder-event@kolab.org'); + + // This end-point does not exist for folders + $response = $this->actingAs($reseller1)->get("/api/v4/shared-folders/{$folder->id}/status"); + $response->assertStatus(404); + } + + /** + * Test shared folder creating (POST /api/v4/shared-folders) + */ + public function testStore(): void + { + $user = $this->getTestUser('john@kolab.org'); + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); + + // Test unauthorized access to reseller API + $response = $this->actingAs($user)->post("/api/v4/shared-folders", []); + $response->assertStatus(403); + + // Reseller or admin can't create folders + $response = $this->actingAs($admin)->post("/api/v4/shared-folders", []); + $response->assertStatus(403); + + $response = $this->actingAs($reseller1)->post("/api/v4/shared-folders", []); + $response->assertStatus(404); + } +} diff --git a/src/tests/Feature/Controller/Reseller/SkusTest.php b/src/tests/Feature/Controller/Reseller/SkusTest.php index 58d679a0..421c9e32 100644 --- a/src/tests/Feature/Controller/Reseller/SkusTest.php +++ b/src/tests/Feature/Controller/Reseller/SkusTest.php @@ -1,173 +1,173 @@ delete(); $this->clearBetaEntitlements(); $this->clearMeetEntitlements(); } /** * {@inheritDoc} */ public function tearDown(): void { Sku::where('title', 'test')->delete(); $this->clearBetaEntitlements(); $this->clearMeetEntitlements(); parent::tearDown(); } /** * Test fetching SKUs list for a domain (GET /domains//skus) */ public function testDomainSkus(): void { $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $user = $this->getTestUser('john@kolab.org'); $domain = $this->getTestDomain('kolab.org'); // Unauth access not allowed $response = $this->get("api/v4/domains/{$domain->id}/skus"); $response->assertStatus(401); // User access not allowed $response = $this->actingAs($user)->get("api/v4/domains/{$domain->id}/skus"); $response->assertStatus(403); // Admin access not allowed $response = $this->actingAs($admin)->get("api/v4/domains/{$domain->id}/skus"); $response->assertStatus(403); // Reseller from another tenant $response = $this->actingAs($reseller2)->get("api/v4/domains/{$domain->id}/skus"); $response->assertStatus(404); // Reseller access $response = $this->actingAs($reseller1)->get("api/v4/domains/{$domain->id}/skus"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(1, $json); // Note: Details are tested where we test API\V4\SkusController } /** * Test fetching SKUs list */ public function testIndex(): void { $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $user = $this->getTestUser('john@kolab.org'); $sku = Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); // Unauth access not allowed $response = $this->get("api/v4/skus"); $response->assertStatus(401); // User access not allowed $response = $this->actingAs($user)->get("api/v4/skus"); $response->assertStatus(403); // Admin access not allowed $response = $this->actingAs($admin)->get("api/v4/skus"); $response->assertStatus(403); $response = $this->actingAs($reseller1)->get("api/v4/skus"); $response->assertStatus(200); $json = $response->json(); - $this->assertCount(11, $json); + $this->assertCount(13, $json); $this->assertSame(100, $json[0]['prio']); $this->assertSame($sku->id, $json[0]['id']); $this->assertSame($sku->title, $json[0]['title']); $this->assertSame($sku->name, $json[0]['name']); $this->assertSame($sku->description, $json[0]['description']); $this->assertSame($sku->cost, $json[0]['cost']); $this->assertSame($sku->units_free, $json[0]['units_free']); $this->assertSame($sku->period, $json[0]['period']); $this->assertSame($sku->active, $json[0]['active']); $this->assertSame('user', $json[0]['type']); $this->assertSame('mailbox', $json[0]['handler']); // Test with another tenant $sku = Sku::where('title', 'mailbox')->where('tenant_id', $reseller2->tenant_id)->first(); $response = $this->actingAs($reseller2)->get("api/v4/skus"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(6, $json); $this->assertSame(100, $json[0]['prio']); $this->assertSame($sku->id, $json[0]['id']); $this->assertSame($sku->title, $json[0]['title']); $this->assertSame($sku->name, $json[0]['name']); $this->assertSame($sku->description, $json[0]['description']); $this->assertSame($sku->cost, $json[0]['cost']); $this->assertSame($sku->units_free, $json[0]['units_free']); $this->assertSame($sku->period, $json[0]['period']); $this->assertSame($sku->active, $json[0]['active']); $this->assertSame('user', $json[0]['type']); $this->assertSame('mailbox', $json[0]['handler']); } /** * Test fetching SKUs list for a user (GET /users//skus) */ public function testUserSkus(): void { $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $user = $this->getTestUser('john@kolab.org'); // Unauth access not allowed $response = $this->get("api/v4/users/{$user->id}/skus"); $response->assertStatus(401); // User access not allowed $response = $this->actingAs($user)->get("api/v4/users/{$user->id}/skus"); $response->assertStatus(403); // Admin access not allowed $response = $this->actingAs($admin)->get("api/v4/users/{$user->id}/skus"); $response->assertStatus(403); // Reseller from another tenant $response = $this->actingAs($reseller2)->get("api/v4/users/{$user->id}/skus"); $response->assertStatus(404); // Reseller access $response = $this->actingAs($reseller1)->get("api/v4/users/{$user->id}/skus"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(6, $json); // Note: Details are tested where we test API\V4\SkusController } } diff --git a/src/tests/Feature/Controller/SharedFoldersTest.php b/src/tests/Feature/Controller/SharedFoldersTest.php new file mode 100644 index 00000000..b803b468 --- /dev/null +++ b/src/tests/Feature/Controller/SharedFoldersTest.php @@ -0,0 +1,488 @@ +deleteTestSharedFolder('folder-test@kolab.org'); + SharedFolder::where('name', 'Test Folder')->delete(); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestSharedFolder('folder-test@kolab.org'); + SharedFolder::where('name', 'Test Folder')->delete(); + + parent::tearDown(); + } + + /** + * Test resource deleting (DELETE /api/v4/resources/) + */ + public function testDestroy(): void + { + $john = $this->getTestUser('john@kolab.org'); + $jack = $this->getTestUser('jack@kolab.org'); + $folder = $this->getTestSharedFolder('folder-test@kolab.org'); + $folder->assignToWallet($john->wallets->first()); + + // Test unauth access + $response = $this->delete("api/v4/shared-folders/{$folder->id}"); + $response->assertStatus(401); + + // Test non-existing folder + $response = $this->actingAs($john)->delete("api/v4/shared-folders/abc"); + $response->assertStatus(404); + + // Test access to other user's folder + $response = $this->actingAs($jack)->delete("api/v4/shared-folders/{$folder->id}"); + $response->assertStatus(403); + + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertSame("Access denied", $json['message']); + $this->assertCount(2, $json); + + // Test removing a folder + $response = $this->actingAs($john)->delete("api/v4/shared-folders/{$folder->id}"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertEquals('success', $json['status']); + $this->assertEquals("Shared folder deleted successfully.", $json['message']); + } + + /** + * Test shared folders listing (GET /api/v4/shared-folders) + */ + public function testIndex(): void + { + $jack = $this->getTestUser('jack@kolab.org'); + $john = $this->getTestUser('john@kolab.org'); + $ned = $this->getTestUser('ned@kolab.org'); + + // Test unauth access + $response = $this->get("api/v4/shared-folders"); + $response->assertStatus(401); + + // Test a user with no shared folders + $response = $this->actingAs($jack)->get("/api/v4/shared-folders"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertCount(0, $json); + + // Test a user with two shared folders + $response = $this->actingAs($john)->get("/api/v4/shared-folders"); + $response->assertStatus(200); + + $json = $response->json(); + + $folder = SharedFolder::where('name', 'Calendar')->first(); + + $this->assertCount(2, $json); + $this->assertSame($folder->id, $json[0]['id']); + $this->assertSame($folder->email, $json[0]['email']); + $this->assertSame($folder->name, $json[0]['name']); + $this->assertSame($folder->type, $json[0]['type']); + $this->assertArrayHasKey('isDeleted', $json[0]); + $this->assertArrayHasKey('isActive', $json[0]); + $this->assertArrayHasKey('isLdapReady', $json[0]); + $this->assertArrayHasKey('isImapReady', $json[0]); + + // Test that another wallet controller has access to shared folders + $response = $this->actingAs($ned)->get("/api/v4/shared-folders"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertCount(2, $json); + $this->assertSame($folder->email, $json[0]['email']); + } + + /** + * Test shared folder config update (POST /api/v4/shared-folders//config) + */ + public function testSetConfig(): void + { + $john = $this->getTestUser('john@kolab.org'); + $jack = $this->getTestUser('jack@kolab.org'); + $folder = $this->getTestSharedFolder('folder-test@kolab.org'); + $folder->assignToWallet($john->wallets->first()); + + // Test unknown resource id + $post = ['acl' => ['john@kolab.org, full']]; + $response = $this->actingAs($john)->post("/api/v4/shared-folders/123/config", $post); + $json = $response->json(); + + $response->assertStatus(404); + + // Test access by user not being a wallet controller + $response = $this->actingAs($jack)->post("/api/v4/shared-folders/{$folder->id}/config", $post); + $json = $response->json(); + + $response->assertStatus(403); + + $this->assertSame('error', $json['status']); + $this->assertSame("Access denied", $json['message']); + $this->assertCount(2, $json); + + // Test some invalid data + $post = ['test' => 1]; + $response = $this->actingAs($john)->post("/api/v4/shared-folders/{$folder->id}/config", $post); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertCount(2, $json); + $this->assertCount(1, $json['errors']); + $this->assertSame('The requested configuration parameter is not supported.', $json['errors']['test']); + + $folder->refresh(); + + $this->assertNull($folder->getSetting('test')); + $this->assertNull($folder->getSetting('acl')); + + // Test some valid data + $post = ['acl' => ['john@kolab.org, full']]; + $response = $this->actingAs($john)->post("/api/v4/shared-folders/{$folder->id}/config", $post); + + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertCount(2, $json); + $this->assertSame('success', $json['status']); + $this->assertSame("Shared folder settings updated successfully.", $json['message']); + + $this->assertSame(['acl' => $post['acl']], $folder->fresh()->getConfig()); + + // Test input validation + $post = ['acl' => ['john@kolab.org, full', 'test, full']]; + $response = $this->actingAs($john)->post("/api/v4/shared-folders/{$folder->id}/config", $post); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertCount(2, $json); + $this->assertSame('error', $json['status']); + $this->assertCount(1, $json['errors']); + $this->assertCount(1, $json['errors']['acl']); + $this->assertSame( + "The specified email address is invalid.", + $json['errors']['acl'][1] + ); + + $this->assertSame(['acl' => ['john@kolab.org, full']], $folder->fresh()->getConfig()); + } + + /** + * Test fetching shared folder data/profile (GET /api/v4/shared-folders/) + */ + public function testShow(): void + { + $jack = $this->getTestUser('jack@kolab.org'); + $john = $this->getTestUser('john@kolab.org'); + $ned = $this->getTestUser('ned@kolab.org'); + + $folder = $this->getTestSharedFolder('folder-test@kolab.org'); + $folder->assignToWallet($john->wallets->first()); + $folder->setSetting('acl', '["anyone, full"]'); + + // Test unauthenticated access + $response = $this->get("/api/v4/shared-folders/{$folder->id}"); + $response->assertStatus(401); + + // Test unauthorized access to a shared folder of another user + $response = $this->actingAs($jack)->get("/api/v4/shared-folders/{$folder->id}"); + $response->assertStatus(403); + + // John: Account owner - non-existing folder + $response = $this->actingAs($john)->get("/api/v4/shared-folders/abc"); + $response->assertStatus(404); + + // John: Account owner + $response = $this->actingAs($john)->get("/api/v4/shared-folders/{$folder->id}"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame($folder->id, $json['id']); + $this->assertSame($folder->email, $json['email']); + $this->assertSame($folder->name, $json['name']); + $this->assertSame($folder->type, $json['type']); + $this->assertTrue(!empty($json['statusInfo'])); + $this->assertArrayHasKey('isDeleted', $json); + $this->assertArrayHasKey('isActive', $json); + $this->assertArrayHasKey('isLdapReady', $json); + $this->assertArrayHasKey('isImapReady', $json); + $this->assertSame(['acl' => ['anyone, full']], $json['config']); + } + + /** + * Test fetching a shared folder status (GET /api/v4/shared-folders//status) + * and forcing setup process update (?refresh=1) + */ + public function testStatus(): void + { + Queue::fake(); + + $john = $this->getTestUser('john@kolab.org'); + $jack = $this->getTestUser('jack@kolab.org'); + + $folder = $this->getTestSharedFolder('folder-test@kolab.org'); + $folder->assignToWallet($john->wallets->first()); + + // Test unauthorized access + $response = $this->get("/api/v4/shared-folders/abc/status"); + $response->assertStatus(401); + + // Test unauthorized access + $response = $this->actingAs($jack)->get("/api/v4/shared-folders/{$folder->id}/status"); + $response->assertStatus(403); + + $folder->status = SharedFolder::STATUS_NEW | SharedFolder::STATUS_ACTIVE; + $folder->save(); + + // Get resource status + $response = $this->actingAs($john)->get("/api/v4/shared-folders/{$folder->id}/status"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertFalse($json['isLdapReady']); + $this->assertFalse($json['isImapReady']); + $this->assertFalse($json['isReady']); + $this->assertFalse($json['isDeleted']); + $this->assertTrue($json['isActive']); + $this->assertCount(7, $json['process']); + $this->assertSame('shared-folder-new', $json['process'][0]['label']); + $this->assertSame(true, $json['process'][0]['state']); + $this->assertSame('shared-folder-ldap-ready', $json['process'][1]['label']); + $this->assertSame(false, $json['process'][1]['state']); + $this->assertTrue(empty($json['status'])); + $this->assertTrue(empty($json['message'])); + $this->assertSame('running', $json['processState']); + + // Make sure the domain is confirmed (other test might unset that status) + $domain = $this->getTestDomain('kolab.org'); + $domain->status |= \App\Domain::STATUS_CONFIRMED; + $domain->save(); + $folder->status |= SharedFolder::STATUS_IMAP_READY; + $folder->save(); + + // Now "reboot" the process and get the folder status + $response = $this->actingAs($john)->get("/api/v4/shared-folders/{$folder->id}/status?refresh=1"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertTrue($json['isLdapReady']); + $this->assertTrue($json['isImapReady']); + $this->assertTrue($json['isReady']); + $this->assertCount(7, $json['process']); + $this->assertSame('shared-folder-ldap-ready', $json['process'][1]['label']); + $this->assertSame(true, $json['process'][1]['state']); + $this->assertSame('shared-folder-imap-ready', $json['process'][2]['label']); + $this->assertSame(true, $json['process'][2]['state']); + $this->assertSame('success', $json['status']); + $this->assertSame('Setup process finished successfully.', $json['message']); + $this->assertSame('done', $json['processState']); + + // Test a case when a domain is not ready + $domain->status ^= \App\Domain::STATUS_CONFIRMED; + $domain->save(); + + $response = $this->actingAs($john)->get("/api/v4/shared-folders/{$folder->id}/status?refresh=1"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertTrue($json['isLdapReady']); + $this->assertTrue($json['isReady']); + $this->assertCount(7, $json['process']); + $this->assertSame('shared-folder-ldap-ready', $json['process'][1]['label']); + $this->assertSame(true, $json['process'][1]['state']); + $this->assertSame('success', $json['status']); + $this->assertSame('Setup process finished successfully.', $json['message']); + } + + /** + * Test SharedFoldersController::statusInfo() + */ + public function testStatusInfo(): void + { + $john = $this->getTestUser('john@kolab.org'); + $folder = $this->getTestSharedFolder('folder-test@kolab.org'); + $folder->assignToWallet($john->wallets->first()); + $folder->status = SharedFolder::STATUS_NEW | SharedFolder::STATUS_ACTIVE; + $folder->save(); + $domain = $this->getTestDomain('kolab.org'); + $domain->status |= \App\Domain::STATUS_CONFIRMED; + $domain->save(); + + $result = SharedFoldersController::statusInfo($folder); + + $this->assertFalse($result['isReady']); + $this->assertCount(7, $result['process']); + $this->assertSame('shared-folder-new', $result['process'][0]['label']); + $this->assertSame(true, $result['process'][0]['state']); + $this->assertSame('shared-folder-ldap-ready', $result['process'][1]['label']); + $this->assertSame(false, $result['process'][1]['state']); + $this->assertSame('running', $result['processState']); + + $folder->created_at = Carbon::now()->subSeconds(181); + $folder->save(); + + $result = SharedFoldersController::statusInfo($folder); + + $this->assertSame('failed', $result['processState']); + + $folder->status |= SharedFolder::STATUS_LDAP_READY | SharedFolder::STATUS_IMAP_READY; + $folder->save(); + + $result = SharedFoldersController::statusInfo($folder); + + $this->assertTrue($result['isReady']); + $this->assertCount(7, $result['process']); + $this->assertSame('shared-folder-new', $result['process'][0]['label']); + $this->assertSame(true, $result['process'][0]['state']); + $this->assertSame('shared-folder-ldap-ready', $result['process'][1]['label']); + $this->assertSame(true, $result['process'][1]['state']); + $this->assertSame('shared-folder-ldap-ready', $result['process'][1]['label']); + $this->assertSame(true, $result['process'][1]['state']); + $this->assertSame('done', $result['processState']); + } + + /** + * Test shared folder creation (POST /api/v4/shared-folders) + */ + public function testStore(): void + { + Queue::fake(); + + $jack = $this->getTestUser('jack@kolab.org'); + $john = $this->getTestUser('john@kolab.org'); + + // Test unauth request + $response = $this->post("/api/v4/shared-folders", []); + $response->assertStatus(401); + + // Test non-controller user + $response = $this->actingAs($jack)->post("/api/v4/shared-folders", []); + $response->assertStatus(403); + + // Test empty request + $response = $this->actingAs($john)->post("/api/v4/shared-folders", []); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertSame("The name field is required.", $json['errors']['name'][0]); + $this->assertSame("The type field is required.", $json['errors']['type'][0]); + $this->assertCount(2, $json); + $this->assertCount(2, $json['errors']); + + // Test too long name + $post = ['domain' => 'kolab.org', 'name' => str_repeat('A', 192), 'type' => 'unknown']; + $response = $this->actingAs($john)->post("/api/v4/shared-folders", $post); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertCount(2, $json); + $this->assertSame(["The name may not be greater than 191 characters."], $json['errors']['name']); + $this->assertSame(["The specified type is invalid."], $json['errors']['type']); + $this->assertCount(2, $json['errors']); + + // Test successful folder creation + $post['name'] = 'Test Folder'; + $post['type'] = 'event'; + $response = $this->actingAs($john)->post("/api/v4/shared-folders", $post); + $json = $response->json(); + + $response->assertStatus(200); + + $this->assertSame('success', $json['status']); + $this->assertSame("Shared folder created successfully.", $json['message']); + $this->assertCount(2, $json); + + $folder = SharedFolder::where('name', $post['name'])->first(); + $this->assertInstanceOf(SharedFolder::class, $folder); + $this->assertSame($post['type'], $folder->type); + $this->assertTrue($john->sharedFolders()->get()->contains($folder)); + + // Shared folder name must be unique within a domain + $post['type'] = 'mail'; + $response = $this->actingAs($john)->post("/api/v4/shared-folders", $post); + $response->assertStatus(422); + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertCount(2, $json); + $this->assertCount(1, $json['errors']); + $this->assertSame("The specified name is not available.", $json['errors']['name'][0]); + } + + /** + * Test shared folder update (PUT /api/v4/shared-folders/getTestUser('jack@kolab.org'); + $john = $this->getTestUser('john@kolab.org'); + $ned = $this->getTestUser('ned@kolab.org'); + + $folder = $this->getTestSharedFolder('folder-test@kolab.org'); + $folder->assignToWallet($john->wallets->first()); + + // Test unauthorized update + $response = $this->get("/api/v4/shared-folders/{$folder->id}", []); + $response->assertStatus(401); + + // Test unauthorized update + $response = $this->actingAs($jack)->get("/api/v4/shared-folders/{$folder->id}", []); + $response->assertStatus(403); + + // Name change + $post = [ + 'name' => 'Test Res', + ]; + + $response = $this->actingAs($john)->put("/api/v4/shared-folders/{$folder->id}", $post); + $json = $response->json(); + + $response->assertStatus(200); + + $this->assertSame('success', $json['status']); + $this->assertSame("Shared folder updated successfully.", $json['message']); + $this->assertCount(2, $json); + + $folder->refresh(); + $this->assertSame($post['name'], $folder->name); + } +} diff --git a/src/tests/Feature/Controller/SkusTest.php b/src/tests/Feature/Controller/SkusTest.php index 8dbc2244..277c5ec9 100644 --- a/src/tests/Feature/Controller/SkusTest.php +++ b/src/tests/Feature/Controller/SkusTest.php @@ -1,273 +1,282 @@ clearBetaEntitlements(); $this->clearMeetEntitlements(); Sku::where('title', 'test')->delete(); } /** * {@inheritDoc} */ public function tearDown(): void { $this->clearBetaEntitlements(); $this->clearMeetEntitlements(); Sku::where('title', 'test')->delete(); parent::tearDown(); } /** * Test fetching SKUs list for a domain (GET /domains//skus) */ public function testDomainSkus(): void { $user = $this->getTestUser('john@kolab.org'); $domain = $this->getTestDomain('kolab.org'); // Unauth access not allowed $response = $this->get("api/v4/domains/{$domain->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' => 'App\Handlers\Domain', ]); $tenant = Tenant::whereNotIn('id', [\config('app.tenant_id')])->first(); $nsku->tenant_id = $tenant->id; $nsku->save(); $response = $this->actingAs($user)->get("api/v4/domains/{$domain->id}/skus"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(1, $json); $this->assertSkuElement('domain-hosting', $json[0], [ 'prio' => 0, 'type' => 'domain', 'handler' => 'domainhosting', 'enabled' => false, 'readonly' => false, ]); } /** * Test fetching SKUs list */ public function testIndex(): void { // Unauth access not allowed $response = $this->get("api/v4/skus"); $response->assertStatus(401); $user = $this->getTestUser('john@kolab.org'); $sku = Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); // 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' => 'App\Handlers\Mailbox', ]); $tenant = Tenant::whereNotIn('id', [\config('app.tenant_id')])->first(); $nsku->tenant_id = $tenant->id; $nsku->save(); $response = $this->actingAs($user)->get("api/v4/skus"); $response->assertStatus(200); $json = $response->json(); - $this->assertCount(11, $json); + $this->assertCount(13, $json); $this->assertSame(100, $json[0]['prio']); $this->assertSame($sku->id, $json[0]['id']); $this->assertSame($sku->title, $json[0]['title']); $this->assertSame($sku->name, $json[0]['name']); $this->assertSame($sku->description, $json[0]['description']); $this->assertSame($sku->cost, $json[0]['cost']); $this->assertSame($sku->units_free, $json[0]['units_free']); $this->assertSame($sku->period, $json[0]['period']); $this->assertSame($sku->active, $json[0]['active']); $this->assertSame('user', $json[0]['type']); $this->assertSame('mailbox', $json[0]['handler']); } /** * Test fetching SKUs list for a user (GET /users//skus) */ public function testUserSkus(): 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' => 'App\Handlers\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(6, $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'], ]); $this->assertSkuElement('meet', $json[5], [ 'prio' => 50, 'type' => 'user', 'handler' => 'meet', 'enabled' => false, 'readonly' => false, 'required' => ['groupware'], ]); // 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(9, $json); + $this->assertCount(10, $json); $this->assertSkuElement('beta', $json[6], [ 'prio' => 10, 'type' => 'user', 'handler' => 'beta', 'enabled' => false, 'readonly' => false, ]); $this->assertSkuElement('beta-resources', $json[7], [ 'prio' => 10, 'type' => 'user', 'handler' => 'resources', // TODO: shouldn't it be beta-resources or beta/resources? 'enabled' => false, 'readonly' => false, 'required' => ['beta'], ]); - $this->assertSkuElement('distlist', $json[8], [ + $this->assertSkuElement('beta-shared-folders', $json[8], [ + 'prio' => 10, + 'type' => 'user', + 'handler' => 'sharedfolders', + 'enabled' => false, + 'readonly' => false, + 'required' => ['beta'], + ]); + + $this->assertSkuElement('distlist', $json[9], [ 'prio' => 10, 'type' => 'user', 'handler' => 'distlist', 'enabled' => false, 'readonly' => false, 'required' => ['beta'], ]); } /** * Assert content of the SKU element in an API response * * @param string $sku_title The SKU title * @param array $result The result to assert * @param array $other Other items the SKU itself does not include */ protected function assertSkuElement($sku_title, $result, $other = []): void { $sku = Sku::withEnvTenantContext()->where('title', $sku_title)->first(); $this->assertSame($sku->id, $result['id']); $this->assertSame($sku->title, $result['title']); $this->assertSame($sku->name, $result['name']); $this->assertSame($sku->description, $result['description']); $this->assertSame($sku->cost, $result['cost']); $this->assertSame($sku->units_free, $result['units_free']); $this->assertSame($sku->period, $result['period']); $this->assertSame($sku->active, $result['active']); foreach ($other as $key => $value) { $this->assertSame($value, $result[$key]); } $this->assertCount(8 + count($other), $result); } } diff --git a/src/tests/Feature/DomainTest.php b/src/tests/Feature/DomainTest.php index 620aee29..ebf09c72 100644 --- a/src/tests/Feature/DomainTest.php +++ b/src/tests/Feature/DomainTest.php @@ -1,347 +1,351 @@ domains as $domain) { $this->deleteTestDomain($domain); } $this->deleteTestUser('user@gmail.com'); } /** * {@inheritDoc} */ public function tearDown(): void { foreach ($this->domains as $domain) { $this->deleteTestDomain($domain); } $this->deleteTestUser('user@gmail.com'); parent::tearDown(); } /** * Test domain create/creating observer */ public function testCreate(): void { Queue::fake(); $domain = Domain::create([ 'namespace' => 'GMAIL.COM', 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ]); $result = Domain::where('namespace', 'gmail.com')->first(); $this->assertSame('gmail.com', $result->namespace); $this->assertSame($domain->id, $result->id); $this->assertSame($domain->type, $result->type); $this->assertSame(Domain::STATUS_NEW, $result->status); } /** * Test domain creating jobs */ public function testCreateJobs(): void { // Fake the queue, assert that no jobs were pushed... Queue::fake(); Queue::assertNothingPushed(); $domain = Domain::create([ 'namespace' => 'gmail.com', 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ]); Queue::assertPushed(\App\Jobs\Domain\CreateJob::class, 1); Queue::assertPushed( \App\Jobs\Domain\CreateJob::class, function ($job) use ($domain) { $domainId = TestCase::getObjectProperty($job, 'domainId'); $domainNamespace = TestCase::getObjectProperty($job, 'domainNamespace'); return $domainId === $domain->id && $domainNamespace === $domain->namespace; } ); $job = new \App\Jobs\Domain\CreateJob($domain->id); $job->handle(); } /** * Tests getPublicDomains() method */ public function testGetPublicDomains(): void { $public_domains = Domain::getPublicDomains(); $this->assertNotContains('public-active.com', $public_domains); $queue = Queue::fake(); $domain = Domain::create([ 'namespace' => 'public-active.com', 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ]); // External domains should not be returned $public_domains = Domain::getPublicDomains(); $this->assertNotContains('public-active.com', $public_domains); $domain->type = Domain::TYPE_PUBLIC; $domain->save(); $public_domains = Domain::getPublicDomains(); $this->assertContains('public-active.com', $public_domains); // Domains of other tenants should not be returned $tenant = Tenant::whereNotIn('id', [\config('app.tenant_id')])->first(); $domain->tenant_id = $tenant->id; $domain->save(); $public_domains = Domain::getPublicDomains(); $this->assertNotContains('public-active.com', $public_domains); } /** * Test domain (ownership) confirmation * * @group dns */ public function testConfirm(): void { /* DNS records for positive and negative tests - kolab.org: ci-success-cname A 212.103.80.148 ci-success-cname MX 10 mx01.kolabnow.com. ci-success-cname TXT "v=spf1 mx -all" kolab-verify.ci-success-cname CNAME 2b719cfa4e1033b1e1e132977ed4fe3e.ci-success-cname ci-failure-cname A 212.103.80.148 ci-failure-cname MX 10 mx01.kolabnow.com. kolab-verify.ci-failure-cname CNAME 2b719cfa4e1033b1e1e132977ed4fe3e.ci-failure-cname ci-success-txt A 212.103.80.148 ci-success-txt MX 10 mx01.kolabnow.com. ci-success-txt TXT "v=spf1 mx -all" ci-success-txt TXT "kolab-verify=de5d04ababb52d52e2519a2f16d11422" ci-failure-txt A 212.103.80.148 ci-failure-txt MX 10 mx01.kolabnow.com. kolab-verify.ci-failure-txt TXT "kolab-verify=de5d04ababb52d52e2519a2f16d11422" ci-failure-none A 212.103.80.148 ci-failure-none MX 10 mx01.kolabnow.com. */ $queue = Queue::fake(); $domain_props = ['status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL]; $domain = $this->getTestDomain('ci-failure-none.kolab.org', $domain_props); $this->assertTrue($domain->confirm() === false); $this->assertFalse($domain->isConfirmed()); $domain = $this->getTestDomain('ci-failure-txt.kolab.org', $domain_props); $this->assertTrue($domain->confirm() === false); $this->assertFalse($domain->isConfirmed()); $domain = $this->getTestDomain('ci-failure-cname.kolab.org', $domain_props); $this->assertTrue($domain->confirm() === false); $this->assertFalse($domain->isConfirmed()); $domain = $this->getTestDomain('ci-success-txt.kolab.org', $domain_props); $this->assertTrue($domain->confirm()); $this->assertTrue($domain->isConfirmed()); $domain = $this->getTestDomain('ci-success-cname.kolab.org', $domain_props); $this->assertTrue($domain->confirm()); $this->assertTrue($domain->isConfirmed()); } /** * Test domain deletion */ public function testDelete(): void { Queue::fake(); $domain = $this->getTestDomain('gmail.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_PUBLIC, ]); $domain->delete(); $this->assertTrue($domain->fresh()->trashed()); $this->assertFalse($domain->fresh()->isDeleted()); // Delete the domain for real $job = new \App\Jobs\Domain\DeleteJob($domain->id); $job->handle(); $this->assertTrue(Domain::withTrashed()->where('id', $domain->id)->first()->isDeleted()); $domain->forceDelete(); $this->assertCount(0, Domain::withTrashed()->where('id', $domain->id)->get()); } /** * Test isEmpty() method */ public function testIsEmpty(): void { Queue::fake(); $this->deleteTestUser('user@gmail.com'); $this->deleteTestGroup('group@gmail.com'); $this->deleteTestResource('resource@gmail.com'); + $this->deleteTestSharedFolder('folder@gmail.com'); // Empty domain $domain = $this->getTestDomain('gmail.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ]); $this->assertTrue($domain->isEmpty()); $this->getTestUser('user@gmail.com'); $this->assertFalse($domain->isEmpty()); $this->deleteTestUser('user@gmail.com'); $this->assertTrue($domain->isEmpty()); $this->getTestGroup('group@gmail.com'); $this->assertFalse($domain->isEmpty()); $this->deleteTestGroup('group@gmail.com'); $this->assertTrue($domain->isEmpty()); $this->getTestResource('resource@gmail.com'); $this->assertFalse($domain->isEmpty()); $this->deleteTestResource('resource@gmail.com'); + $this->getTestSharedFolder('folder@gmail.com'); + $this->assertFalse($domain->isEmpty()); + $this->deleteTestSharedFolder('folder@gmail.com'); // TODO: Test with an existing alias, but not other objects in a domain // Empty public domain $domain = Domain::where('namespace', 'libertymail.net')->first(); $this->assertFalse($domain->isEmpty()); } /** * Test domain restoring */ public function testRestore(): void { Queue::fake(); $domain = $this->getTestDomain('gmail.com', [ 'status' => Domain::STATUS_NEW | Domain::STATUS_SUSPENDED | Domain::STATUS_LDAP_READY | Domain::STATUS_CONFIRMED, 'type' => Domain::TYPE_PUBLIC, ]); $user = $this->getTestUser('user@gmail.com'); $sku = \App\Sku::where('title', 'domain-hosting')->first(); $now = \Carbon\Carbon::now(); // Assign two entitlements to the domain, so we can assert that only the // ones deleted last will be restored $ent1 = \App\Entitlement::create([ 'wallet_id' => $user->wallets->first()->id, 'sku_id' => $sku->id, 'cost' => 0, 'entitleable_id' => $domain->id, 'entitleable_type' => Domain::class, ]); $ent2 = \App\Entitlement::create([ 'wallet_id' => $user->wallets->first()->id, 'sku_id' => $sku->id, 'cost' => 0, 'entitleable_id' => $domain->id, 'entitleable_type' => Domain::class, ]); $domain->delete(); $this->assertTrue($domain->fresh()->trashed()); $this->assertFalse($domain->fresh()->isDeleted()); $this->assertTrue($ent1->fresh()->trashed()); $this->assertTrue($ent2->fresh()->trashed()); // Backdate some properties \App\Entitlement::withTrashed()->where('id', $ent2->id)->update(['deleted_at' => $now->subMinutes(2)]); \App\Entitlement::withTrashed()->where('id', $ent1->id)->update(['updated_at' => $now->subMinutes(10)]); Queue::fake(); $domain->restore(); $domain->refresh(); $this->assertFalse($domain->trashed()); $this->assertFalse($domain->isDeleted()); $this->assertFalse($domain->isSuspended()); $this->assertFalse($domain->isLdapReady()); $this->assertTrue($domain->isActive()); $this->assertTrue($domain->isConfirmed()); // Assert entitlements $this->assertTrue($ent2->fresh()->trashed()); $this->assertFalse($ent1->fresh()->trashed()); $this->assertTrue($ent1->updated_at->greaterThan(\Carbon\Carbon::now()->subSeconds(5))); // We expect only one CreateJob and one UpdateJob // Because how Illuminate/Database/Eloquent/SoftDeletes::restore() method // is implemented we cannot skip the UpdateJob in any way. // I don't want to overwrite this method, the extra job shouldn't do any harm. $this->assertCount(2, Queue::pushedJobs()); // @phpstan-ignore-line Queue::assertPushed(\App\Jobs\Domain\UpdateJob::class, 1); Queue::assertPushed(\App\Jobs\Domain\CreateJob::class, 1); Queue::assertPushed( \App\Jobs\Domain\CreateJob::class, function ($job) use ($domain) { return $domain->id === TestCase::getObjectProperty($job, 'domainId'); } ); } } diff --git a/src/tests/Feature/Jobs/DomainCreateTest.php b/src/tests/Feature/Jobs/Domain/CreateTest.php similarity index 96% rename from src/tests/Feature/Jobs/DomainCreateTest.php rename to src/tests/Feature/Jobs/Domain/CreateTest.php index f369362f..0e5989d8 100644 --- a/src/tests/Feature/Jobs/DomainCreateTest.php +++ b/src/tests/Feature/Jobs/Domain/CreateTest.php @@ -1,74 +1,74 @@ deleteTestDomain('domain-create-test.com'); } public function tearDown(): void { $this->deleteTestDomain('domain-create-test.com'); parent::tearDown(); } /** * Test job handle * * @group ldap */ public function testHandle(): void { $domain = $this->getTestDomain( 'domain-create-test.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ] ); $this->assertFalse($domain->isLdapReady()); // Fake the queue, assert that no jobs were pushed... Queue::fake(); Queue::assertNothingPushed(); $job = new \App\Jobs\Domain\CreateJob($domain->id); $job->handle(); $this->assertTrue($domain->fresh()->isLdapReady()); Queue::assertPushed(\App\Jobs\Domain\VerifyJob::class, 1); Queue::assertPushed( \App\Jobs\Domain\VerifyJob::class, function ($job) use ($domain) { $domainId = TestCase::getObjectProperty($job, 'domainId'); $domainNamespace = TestCase::getObjectProperty($job, 'domainNamespace'); return $domainId === $domain->id && $domainNamespace === $domain->namespace; } ); // Test job releasing on unknown identifier $job = new \App\Jobs\Domain\CreateJob(123); $job->handle(); $this->assertTrue($job->isReleased()); $this->assertFalse($job->hasFailed()); } } diff --git a/src/tests/Feature/Jobs/DomainVerifyTest.php b/src/tests/Feature/Jobs/Domain/VerifyTest.php similarity index 96% rename from src/tests/Feature/Jobs/DomainVerifyTest.php rename to src/tests/Feature/Jobs/Domain/VerifyTest.php index f0e12d5f..c94ff155 100644 --- a/src/tests/Feature/Jobs/DomainVerifyTest.php +++ b/src/tests/Feature/Jobs/Domain/VerifyTest.php @@ -1,81 +1,81 @@ deleteTestDomain('gmail.com'); $this->deleteTestDomain('some-non-existing-domain.fff'); } public function tearDown(): void { $this->deleteTestDomain('gmail.com'); $this->deleteTestDomain('some-non-existing-domain.fff'); parent::tearDown(); } /** * Test job handle (existing domain) * * @group dns */ public function testHandle(): void { $domain = $this->getTestDomain( 'gmail.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ] ); $this->assertFalse($domain->isVerified()); $job = new \App\Jobs\Domain\VerifyJob($domain->id); $job->handle(); $this->assertTrue($domain->fresh()->isVerified()); // Test non-existing domain ID $job = new \App\Jobs\Domain\VerifyJob(123); $job->handle(); $this->assertTrue($job->hasFailed()); $this->assertSame("Domain 123 could not be found in the database.", $job->failureMessage); } /** * Test job handle (non-existing domain) * * @group dns */ public function testHandleNonExisting(): void { $domain = $this->getTestDomain( 'some-non-existing-domain.fff', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ] ); $this->assertFalse($domain->isVerified()); $job = new \App\Jobs\Domain\VerifyJob($domain->id); $job->handle(); $this->assertFalse($domain->fresh()->isVerified()); } } diff --git a/src/tests/Feature/Jobs/Resource/CreateTest.php b/src/tests/Feature/Jobs/Resource/CreateTest.php new file mode 100644 index 00000000..496b8228 --- /dev/null +++ b/src/tests/Feature/Jobs/Resource/CreateTest.php @@ -0,0 +1,83 @@ +deleteTestResource('resource-test@' . \config('app.domain')); + } + + public function tearDown(): void + { + $this->deleteTestResource('resource-test@' . \config('app.domain')); + + parent::tearDown(); + } + + /** + * Test job handle + * + * @group ldap + */ + public function testHandle(): void + { + Queue::fake(); + + // Test unknown resource + $job = new \App\Jobs\Resource\CreateJob(123); + $job->handle(); + + $this->assertTrue($job->isReleased()); + $this->assertFalse($job->hasFailed()); + + $resource = $this->getTestResource('resource-test@' . \config('app.domain')); + + $this->assertFalse($resource->isLdapReady()); + + // Test resource creation + $job = new \App\Jobs\Resource\CreateJob($resource->id); + $job->handle(); + + $this->assertTrue($resource->fresh()->isLdapReady()); + $this->assertFalse($job->hasFailed()); + + // Test job failures + $job = new \App\Jobs\Resource\CreateJob($resource->id); + $job->handle(); + + $this->assertTrue($job->hasFailed()); + $this->assertSame("Resource {$resource->id} is already marked as ldap-ready.", $job->failureMessage); + + $resource->status |= Resource::STATUS_DELETED; + $resource->save(); + + $job = new \App\Jobs\Resource\CreateJob($resource->id); + $job->handle(); + + $this->assertTrue($job->hasFailed()); + $this->assertSame("Resource {$resource->id} is marked as deleted.", $job->failureMessage); + + $resource->status ^= Resource::STATUS_DELETED; + $resource->save(); + $resource->delete(); + + $job = new \App\Jobs\Resource\CreateJob($resource->id); + $job->handle(); + + $this->assertTrue($job->hasFailed()); + $this->assertSame("Resource {$resource->id} is actually deleted.", $job->failureMessage); + + // TODO: Test failures on domain sanity checks + } +} diff --git a/src/tests/Feature/Jobs/Resource/DeleteTest.php b/src/tests/Feature/Jobs/Resource/DeleteTest.php new file mode 100644 index 00000000..6051af32 --- /dev/null +++ b/src/tests/Feature/Jobs/Resource/DeleteTest.php @@ -0,0 +1,76 @@ +deleteTestResource('resource-test@' . \config('app.domain')); + } + + public function tearDown(): void + { + $this->deleteTestResource('resource-test@' . \config('app.domain')); + + parent::tearDown(); + } + + /** + * Test job handle + * + * @group ldap + */ + public function testHandle(): void + { + Queue::fake(); + + // Test non-existing resource ID + $job = new \App\Jobs\Resource\DeleteJob(123); + $job->handle(); + + $this->assertTrue($job->hasFailed()); + $this->assertSame("Resource 123 could not be found in the database.", $job->failureMessage); + + $resource = $this->getTestResource('resource-test@' . \config('app.domain'), [ + 'status' => Resource::STATUS_NEW + ]); + + // create the resource first + $job = new \App\Jobs\Resource\CreateJob($resource->id); + $job->handle(); + + $resource->refresh(); + + $this->assertTrue($resource->isLdapReady()); + + // Test successful deletion + $resource->status |= Resource::STATUS_IMAP_READY; + $resource->save(); + + $job = new \App\Jobs\Resource\DeleteJob($resource->id); + $job->handle(); + + $resource->refresh(); + + $this->assertFalse($resource->isLdapReady()); + $this->assertFalse($resource->isImapReady()); + $this->assertTrue($resource->isDeleted()); + + // Test deleting already deleted resource + $job = new \App\Jobs\Resource\DeleteJob($resource->id); + $job->handle(); + + $this->assertTrue($job->hasFailed()); + $this->assertSame("Resource {$resource->id} is already marked as deleted.", $job->failureMessage); + } +} diff --git a/src/tests/Feature/Jobs/Resource/UpdateTest.php b/src/tests/Feature/Jobs/Resource/UpdateTest.php new file mode 100644 index 00000000..8b509a86 --- /dev/null +++ b/src/tests/Feature/Jobs/Resource/UpdateTest.php @@ -0,0 +1,82 @@ +deleteTestResource('resource-test@' . \config('app.domain')); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestResource('resource-test@' . \config('app.domain')); + + parent::tearDown(); + } + + /** + * Test job handle + * + * @group ldap + */ + public function testHandle(): void + { + Queue::fake(); + + // Test non-existing resource ID + $job = new \App\Jobs\Resource\UpdateJob(123); + $job->handle(); + + $this->assertTrue($job->hasFailed()); + $this->assertSame("Resource 123 could not be found in the database.", $job->failureMessage); + + $resource = $this->getTestResource('resource-test@' . \config('app.domain')); + + // Create the resource in LDAP + $job = new \App\Jobs\Resource\CreateJob($resource->id); + $job->handle(); + + $resource->setConfig(['invitation_policy' => 'accept']); + + $job = new \App\Jobs\Resource\UpdateJob($resource->id); + $job->handle(); + + $ldap_resource = LDAP::getResource($resource->email); + + $this->assertSame('ACT_ACCEPT', $ldap_resource['kolabinvitationpolicy']); + + // Test that the job is being deleted if the resource is not ldap ready or is deleted + $resource->refresh(); + $resource->status = Resource::STATUS_NEW | Resource::STATUS_ACTIVE; + $resource->save(); + + $job = new \App\Jobs\Resource\UpdateJob($resource->id); + $job->handle(); + + $this->assertTrue($job->isDeleted()); + + $resource->status = Resource::STATUS_NEW | Resource::STATUS_ACTIVE + | Resource::STATUS_LDAP_READY | Resource::STATUS_DELETED; + $resource->save(); + + $job = new \App\Jobs\Resource\UpdateJob($resource->id); + $job->handle(); + + $this->assertTrue($job->isDeleted()); + } +} diff --git a/src/tests/Feature/Jobs/Resource/VerifyTest.php b/src/tests/Feature/Jobs/Resource/VerifyTest.php new file mode 100644 index 00000000..f8732dcd --- /dev/null +++ b/src/tests/Feature/Jobs/Resource/VerifyTest.php @@ -0,0 +1,75 @@ +getTestResource('resource-test1@kolab.org'); + $resource->status |= Resource::STATUS_IMAP_READY; + $resource->save(); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $resource = $this->getTestResource('resource-test1@kolab.org'); + $resource->status |= Resource::STATUS_IMAP_READY; + $resource->save(); + + parent::tearDown(); + } + + /** + * Test job handle + * + * @group imap + */ + public function testHandle(): void + { + Queue::fake(); + + // Test non-existing resource ID + $job = new \App\Jobs\Resource\VerifyJob(123); + $job->handle(); + + $this->assertTrue($job->hasFailed()); + $this->assertSame("Resource 123 could not be found in the database.", $job->failureMessage); + + // Test existing resource + $resource = $this->getTestResource('resource-test1@kolab.org'); + + if ($resource->isImapReady()) { + $resource->status ^= Resource::STATUS_IMAP_READY; + $resource->save(); + } + + $this->assertFalse($resource->isImapReady()); + + for ($i = 0; $i < 10; $i++) { + $job = new \App\Jobs\Resource\VerifyJob($resource->id); + $job->handle(); + + if ($resource->fresh()->isImapReady()) { + $this->assertTrue(true); + return; + } + + sleep(1); + } + + $this->assertTrue(false, "Unable to verify the shared folder is set up in time"); + } +} diff --git a/src/tests/Feature/Jobs/SharedFolder/CreateTest.php b/src/tests/Feature/Jobs/SharedFolder/CreateTest.php new file mode 100644 index 00000000..f9aca4b5 --- /dev/null +++ b/src/tests/Feature/Jobs/SharedFolder/CreateTest.php @@ -0,0 +1,83 @@ +deleteTestSharedFolder('folder-test@' . \config('app.domain')); + } + + public function tearDown(): void + { + $this->deleteTestSharedFolder('folder-test@' . \config('app.domain')); + + parent::tearDown(); + } + + /** + * Test job handle + * + * @group ldap + */ + public function testHandle(): void + { + Queue::fake(); + + // Test unknown folder + $job = new \App\Jobs\SharedFolder\CreateJob(123); + $job->handle(); + + $this->assertTrue($job->isReleased()); + $this->assertFalse($job->hasFailed()); + + $folder = $this->getTestSharedFolder('folder-test@' . \config('app.domain')); + + $this->assertFalse($folder->isLdapReady()); + + // Test shared folder creation + $job = new \App\Jobs\SharedFolder\CreateJob($folder->id); + $job->handle(); + + $this->assertTrue($folder->fresh()->isLdapReady()); + $this->assertFalse($job->hasFailed()); + + // Test job failures + $job = new \App\Jobs\SharedFolder\CreateJob($folder->id); + $job->handle(); + + $this->assertTrue($job->hasFailed()); + $this->assertSame("Shared folder {$folder->id} is already marked as ldap-ready.", $job->failureMessage); + + $folder->status |= SharedFolder::STATUS_DELETED; + $folder->save(); + + $job = new \App\Jobs\SharedFolder\CreateJob($folder->id); + $job->handle(); + + $this->assertTrue($job->hasFailed()); + $this->assertSame("Shared folder {$folder->id} is marked as deleted.", $job->failureMessage); + + $folder->status ^= SharedFolder::STATUS_DELETED; + $folder->save(); + $folder->delete(); + + $job = new \App\Jobs\SharedFolder\CreateJob($folder->id); + $job->handle(); + + $this->assertTrue($job->hasFailed()); + $this->assertSame("Shared folder {$folder->id} is actually deleted.", $job->failureMessage); + + // TODO: Test failures on domain sanity checks + } +} diff --git a/src/tests/Feature/Jobs/SharedFolder/DeleteTest.php b/src/tests/Feature/Jobs/SharedFolder/DeleteTest.php new file mode 100644 index 00000000..66f5d0c4 --- /dev/null +++ b/src/tests/Feature/Jobs/SharedFolder/DeleteTest.php @@ -0,0 +1,76 @@ +deleteTestSharedFolder('folder-test@' . \config('app.domain')); + } + + public function tearDown(): void + { + $this->deleteTestSharedFolder('folder-test@' . \config('app.domain')); + + parent::tearDown(); + } + + /** + * Test job handle + * + * @group ldap + */ + public function testHandle(): void + { + Queue::fake(); + + // Test non-existing folder ID + $job = new \App\Jobs\SharedFolder\DeleteJob(123); + $job->handle(); + + $this->assertTrue($job->hasFailed()); + $this->assertSame("Shared folder 123 could not be found in the database.", $job->failureMessage); + + $folder = $this->getTestSharedFolder('folder-test@' . \config('app.domain'), [ + 'status' => SharedFolder::STATUS_NEW + ]); + + // create the shared folder first + $job = new \App\Jobs\SharedFolder\CreateJob($folder->id); + $job->handle(); + + $folder->refresh(); + + $this->assertTrue($folder->isLdapReady()); + + // Test successful deletion + $folder->status |= SharedFolder::STATUS_IMAP_READY; + $folder->save(); + + $job = new \App\Jobs\SharedFolder\DeleteJob($folder->id); + $job->handle(); + + $folder->refresh(); + + $this->assertFalse($folder->isLdapReady()); + $this->assertFalse($folder->isImapReady()); + $this->assertTrue($folder->isDeleted()); + + // Test deleting already deleted folder + $job = new \App\Jobs\SharedFolder\DeleteJob($folder->id); + $job->handle(); + + $this->assertTrue($job->hasFailed()); + $this->assertSame("Shared folder {$folder->id} is already marked as deleted.", $job->failureMessage); + } +} diff --git a/src/tests/Feature/Jobs/SharedFolder/UpdateTest.php b/src/tests/Feature/Jobs/SharedFolder/UpdateTest.php new file mode 100644 index 00000000..292726b5 --- /dev/null +++ b/src/tests/Feature/Jobs/SharedFolder/UpdateTest.php @@ -0,0 +1,78 @@ +deleteTestSharedFolder('folder-test@' . \config('app.domain')); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestSharedFolder('folder-test@' . \config('app.domain')); + + parent::tearDown(); + } + + /** + * Test job handle + * + * @group ldap + */ + public function testHandle(): void + { + Queue::fake(); + + // Test non-existing folder ID + $job = new \App\Jobs\SharedFolder\UpdateJob(123); + $job->handle(); + + $this->assertTrue($job->hasFailed()); + $this->assertSame("Shared folder 123 could not be found in the database.", $job->failureMessage); + + $folder = $this->getTestSharedFolder('folder-test@' . \config('app.domain')); + + // Create the folder in LDAP + $job = new \App\Jobs\SharedFolder\CreateJob($folder->id); + $job->handle(); + + $job = new \App\Jobs\SharedFolder\UpdateJob($folder->id); + $job->handle(); + + $this->assertTrue(is_array(LDAP::getSharedFolder($folder->email))); + + // Test that the job is being deleted if the folder is not ldap ready or is deleted + $folder->refresh(); + $folder->status = SharedFolder::STATUS_NEW | SharedFolder::STATUS_ACTIVE; + $folder->save(); + + $job = new \App\Jobs\SharedFolder\UpdateJob($folder->id); + $job->handle(); + + $this->assertTrue($job->isDeleted()); + + $folder->status = SharedFolder::STATUS_NEW | SharedFolder::STATUS_ACTIVE + | SharedFolder::STATUS_LDAP_READY | SharedFolder::STATUS_DELETED; + $folder->save(); + + $job = new \App\Jobs\SharedFolder\UpdateJob($folder->id); + $job->handle(); + + $this->assertTrue($job->isDeleted()); + } +} diff --git a/src/tests/Feature/Jobs/SharedFolder/VerifyTest.php b/src/tests/Feature/Jobs/SharedFolder/VerifyTest.php new file mode 100644 index 00000000..39d8a3db --- /dev/null +++ b/src/tests/Feature/Jobs/SharedFolder/VerifyTest.php @@ -0,0 +1,75 @@ +getTestSharedFolder('folder-event@kolab.org'); + $folder->status |= SharedFolder::STATUS_IMAP_READY; + $folder->save(); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $folder = $this->getTestSharedFolder('folder-event@kolab.org'); + $folder->status |= SharedFolder::STATUS_IMAP_READY; + $folder->save(); + + parent::tearDown(); + } + + /** + * Test job handle + * + * @group imap + */ + public function testHandle(): void + { + Queue::fake(); + + // Test non-existing folder ID + $job = new \App\Jobs\SharedFolder\VerifyJob(123); + $job->handle(); + + $this->assertTrue($job->hasFailed()); + $this->assertSame("Shared folder 123 could not be found in the database.", $job->failureMessage); + + // Test existing folder + $folder = $this->getTestSharedFolder('folder-event@kolab.org'); + + if ($folder->isImapReady()) { + $folder->status ^= SharedFolder::STATUS_IMAP_READY; + $folder->save(); + } + + $this->assertFalse($folder->isImapReady()); + + for ($i = 0; $i < 10; $i++) { + $job = new \App\Jobs\SharedFolder\VerifyJob($folder->id); + $job->handle(); + + if ($folder->fresh()->isImapReady()) { + $this->assertTrue(true); + return; + } + + sleep(1); + } + + $this->assertTrue(false, "Unable to verify the shared folder is set up in time"); + } +} diff --git a/src/tests/Feature/Jobs/UserCreateTest.php b/src/tests/Feature/Jobs/User/CreateTest.php similarity index 96% rename from src/tests/Feature/Jobs/UserCreateTest.php rename to src/tests/Feature/Jobs/User/CreateTest.php index 0a67c562..fcc696e7 100644 --- a/src/tests/Feature/Jobs/UserCreateTest.php +++ b/src/tests/Feature/Jobs/User/CreateTest.php @@ -1,78 +1,78 @@ deleteTestUser('new-job-user@' . \config('app.domain')); } public function tearDown(): void { $this->deleteTestUser('new-job-user@' . \config('app.domain')); parent::tearDown(); } /** * Test job handle * * @group ldap */ public function testHandle(): void { $user = $this->getTestUser('new-job-user@' . \config('app.domain')); $this->assertFalse($user->isLdapReady()); $job = new \App\Jobs\User\CreateJob($user->id); $job->handle(); $this->assertTrue($user->fresh()->isLdapReady()); $this->assertFalse($job->hasFailed()); // Test job failures $job = new \App\Jobs\User\CreateJob($user->id); $job->handle(); $this->assertTrue($job->hasFailed()); $this->assertSame("User {$user->id} is already marked as ldap-ready.", $job->failureMessage); $user->status |= User::STATUS_DELETED; $user->save(); $job = new \App\Jobs\User\CreateJob($user->id); $job->handle(); $this->assertTrue($job->hasFailed()); $this->assertSame("User {$user->id} is marked as deleted.", $job->failureMessage); $user->status ^= User::STATUS_DELETED; $user->save(); $user->delete(); $job = new \App\Jobs\User\CreateJob($user->id); $job->handle(); $this->assertTrue($job->hasFailed()); $this->assertSame("User {$user->id} is actually deleted.", $job->failureMessage); // TODO: Test failures on domain sanity checks $job = new \App\Jobs\User\CreateJob(123); $job->handle(); $this->assertTrue($job->isReleased()); $this->assertFalse($job->hasFailed()); } } diff --git a/src/tests/Feature/Jobs/UserUpdateTest.php b/src/tests/Feature/Jobs/User/UpdateTest.php similarity index 97% rename from src/tests/Feature/Jobs/UserUpdateTest.php rename to src/tests/Feature/Jobs/User/UpdateTest.php index 27d371a3..72776b67 100644 --- a/src/tests/Feature/Jobs/UserUpdateTest.php +++ b/src/tests/Feature/Jobs/User/UpdateTest.php @@ -1,94 +1,94 @@ deleteTestUser('new-job-user@' . \config('app.domain')); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('new-job-user@' . \config('app.domain')); parent::tearDown(); } /** * Test job handle * * @group ldap */ public function testHandle(): void { // Ignore any jobs created here (e.g. on setAliases() use) Queue::fake(); $user = $this->getTestUser('new-job-user@' . \config('app.domain')); // Create the user in LDAP $job = new \App\Jobs\User\CreateJob($user->id); $job->handle(); // Test setting two aliases $aliases = [ 'new-job-user1@' . \config('app.domain'), 'new-job-user2@' . \config('app.domain'), ]; $user->setAliases($aliases); $job = new \App\Jobs\User\UpdateJob($user->id); $job->handle(); $ldap_user = LDAP::getUser('new-job-user@' . \config('app.domain')); $this->assertSame($aliases, $ldap_user['alias']); // Test updating aliases list $aliases = [ 'new-job-user1@' . \config('app.domain'), ]; $user->setAliases($aliases); $job = new \App\Jobs\User\UpdateJob($user->id); $job->handle(); $ldap_user = LDAP::getUser('new-job-user@' . \config('app.domain')); $this->assertSame($aliases, (array) $ldap_user['alias']); // Test unsetting aliases list $aliases = []; $user->setAliases($aliases); $job = new \App\Jobs\User\UpdateJob($user->id); $job->handle(); $ldap_user = LDAP::getUser('new-job-user@' . \config('app.domain')); $this->assertTrue(empty($ldap_user['alias'])); // Test non-existing user ID $job = new \App\Jobs\User\UpdateJob(123); $job->handle(); $this->assertTrue($job->hasFailed()); $this->assertSame("User 123 could not be found in the database.", $job->failureMessage); } } diff --git a/src/tests/Feature/Jobs/UserVerifyTest.php b/src/tests/Feature/Jobs/User/VerifyTest.php similarity index 95% rename from src/tests/Feature/Jobs/UserVerifyTest.php rename to src/tests/Feature/Jobs/User/VerifyTest.php index d2130c18..17326fe4 100644 --- a/src/tests/Feature/Jobs/UserVerifyTest.php +++ b/src/tests/Feature/Jobs/User/VerifyTest.php @@ -1,75 +1,75 @@ getTestUser('ned@kolab.org'); $ned->status |= User::STATUS_IMAP_READY; $ned->save(); } /** * {@inheritDoc} */ public function tearDown(): void { $ned = $this->getTestUser('ned@kolab.org'); $ned->status |= User::STATUS_IMAP_READY; $ned->save(); parent::tearDown(); } /** * Test job handle * * @group imap */ public function testHandle(): void { Queue::fake(); // Test non-existing user ID $job = new \App\Jobs\User\VerifyJob(123); $job->handle(); $this->assertTrue($job->hasFailed()); $this->assertSame("User 123 could not be found in the database.", $job->failureMessage); // Test existing user $user = $this->getTestUser('ned@kolab.org'); if ($user->isImapReady()) { $user->status ^= User::STATUS_IMAP_READY; $user->save(); } $this->assertFalse($user->isImapReady()); for ($i = 0; $i < 10; $i++) { $job = new \App\Jobs\User\VerifyJob($user->id); $job->handle(); if ($user->fresh()->isImapReady()) { $this->assertTrue(true); return; } sleep(1); } $this->assertTrue(false, "Unable to verify the IMAP account is set up in time"); } } diff --git a/src/tests/Feature/SharedFolderTest.php b/src/tests/Feature/SharedFolderTest.php new file mode 100644 index 00000000..bf486ee6 --- /dev/null +++ b/src/tests/Feature/SharedFolderTest.php @@ -0,0 +1,304 @@ +deleteTestUser('user-test@kolabnow.com'); + SharedFolder::withTrashed()->where('email', 'like', '%@kolabnow.com')->each(function ($folder) { + $this->deleteTestSharedFolder($folder->email); + }); + } + + public function tearDown(): void + { + $this->deleteTestUser('user-test@kolabnow.com'); + SharedFolder::withTrashed()->where('email', 'like', '%@kolabnow.com')->each(function ($folder) { + $this->deleteTestSharedFolder($folder->email); + }); + + parent::tearDown(); + } + + /** + * Tests for SharedFolder::assignToWallet() + */ + public function testAssignToWallet(): void + { + $user = $this->getTestUser('user-test@kolabnow.com'); + $folder = $this->getTestSharedFolder('folder-test@kolabnow.com'); + + $result = $folder->assignToWallet($user->wallets->first()); + + $this->assertSame($folder, $result); + $this->assertSame(1, $folder->entitlements()->count()); + $this->assertSame('shared-folder', $folder->entitlements()->first()->sku->title); + + // Can't be done twice on the same folder + $this->expectException(\Exception::class); + $result->assignToWallet($user->wallets->first()); + } + + /** + * Test SharedFolder::getConfig() and setConfig() methods + */ + public function testConfigTrait(): void + { + Queue::fake(); + + $folder = new SharedFolder(); + $folder->email = 'folder-test@kolabnow.com'; + $folder->name = 'Test'; + $folder->save(); + $john = $this->getTestUser('john@kolab.org'); + $folder->assignToWallet($john->wallets->first()); + + $this->assertSame(['acl' => []], $folder->getConfig()); + + $result = $folder->setConfig(['acl' => ['anyone, read-only'], 'unknown' => false]); + + $this->assertSame(['acl' => ['anyone, read-only']], $folder->getConfig()); + $this->assertSame('["anyone, read-only"]', $folder->getSetting('acl')); + $this->assertSame(['unknown' => "The requested configuration parameter is not supported."], $result); + + $result = $folder->setConfig(['acl' => ['anyone, unknown']]); + + $this->assertSame(['acl' => ['anyone, read-only']], $folder->getConfig()); + $this->assertSame('["anyone, read-only"]', $folder->getSetting('acl')); + $this->assertSame(['acl' => ["The entry format is invalid. Expected an email address."]], $result); + + // Test valid user for ACL + $result = $folder->setConfig(['acl' => ['john@kolab.org, full']]); + + $this->assertSame(['acl' => ['john@kolab.org, full']], $folder->getConfig()); + $this->assertSame('["john@kolab.org, full"]', $folder->getSetting('acl')); + $this->assertSame([], $result); + + // Test invalid user for ACL + $result = $folder->setConfig(['acl' => ['john, full']]); + + $this->assertSame(['acl' => ['john@kolab.org, full']], $folder->getConfig()); + $this->assertSame('["john@kolab.org, full"]', $folder->getSetting('acl')); + $this->assertSame(['acl' => ["The specified email address is invalid."]], $result); + + // Other invalid entries + $acl = [ + // Test non-existing user for ACL + 'unknown@kolab.org, full', + // Test existing user from a different wallet + 'user@sample-tenant.dev-local, read-only', + // Valid entry + 'john@kolab.org, read-write', + ]; + + $result = $folder->setConfig(['acl' => $acl]); + $this->assertCount(2, $result['acl']); + $this->assertSame("The specified email address does not exist.", $result['acl'][0]); + $this->assertSame("The specified email address does not exist.", $result['acl'][1]); + $this->assertSame(['acl' => ['john@kolab.org, full']], $folder->getConfig()); + $this->assertSame('["john@kolab.org, full"]', $folder->getSetting('acl')); + } + + /** + * Test creating a shared folder + */ + public function testCreate(): void + { + Queue::fake(); + + $folder = new SharedFolder(); + $folder->name = 'Reśo'; + $folder->domain = 'kolabnow.com'; + $folder->save(); + + $this->assertMatchesRegularExpression('/^[0-9]{1,20}$/', $folder->id); + $this->assertMatchesRegularExpression('/^mail-[0-9]{1,20}@kolabnow\.com$/', $folder->email); + $this->assertSame('Reśo', $folder->name); + $this->assertTrue($folder->isNew()); + $this->assertTrue($folder->isActive()); + $this->assertFalse($folder->isDeleted()); + $this->assertFalse($folder->isLdapReady()); + $this->assertFalse($folder->isImapReady()); + + $settings = $folder->settings()->get(); + $this->assertCount(1, $settings); + $this->assertSame('folder', $settings[0]->key); + $this->assertSame('shared/Reśo@kolabnow.com', $settings[0]->value); + + Queue::assertPushed( + \App\Jobs\SharedFolder\CreateJob::class, + function ($job) use ($folder) { + $folderEmail = TestCase::getObjectProperty($job, 'folderEmail'); + $folderId = TestCase::getObjectProperty($job, 'folderId'); + + return $folderEmail === $folder->email + && $folderId === $folder->id; + } + ); + + Queue::assertPushedWithChain( + \App\Jobs\SharedFolder\CreateJob::class, + [ + \App\Jobs\SharedFolder\VerifyJob::class, + ] + ); + } + + /** + * Test a shared folder deletion and force-deletion + */ + public function testDelete(): void + { + Queue::fake(); + + $user = $this->getTestUser('user-test@kolabnow.com'); + $folder = $this->getTestSharedFolder('folder-test@kolabnow.com'); + $folder->assignToWallet($user->wallets->first()); + + $entitlements = \App\Entitlement::where('entitleable_id', $folder->id); + + $this->assertSame(1, $entitlements->count()); + + $folder->delete(); + + $this->assertTrue($folder->fresh()->trashed()); + $this->assertSame(0, $entitlements->count()); + $this->assertSame(1, $entitlements->withTrashed()->count()); + + $folder->forceDelete(); + + $this->assertSame(0, $entitlements->withTrashed()->count()); + $this->assertCount(0, SharedFolder::withTrashed()->where('id', $folder->id)->get()); + + Queue::assertPushed(\App\Jobs\SharedFolder\DeleteJob::class, 1); + Queue::assertPushed( + \App\Jobs\SharedFolder\DeleteJob::class, + function ($job) use ($folder) { + $folderEmail = TestCase::getObjectProperty($job, 'folderEmail'); + $folderId = TestCase::getObjectProperty($job, 'folderId'); + + return $folderEmail === $folder->email + && $folderId === $folder->id; + } + ); + } + + /** + * Tests for SharedFolder::emailExists() + */ + public function testEmailExists(): void + { + Queue::fake(); + + $folder = $this->getTestSharedFolder('folder-test@kolabnow.com'); + + $this->assertFalse(SharedFolder::emailExists('unknown@domain.tld')); + $this->assertTrue(SharedFolder::emailExists($folder->email)); + + $result = SharedFolder::emailExists($folder->email, true); + $this->assertSame($result->id, $folder->id); + + $folder->delete(); + + $this->assertTrue(SharedFolder::emailExists($folder->email)); + + $result = SharedFolder::emailExists($folder->email, true); + $this->assertSame($result->id, $folder->id); + } + + /** + * Tests for SettingsTrait functionality and SharedFolderSettingObserver + */ + public function testSettings(): void + { + Queue::fake(); + Queue::assertNothingPushed(); + + $folder = $this->getTestSharedFolder('folder-test@kolabnow.com'); + + Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 0); + + // Add a setting + $folder->setSetting('unknown', 'test'); + + Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 0); + + // Add a setting that is synced to LDAP + $folder->setSetting('acl', 'test'); + + Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 1); + + // Note: We test both current folder as well as fresh folder object + // to make sure cache works as expected + $this->assertSame('test', $folder->getSetting('unknown')); + $this->assertSame('test', $folder->fresh()->getSetting('acl')); + + Queue::fake(); + + // Update a setting + $folder->setSetting('unknown', 'test1'); + + Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 0); + + // Update a setting that is synced to LDAP + $folder->setSetting('acl', 'test1'); + + Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 1); + + $this->assertSame('test1', $folder->getSetting('unknown')); + $this->assertSame('test1', $folder->fresh()->getSetting('acl')); + + Queue::fake(); + + // Delete a setting (null) + $folder->setSetting('unknown', null); + + Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 0); + + // Delete a setting that is synced to LDAP + $folder->setSetting('acl', null); + + Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 1); + + $this->assertSame(null, $folder->getSetting('unknown')); + $this->assertSame(null, $folder->fresh()->getSetting('acl')); + } + + /** + * Test updating a shared folder + */ + public function testUpdate(): void + { + Queue::fake(); + + $folder = $this->getTestSharedFolder('folder-test@kolabnow.com'); + + $folder->name = 'New'; + $folder->save(); + + // Assert the imap folder changes on a folder name change + $settings = $folder->settings()->where('key', 'folder')->get(); + $this->assertCount(1, $settings); + $this->assertSame('shared/New@kolabnow.com', $settings[0]->value); + + Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 1); + Queue::assertPushed( + \App\Jobs\SharedFolder\UpdateJob::class, + function ($job) use ($folder) { + $folderEmail = TestCase::getObjectProperty($job, 'folderEmail'); + $folderId = TestCase::getObjectProperty($job, 'folderId'); + + return $folderEmail === $folder->email + && $folderId === $folder->id; + } + ); + } +} diff --git a/src/tests/Feature/UserTest.php b/src/tests/Feature/UserTest.php index d6f39dd6..c7b82b15 100644 --- a/src/tests/Feature/UserTest.php +++ b/src/tests/Feature/UserTest.php @@ -1,1048 +1,1084 @@ deleteTestUser('user-test@' . \config('app.domain')); $this->deleteTestUser('UserAccountA@UserAccount.com'); $this->deleteTestUser('UserAccountB@UserAccount.com'); $this->deleteTestUser('UserAccountC@UserAccount.com'); $this->deleteTestGroup('test-group@UserAccount.com'); $this->deleteTestResource('test-resource@UserAccount.com'); + $this->deleteTestSharedFolder('test-folder@UserAccount.com'); $this->deleteTestDomain('UserAccount.com'); $this->deleteTestDomain('UserAccountAdd.com'); } public function tearDown(): void { \App\TenantSetting::truncate(); $this->deleteTestUser('user-test@' . \config('app.domain')); $this->deleteTestUser('UserAccountA@UserAccount.com'); $this->deleteTestUser('UserAccountB@UserAccount.com'); $this->deleteTestUser('UserAccountC@UserAccount.com'); $this->deleteTestGroup('test-group@UserAccount.com'); $this->deleteTestResource('test-resource@UserAccount.com'); + $this->deleteTestSharedFolder('test-folder@UserAccount.com'); $this->deleteTestDomain('UserAccount.com'); $this->deleteTestDomain('UserAccountAdd.com'); parent::tearDown(); } /** * Tests for User::assignPackage() */ public function testAssignPackage(): void { $this->markTestIncomplete(); } /** * Tests for User::assignPlan() */ public function testAssignPlan(): void { $this->markTestIncomplete(); } /** * Tests for User::assignSku() */ public function testAssignSku(): void { $this->markTestIncomplete(); } /** * Verify a wallet assigned a controller is among the accounts of the assignee. */ public function testAccounts(): void { $userA = $this->getTestUser('UserAccountA@UserAccount.com'); $userB = $this->getTestUser('UserAccountB@UserAccount.com'); $this->assertTrue($userA->wallets()->count() == 1); $userA->wallets()->each( function ($wallet) use ($userB) { $wallet->addController($userB); } ); $this->assertTrue($userB->accounts()->get()[0]->id === $userA->wallets()->get()[0]->id); } public function testCanDelete(): void { $this->markTestIncomplete(); } /** * Test User::canRead() method */ public function testCanRead(): void { $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $domain = $this->getTestDomain('kolab.org'); // Admin $this->assertTrue($admin->canRead($admin)); $this->assertTrue($admin->canRead($john)); $this->assertTrue($admin->canRead($jack)); $this->assertTrue($admin->canRead($reseller1)); $this->assertTrue($admin->canRead($reseller2)); $this->assertTrue($admin->canRead($domain)); $this->assertTrue($admin->canRead($domain->wallet())); // Reseller - kolabnow $this->assertTrue($reseller1->canRead($john)); $this->assertTrue($reseller1->canRead($jack)); $this->assertTrue($reseller1->canRead($reseller1)); $this->assertTrue($reseller1->canRead($domain)); $this->assertTrue($reseller1->canRead($domain->wallet())); $this->assertFalse($reseller1->canRead($reseller2)); $this->assertFalse($reseller1->canRead($admin)); // Reseller - different tenant $this->assertTrue($reseller2->canRead($reseller2)); $this->assertFalse($reseller2->canRead($john)); $this->assertFalse($reseller2->canRead($jack)); $this->assertFalse($reseller2->canRead($reseller1)); $this->assertFalse($reseller2->canRead($domain)); $this->assertFalse($reseller2->canRead($domain->wallet())); $this->assertFalse($reseller2->canRead($admin)); // Normal user - account owner $this->assertTrue($john->canRead($john)); $this->assertTrue($john->canRead($ned)); $this->assertTrue($john->canRead($jack)); $this->assertTrue($john->canRead($domain)); $this->assertTrue($john->canRead($domain->wallet())); $this->assertFalse($john->canRead($reseller1)); $this->assertFalse($john->canRead($reseller2)); $this->assertFalse($john->canRead($admin)); // Normal user - a non-owner and non-controller $this->assertTrue($jack->canRead($jack)); $this->assertFalse($jack->canRead($john)); $this->assertFalse($jack->canRead($domain)); $this->assertFalse($jack->canRead($domain->wallet())); $this->assertFalse($jack->canRead($reseller1)); $this->assertFalse($jack->canRead($reseller2)); $this->assertFalse($jack->canRead($admin)); // Normal user - John's wallet controller $this->assertTrue($ned->canRead($ned)); $this->assertTrue($ned->canRead($john)); $this->assertTrue($ned->canRead($jack)); $this->assertTrue($ned->canRead($domain)); $this->assertTrue($ned->canRead($domain->wallet())); $this->assertFalse($ned->canRead($reseller1)); $this->assertFalse($ned->canRead($reseller2)); $this->assertFalse($ned->canRead($admin)); } /** * Test User::canUpdate() method */ public function testCanUpdate(): void { $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $domain = $this->getTestDomain('kolab.org'); // Admin $this->assertTrue($admin->canUpdate($admin)); $this->assertTrue($admin->canUpdate($john)); $this->assertTrue($admin->canUpdate($jack)); $this->assertTrue($admin->canUpdate($reseller1)); $this->assertTrue($admin->canUpdate($reseller2)); $this->assertTrue($admin->canUpdate($domain)); $this->assertTrue($admin->canUpdate($domain->wallet())); // Reseller - kolabnow $this->assertTrue($reseller1->canUpdate($john)); $this->assertTrue($reseller1->canUpdate($jack)); $this->assertTrue($reseller1->canUpdate($reseller1)); $this->assertTrue($reseller1->canUpdate($domain)); $this->assertTrue($reseller1->canUpdate($domain->wallet())); $this->assertFalse($reseller1->canUpdate($reseller2)); $this->assertFalse($reseller1->canUpdate($admin)); // Reseller - different tenant $this->assertTrue($reseller2->canUpdate($reseller2)); $this->assertFalse($reseller2->canUpdate($john)); $this->assertFalse($reseller2->canUpdate($jack)); $this->assertFalse($reseller2->canUpdate($reseller1)); $this->assertFalse($reseller2->canUpdate($domain)); $this->assertFalse($reseller2->canUpdate($domain->wallet())); $this->assertFalse($reseller2->canUpdate($admin)); // Normal user - account owner $this->assertTrue($john->canUpdate($john)); $this->assertTrue($john->canUpdate($ned)); $this->assertTrue($john->canUpdate($jack)); $this->assertTrue($john->canUpdate($domain)); $this->assertFalse($john->canUpdate($domain->wallet())); $this->assertFalse($john->canUpdate($reseller1)); $this->assertFalse($john->canUpdate($reseller2)); $this->assertFalse($john->canUpdate($admin)); // Normal user - a non-owner and non-controller $this->assertTrue($jack->canUpdate($jack)); $this->assertFalse($jack->canUpdate($john)); $this->assertFalse($jack->canUpdate($domain)); $this->assertFalse($jack->canUpdate($domain->wallet())); $this->assertFalse($jack->canUpdate($reseller1)); $this->assertFalse($jack->canUpdate($reseller2)); $this->assertFalse($jack->canUpdate($admin)); // Normal user - John's wallet controller $this->assertTrue($ned->canUpdate($ned)); $this->assertTrue($ned->canUpdate($john)); $this->assertTrue($ned->canUpdate($jack)); $this->assertTrue($ned->canUpdate($domain)); $this->assertFalse($ned->canUpdate($domain->wallet())); $this->assertFalse($ned->canUpdate($reseller1)); $this->assertFalse($ned->canUpdate($reseller2)); $this->assertFalse($ned->canUpdate($admin)); } /** * Test user create/creating observer */ public function testCreate(): void { Queue::fake(); $domain = \config('app.domain'); $user = User::create(['email' => 'USER-test@' . \strtoupper($domain)]); $result = User::where('email', 'user-test@' . $domain)->first(); $this->assertSame('user-test@' . $domain, $result->email); $this->assertSame($user->id, $result->id); $this->assertSame(User::STATUS_NEW | User::STATUS_ACTIVE, $result->status); } /** * Verify user creation process */ public function testCreateJobs(): void { Queue::fake(); $user = User::create([ 'email' => 'user-test@' . \config('app.domain') ]); Queue::assertPushed(\App\Jobs\User\CreateJob::class, 1); Queue::assertPushed(\App\Jobs\PGP\KeyCreateJob::class, 0); Queue::assertPushed( \App\Jobs\User\CreateJob::class, function ($job) use ($user) { $userEmail = TestCase::getObjectProperty($job, 'userEmail'); $userId = TestCase::getObjectProperty($job, 'userId'); return $userEmail === $user->email && $userId === $user->id; } ); Queue::assertPushedWithChain( \App\Jobs\User\CreateJob::class, [ \App\Jobs\User\VerifyJob::class, ] ); /* FIXME: Looks like we can't really do detailed assertions on chained jobs Another thing to consider is if we maybe should run these jobs independently (not chained) and make sure there's no race-condition in status update Queue::assertPushed(\App\Jobs\User\VerifyJob::class, 1); Queue::assertPushed(\App\Jobs\User\VerifyJob::class, function ($job) use ($user) { $userEmail = TestCase::getObjectProperty($job, 'userEmail'); $userId = TestCase::getObjectProperty($job, 'userId'); return $userEmail === $user->email && $userId === $user->id; }); */ } /** * Verify user creation process invokes the PGP keys creation job (if configured) */ public function testCreatePGPJob(): void { Queue::fake(); \App\Tenant::find(\config('app.tenant_id'))->setSetting('pgp.enable', 1); $user = User::create([ 'email' => 'user-test@' . \config('app.domain') ]); Queue::assertPushed(\App\Jobs\PGP\KeyCreateJob::class, 1); Queue::assertPushed( \App\Jobs\PGP\KeyCreateJob::class, function ($job) use ($user) { $userEmail = TestCase::getObjectProperty($job, 'userEmail'); $userId = TestCase::getObjectProperty($job, 'userId'); return $userEmail === $user->email && $userId === $user->id; } ); } /** * Tests for User::domains() */ public function testDomains(): void { $user = $this->getTestUser('john@kolab.org'); $domain = $this->getTestDomain('useraccount.com', [ 'status' => Domain::STATUS_NEW | Domain::STATUS_ACTIVE, 'type' => Domain::TYPE_PUBLIC, ]); $domains = collect($user->domains())->pluck('namespace')->all(); $this->assertContains($domain->namespace, $domains); $this->assertContains('kolab.org', $domains); // Jack is not the wallet controller, so for him the list should not // include John's domains, kolab.org specifically $user = $this->getTestUser('jack@kolab.org'); $domains = collect($user->domains())->pluck('namespace')->all(); $this->assertContains($domain->namespace, $domains); $this->assertNotContains('kolab.org', $domains); // Public domains of other tenants should not be returned $tenant = \App\Tenant::where('id', '!=', \config('app.tenant_id'))->first(); $domain->tenant_id = $tenant->id; $domain->save(); $domains = collect($user->domains())->pluck('namespace')->all(); $this->assertNotContains($domain->namespace, $domains); } /** * Test User::getConfig() and setConfig() methods */ public function testConfigTrait(): void { $john = $this->getTestUser('john@kolab.org'); $john->setSetting('greylist_enabled', null); $this->assertSame(['greylist_enabled' => true], $john->getConfig()); $result = $john->setConfig(['greylist_enabled' => false, 'unknown' => false]); $this->assertSame(['greylist_enabled' => false], $john->getConfig()); $this->assertSame('false', $john->getSetting('greylist_enabled')); $result = $john->setConfig(['greylist_enabled' => true]); $this->assertSame(['greylist_enabled' => true], $john->getConfig()); $this->assertSame('true', $john->getSetting('greylist_enabled')); } /** * Test User::hasSku() method */ public function testHasSku(): void { $john = $this->getTestUser('john@kolab.org'); $this->assertTrue($john->hasSku('mailbox')); $this->assertTrue($john->hasSku('storage')); $this->assertFalse($john->hasSku('beta')); $this->assertFalse($john->hasSku('unknown')); } public function testUserQuota(): void { // TODO: This test does not test much, probably could be removed // or moved to somewhere else, or extended with // other entitlements() related cases. $user = $this->getTestUser('john@kolab.org'); $storage_sku = \App\Sku::withEnvTenantContext()->where('title', 'storage')->first(); $count = 0; foreach ($user->entitlements()->get() as $entitlement) { if ($entitlement->sku_id == $storage_sku->id) { $count += 1; } } $this->assertTrue($count == 5); } /** * Test user deletion */ public function testDelete(): void { Queue::fake(); $user = $this->getTestUser('user-test@' . \config('app.domain')); $package = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $user->assignPackage($package); $id = $user->id; $this->assertCount(7, $user->entitlements()->get()); $user->delete(); $this->assertCount(0, $user->entitlements()->get()); $this->assertTrue($user->fresh()->trashed()); $this->assertFalse($user->fresh()->isDeleted()); // Delete the user for real $job = new \App\Jobs\User\DeleteJob($id); $job->handle(); $this->assertTrue(User::withTrashed()->where('id', $id)->first()->isDeleted()); $user->forceDelete(); $this->assertCount(0, User::withTrashed()->where('id', $id)->get()); // Test an account with users, domain, and group, and resource $userA = $this->getTestUser('UserAccountA@UserAccount.com'); $userB = $this->getTestUser('UserAccountB@UserAccount.com'); $userC = $this->getTestUser('UserAccountC@UserAccount.com'); $package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $domain = $this->getTestDomain('UserAccount.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_HOSTED, ]); $userA->assignPackage($package_kolab); $domain->assignPackage($package_domain, $userA); $userA->assignPackage($package_kolab, $userB); $userA->assignPackage($package_kolab, $userC); $group = $this->getTestGroup('test-group@UserAccount.com'); $group->assignToWallet($userA->wallets->first()); $resource = $this->getTestResource('test-resource@UserAccount.com', ['name' => 'test']); $resource->assignToWallet($userA->wallets->first()); + $folder = $this->getTestSharedFolder('test-folder@UserAccount.com', ['name' => 'test']); + $folder->assignToWallet($userA->wallets->first()); $entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id); $entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id); $entitlementsC = \App\Entitlement::where('entitleable_id', $userC->id); $entitlementsDomain = \App\Entitlement::where('entitleable_id', $domain->id); $entitlementsGroup = \App\Entitlement::where('entitleable_id', $group->id); $entitlementsResource = \App\Entitlement::where('entitleable_id', $resource->id); + $entitlementsFolder = \App\Entitlement::where('entitleable_id', $folder->id); $this->assertSame(7, $entitlementsA->count()); $this->assertSame(7, $entitlementsB->count()); $this->assertSame(7, $entitlementsC->count()); $this->assertSame(1, $entitlementsDomain->count()); $this->assertSame(1, $entitlementsGroup->count()); $this->assertSame(1, $entitlementsResource->count()); + $this->assertSame(1, $entitlementsFolder->count()); // Delete non-controller user $userC->delete(); $this->assertTrue($userC->fresh()->trashed()); $this->assertFalse($userC->fresh()->isDeleted()); $this->assertSame(0, $entitlementsC->count()); // Delete the controller (and expect "sub"-users to be deleted too) $userA->delete(); $this->assertSame(0, $entitlementsA->count()); $this->assertSame(0, $entitlementsB->count()); $this->assertSame(0, $entitlementsDomain->count()); $this->assertSame(0, $entitlementsGroup->count()); $this->assertSame(0, $entitlementsResource->count()); + $this->assertSame(0, $entitlementsFolder->count()); $this->assertTrue($userA->fresh()->trashed()); $this->assertTrue($userB->fresh()->trashed()); $this->assertTrue($domain->fresh()->trashed()); $this->assertTrue($group->fresh()->trashed()); $this->assertTrue($resource->fresh()->trashed()); + $this->assertTrue($folder->fresh()->trashed()); $this->assertFalse($userA->isDeleted()); $this->assertFalse($userB->isDeleted()); $this->assertFalse($domain->isDeleted()); $this->assertFalse($group->isDeleted()); $this->assertFalse($resource->isDeleted()); + $this->assertFalse($folder->isDeleted()); $userA->forceDelete(); $all_entitlements = \App\Entitlement::where('wallet_id', $userA->wallets->first()->id); $this->assertSame(0, $all_entitlements->withTrashed()->count()); $this->assertCount(0, User::withTrashed()->where('id', $userA->id)->get()); $this->assertCount(0, User::withTrashed()->where('id', $userB->id)->get()); $this->assertCount(0, User::withTrashed()->where('id', $userC->id)->get()); $this->assertCount(0, Domain::withTrashed()->where('id', $domain->id)->get()); $this->assertCount(0, Group::withTrashed()->where('id', $group->id)->get()); $this->assertCount(0, \App\Resource::withTrashed()->where('id', $resource->id)->get()); + $this->assertCount(0, \App\SharedFolder::withTrashed()->where('id', $folder->id)->get()); } /** * Test user deletion vs. group membership */ public function testDeleteAndGroups(): void { Queue::fake(); $package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $userA = $this->getTestUser('UserAccountA@UserAccount.com'); $userB = $this->getTestUser('UserAccountB@UserAccount.com'); $userA->assignPackage($package_kolab, $userB); $group = $this->getTestGroup('test-group@UserAccount.com'); $group->members = ['test@gmail.com', $userB->email]; $group->assignToWallet($userA->wallets->first()); $group->save(); Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 1); $userGroups = $userA->groups()->get(); $this->assertSame(1, $userGroups->count()); $this->assertSame($group->id, $userGroups->first()->id); $userB->delete(); $this->assertSame(['test@gmail.com'], $group->fresh()->members); // Twice, one for save() and one for delete() above Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 2); } /** * Test handling negative balance on user deletion */ public function testDeleteWithNegativeBalance(): void { $user = $this->getTestUser('user-test@' . \config('app.domain')); $wallet = $user->wallets()->first(); $wallet->balance = -1000; $wallet->save(); $reseller_wallet = $user->tenant->wallet(); $reseller_wallet->balance = 0; $reseller_wallet->save(); \App\Transaction::where('object_id', $reseller_wallet->id)->where('object_type', \App\Wallet::class)->delete(); $user->delete(); $reseller_transactions = \App\Transaction::where('object_id', $reseller_wallet->id) ->where('object_type', \App\Wallet::class)->get(); $this->assertSame(-1000, $reseller_wallet->fresh()->balance); $this->assertCount(1, $reseller_transactions); $trans = $reseller_transactions[0]; $this->assertSame("Deleted user {$user->email}", $trans->description); $this->assertSame(-1000, $trans->amount); $this->assertSame(\App\Transaction::WALLET_DEBIT, $trans->type); } /** * Test handling positive balance on user deletion */ public function testDeleteWithPositiveBalance(): void { $user = $this->getTestUser('user-test@' . \config('app.domain')); $wallet = $user->wallets()->first(); $wallet->balance = 1000; $wallet->save(); $reseller_wallet = $user->tenant->wallet(); $reseller_wallet->balance = 0; $reseller_wallet->save(); $user->delete(); $this->assertSame(0, $reseller_wallet->fresh()->balance); } /** * Test user deletion with PGP/WOAT enabled */ public function testDeleteWithPGP(): void { Queue::fake(); // Test with PGP disabled $user = $this->getTestUser('user-test@' . \config('app.domain')); $user->tenant->setSetting('pgp.enable', 0); $user->delete(); Queue::assertPushed(\App\Jobs\PGP\KeyDeleteJob::class, 0); // Test with PGP enabled $this->deleteTestUser('user-test@' . \config('app.domain')); $user = $this->getTestUser('user-test@' . \config('app.domain')); $user->tenant->setSetting('pgp.enable', 1); $user->delete(); $user->tenant->setSetting('pgp.enable', 0); Queue::assertPushed(\App\Jobs\PGP\KeyDeleteJob::class, 1); Queue::assertPushed( \App\Jobs\PGP\KeyDeleteJob::class, function ($job) use ($user) { $userId = TestCase::getObjectProperty($job, 'userId'); $userEmail = TestCase::getObjectProperty($job, 'userEmail'); return $userId == $user->id && $userEmail === $user->email; } ); } /** * Tests for User::aliasExists() */ public function testAliasExists(): void { $this->assertTrue(User::aliasExists('jack.daniels@kolab.org')); $this->assertFalse(User::aliasExists('j.daniels@kolab.org')); $this->assertFalse(User::aliasExists('john@kolab.org')); } /** * Tests for User::emailExists() */ public function testEmailExists(): void { $this->assertFalse(User::emailExists('jack.daniels@kolab.org')); $this->assertFalse(User::emailExists('j.daniels@kolab.org')); $this->assertTrue(User::emailExists('john@kolab.org')); $user = User::emailExists('john@kolab.org', true); $this->assertSame('john@kolab.org', $user->email); } /** * Tests for User::findByEmail() */ public function testFindByEmail(): void { $user = $this->getTestUser('john@kolab.org'); $result = User::findByEmail('john'); $this->assertNull($result); $result = User::findByEmail('non-existing@email.com'); $this->assertNull($result); $result = User::findByEmail('john@kolab.org'); $this->assertInstanceOf(User::class, $result); $this->assertSame($user->id, $result->id); // Use an alias $result = User::findByEmail('john.doe@kolab.org'); $this->assertInstanceOf(User::class, $result); $this->assertSame($user->id, $result->id); Queue::fake(); // A case where two users have the same alias $ned = $this->getTestUser('ned@kolab.org'); $ned->setAliases(['joe.monster@kolab.org']); $result = User::findByEmail('joe.monster@kolab.org'); $this->assertNull($result); $ned->setAliases([]); // TODO: searching by external email (setting) $this->markTestIncomplete(); } /** * Test User::name() */ public function testName(): void { Queue::fake(); $user = $this->getTestUser('user-test@' . \config('app.domain')); $this->assertSame('', $user->name()); $this->assertSame($user->tenant->title . ' User', $user->name(true)); $user->setSetting('first_name', 'First'); $this->assertSame('First', $user->name()); $this->assertSame('First', $user->name(true)); $user->setSetting('last_name', 'Last'); $this->assertSame('First Last', $user->name()); $this->assertSame('First Last', $user->name(true)); } /** * Test resources() method */ public function testResources(): void { $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $resources = $john->resources()->orderBy('email')->get(); $this->assertSame(2, $resources->count()); $this->assertSame('resource-test1@kolab.org', $resources[0]->email); $this->assertSame('resource-test2@kolab.org', $resources[1]->email); $resources = $ned->resources()->orderBy('email')->get(); $this->assertSame(2, $resources->count()); $this->assertSame('resource-test1@kolab.org', $resources[0]->email); $this->assertSame('resource-test2@kolab.org', $resources[1]->email); $resources = $jack->resources()->get(); $this->assertSame(0, $resources->count()); } + /** + * Test sharedFolders() method + */ + public function testSharedFolders(): void + { + $john = $this->getTestUser('john@kolab.org'); + $ned = $this->getTestUser('ned@kolab.org'); + $jack = $this->getTestUser('jack@kolab.org'); + + $folders = $john->sharedFolders()->orderBy('email')->get(); + + $this->assertSame(2, $folders->count()); + $this->assertSame('folder-contact@kolab.org', $folders[0]->email); + $this->assertSame('folder-event@kolab.org', $folders[1]->email); + + $folders = $ned->sharedFolders()->orderBy('email')->get(); + + $this->assertSame(2, $folders->count()); + $this->assertSame('folder-contact@kolab.org', $folders[0]->email); + $this->assertSame('folder-event@kolab.org', $folders[1]->email); + + $folders = $jack->sharedFolders()->get(); + + $this->assertSame(0, $folders->count()); + } + /** * Test user restoring */ public function testRestore(): void { Queue::fake(); // Test an account with users and domain $userA = $this->getTestUser('UserAccountA@UserAccount.com', [ 'status' => User::STATUS_LDAP_READY | User::STATUS_IMAP_READY | User::STATUS_SUSPENDED, ]); $userB = $this->getTestUser('UserAccountB@UserAccount.com'); $package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $domainA = $this->getTestDomain('UserAccount.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_HOSTED, ]); $domainB = $this->getTestDomain('UserAccountAdd.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_HOSTED, ]); $userA->assignPackage($package_kolab); $domainA->assignPackage($package_domain, $userA); $domainB->assignPackage($package_domain, $userA); $userA->assignPackage($package_kolab, $userB); $storage_sku = \App\Sku::withEnvTenantContext()->where('title', 'storage')->first(); $now = \Carbon\Carbon::now(); $wallet_id = $userA->wallets->first()->id; // add an extra storage entitlement $ent1 = \App\Entitlement::create([ 'wallet_id' => $wallet_id, 'sku_id' => $storage_sku->id, 'cost' => 0, 'entitleable_id' => $userA->id, 'entitleable_type' => User::class, ]); $entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id); $entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id); $entitlementsDomain = \App\Entitlement::where('entitleable_id', $domainA->id); // First delete the user $userA->delete(); $this->assertSame(0, $entitlementsA->count()); $this->assertSame(0, $entitlementsB->count()); $this->assertSame(0, $entitlementsDomain->count()); $this->assertTrue($userA->fresh()->trashed()); $this->assertTrue($userB->fresh()->trashed()); $this->assertTrue($domainA->fresh()->trashed()); $this->assertTrue($domainB->fresh()->trashed()); $this->assertFalse($userA->isDeleted()); $this->assertFalse($userB->isDeleted()); $this->assertFalse($domainA->isDeleted()); // Backdate one storage entitlement (it's not expected to be restored) \App\Entitlement::withTrashed()->where('id', $ent1->id) ->update(['deleted_at' => $now->copy()->subMinutes(2)]); // Backdate entitlements to assert that they were restored with proper updated_at timestamp \App\Entitlement::withTrashed()->where('wallet_id', $wallet_id) ->update(['updated_at' => $now->subMinutes(10)]); Queue::fake(); // Then restore it $userA->restore(); $userA->refresh(); $this->assertFalse($userA->trashed()); $this->assertFalse($userA->isDeleted()); $this->assertFalse($userA->isSuspended()); $this->assertFalse($userA->isLdapReady()); $this->assertFalse($userA->isImapReady()); $this->assertTrue($userA->isActive()); $this->assertTrue($userB->fresh()->trashed()); $this->assertTrue($domainB->fresh()->trashed()); $this->assertFalse($domainA->fresh()->trashed()); // Assert entitlements $this->assertSame(7, $entitlementsA->count()); // mailbox + groupware + 5 x storage $this->assertTrue($ent1->fresh()->trashed()); $entitlementsA->get()->each(function ($ent) { $this->assertTrue($ent->updated_at->greaterThan(\Carbon\Carbon::now()->subSeconds(5))); }); // We expect only CreateJob + UpdateJob pair for both user and domain. // Because how Illuminate/Database/Eloquent/SoftDeletes::restore() method // is implemented we cannot skip the UpdateJob in any way. // I don't want to overwrite this method, the extra job shouldn't do any harm. $this->assertCount(4, Queue::pushedJobs()); // @phpstan-ignore-line Queue::assertPushed(\App\Jobs\Domain\UpdateJob::class, 1); Queue::assertPushed(\App\Jobs\Domain\CreateJob::class, 1); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1); Queue::assertPushed(\App\Jobs\User\CreateJob::class, 1); Queue::assertPushed( \App\Jobs\User\CreateJob::class, function ($job) use ($userA) { return $userA->id === TestCase::getObjectProperty($job, 'userId'); } ); Queue::assertPushedWithChain( \App\Jobs\User\CreateJob::class, [ \App\Jobs\User\VerifyJob::class, ] ); } /** * Tests for UserAliasesTrait::setAliases() */ public function testSetAliases(): void { Queue::fake(); Queue::assertNothingPushed(); $user = $this->getTestUser('UserAccountA@UserAccount.com'); $domain = $this->getTestDomain('UserAccount.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_HOSTED, ]); $this->assertCount(0, $user->aliases->all()); $user->tenant->setSetting('pgp.enable', 1); // Add an alias $user->setAliases(['UserAlias1@UserAccount.com']); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1); Queue::assertPushed(\App\Jobs\PGP\KeyCreateJob::class, 1); $user->tenant->setSetting('pgp.enable', 0); $aliases = $user->aliases()->get(); $this->assertCount(1, $aliases); $this->assertSame('useralias1@useraccount.com', $aliases[0]['alias']); // Add another alias $user->setAliases(['UserAlias1@UserAccount.com', 'UserAlias2@UserAccount.com']); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 2); Queue::assertPushed(\App\Jobs\PGP\KeyCreateJob::class, 1); $aliases = $user->aliases()->orderBy('alias')->get(); $this->assertCount(2, $aliases); $this->assertSame('useralias1@useraccount.com', $aliases[0]->alias); $this->assertSame('useralias2@useraccount.com', $aliases[1]->alias); $user->tenant->setSetting('pgp.enable', 1); // Remove an alias $user->setAliases(['UserAlias1@UserAccount.com']); $user->tenant->setSetting('pgp.enable', 0); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 3); Queue::assertPushed(\App\Jobs\PGP\KeyDeleteJob::class, 1); Queue::assertPushed( \App\Jobs\PGP\KeyDeleteJob::class, function ($job) use ($user) { $userId = TestCase::getObjectProperty($job, 'userId'); $userEmail = TestCase::getObjectProperty($job, 'userEmail'); return $userId == $user->id && $userEmail === 'useralias2@useraccount.com'; } ); $aliases = $user->aliases()->get(); $this->assertCount(1, $aliases); $this->assertSame('useralias1@useraccount.com', $aliases[0]['alias']); // Remove all aliases $user->setAliases([]); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 4); $this->assertCount(0, $user->aliases()->get()); } /** * Tests for UserSettingsTrait::setSettings() and getSetting() and getSettings() */ public function testUserSettings(): void { Queue::fake(); Queue::assertNothingPushed(); $user = $this->getTestUser('UserAccountA@UserAccount.com'); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 0); // Test default settings // Note: Technicly this tests UserObserver::created() behavior $all_settings = $user->settings()->orderBy('key')->get(); $this->assertCount(2, $all_settings); $this->assertSame('country', $all_settings[0]->key); $this->assertSame('CH', $all_settings[0]->value); $this->assertSame('currency', $all_settings[1]->key); $this->assertSame('CHF', $all_settings[1]->value); // Add a setting $user->setSetting('first_name', 'Firstname'); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1); // Note: We test both current user as well as fresh user object // to make sure cache works as expected $this->assertSame('Firstname', $user->getSetting('first_name')); $this->assertSame('Firstname', $user->fresh()->getSetting('first_name')); // Update a setting $user->setSetting('first_name', 'Firstname1'); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 2); // Note: We test both current user as well as fresh user object // to make sure cache works as expected $this->assertSame('Firstname1', $user->getSetting('first_name')); $this->assertSame('Firstname1', $user->fresh()->getSetting('first_name')); // Delete a setting (null) $user->setSetting('first_name', null); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 3); // Note: We test both current user as well as fresh user object // to make sure cache works as expected $this->assertSame(null, $user->getSetting('first_name')); $this->assertSame(null, $user->fresh()->getSetting('first_name')); // Delete a setting (empty string) $user->setSetting('first_name', 'Firstname1'); $user->setSetting('first_name', ''); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 5); // Note: We test both current user as well as fresh user object // to make sure cache works as expected $this->assertSame(null, $user->getSetting('first_name')); $this->assertSame(null, $user->fresh()->getSetting('first_name')); // Set multiple settings at once $user->setSettings([ 'first_name' => 'Firstname2', 'last_name' => 'Lastname2', 'country' => null, ]); // TODO: This really should create a single UserUpdate job, not 3 Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 7); // Note: We test both current user as well as fresh user object // to make sure cache works as expected $this->assertSame('Firstname2', $user->getSetting('first_name')); $this->assertSame('Firstname2', $user->fresh()->getSetting('first_name')); $this->assertSame('Lastname2', $user->getSetting('last_name')); $this->assertSame('Lastname2', $user->fresh()->getSetting('last_name')); $this->assertSame(null, $user->getSetting('country')); $this->assertSame(null, $user->fresh()->getSetting('country')); $all_settings = $user->settings()->orderBy('key')->get(); $this->assertCount(3, $all_settings); // Test getSettings() method $this->assertSame( [ 'first_name' => 'Firstname2', 'last_name' => 'Lastname2', 'unknown' => null, ], $user->getSettings(['first_name', 'last_name', 'unknown']) ); } /** * Tests for User::users() */ public function testUsers(): void { $jack = $this->getTestUser('jack@kolab.org'); $joe = $this->getTestUser('joe@kolab.org'); $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $wallet = $john->wallets()->first(); $users = $john->users()->orderBy('email')->get(); $this->assertCount(4, $users); $this->assertEquals($jack->id, $users[0]->id); $this->assertEquals($joe->id, $users[1]->id); $this->assertEquals($john->id, $users[2]->id); $this->assertEquals($ned->id, $users[3]->id); $this->assertSame($wallet->id, $users[0]->wallet_id); $this->assertSame($wallet->id, $users[1]->wallet_id); $this->assertSame($wallet->id, $users[2]->wallet_id); $this->assertSame($wallet->id, $users[3]->wallet_id); $users = $jack->users()->orderBy('email')->get(); $this->assertCount(0, $users); $users = $ned->users()->orderBy('email')->get(); $this->assertCount(4, $users); } public function testWallets(): void { $this->markTestIncomplete(); } } diff --git a/src/tests/TestCaseTrait.php b/src/tests/TestCaseTrait.php index 6580ea2a..2f46066a 100644 --- a/src/tests/TestCaseTrait.php +++ b/src/tests/TestCaseTrait.php @@ -1,547 +1,599 @@ 'John', 'last_name' => 'Doe', 'organization' => 'Test Domain Owner', ]; /** * Some users for the hosted domain, ultimately including the owner. * * @var \App\User[] */ protected $domainUsers = []; /** * A specific user that is a regular user in the hosted domain. * * @var ?\App\User */ protected $jack; /** * A specific user that is a controller on the wallet to which the hosted domain is charged. * * @var ?\App\User */ protected $jane; /** * A specific user that has a second factor configured. * * @var ?\App\User */ protected $joe; /** * One of the domains that is available for public registration. * * @var ?\App\Domain */ protected $publicDomain; /** * A newly generated user in a public domain. * * @var ?\App\User */ protected $publicDomainUser; /** * A placeholder for a password that can be generated. * * Should be generated with `\App\Utils::generatePassphrase()`. * * @var ?string */ protected $userPassword; /** * Register the beta entitlement for a user */ protected function addBetaEntitlement($user, $title): void { // Add beta + $title entitlements $beta_sku = Sku::withEnvTenantContext()->where('title', 'beta')->first(); $sku = Sku::withEnvTenantContext()->where('title', $title)->first(); $user->assignSku($beta_sku); $user->assignSku($sku); } /** * Assert that the entitlements for the user match the expected list of entitlements. * * @param \App\User|\App\Domain $object The object for which the entitlements need to be pulled. * @param array $expected An array of expected \App\Sku titles. */ protected function assertEntitlements($object, $expected) { // Assert the user entitlements $skus = $object->entitlements()->get() ->map(function ($ent) { return $ent->sku->title; }) ->toArray(); sort($skus); Assert::assertSame($expected, $skus); } protected function backdateEntitlements($entitlements, $targetDate) { $wallets = []; $ids = []; foreach ($entitlements as $entitlement) { $ids[] = $entitlement->id; $wallets[] = $entitlement->wallet_id; } \App\Entitlement::whereIn('id', $ids)->update([ 'created_at' => $targetDate, 'updated_at' => $targetDate, ]); if (!empty($wallets)) { $wallets = array_unique($wallets); $owners = \App\Wallet::whereIn('id', $wallets)->pluck('user_id')->all(); \App\User::whereIn('id', $owners)->update(['created_at' => $targetDate]); } } /** * Removes all beta entitlements from the database */ protected function clearBetaEntitlements(): void { $beta_handlers = [ 'App\Handlers\Beta', 'App\Handlers\Beta\Resources', + 'App\Handlers\Beta\SharedFolders', 'App\Handlers\Distlist', ]; $betas = Sku::whereIn('handler_class', $beta_handlers)->pluck('id')->all(); \App\Entitlement::whereIn('sku_id', $betas)->delete(); } /** * Creates the application. * * @return \Illuminate\Foundation\Application */ public function createApplication() { $app = require __DIR__ . '/../bootstrap/app.php'; $app->make(Kernel::class)->bootstrap(); return $app; } /** * Create a set of transaction log entries for a wallet */ protected function createTestTransactions($wallet) { $result = []; $date = Carbon::now(); $debit = 0; $entitlementTransactions = []; foreach ($wallet->entitlements as $entitlement) { if ($entitlement->cost) { $debit += $entitlement->cost; $entitlementTransactions[] = $entitlement->createTransaction( Transaction::ENTITLEMENT_BILLED, $entitlement->cost ); } } $transaction = Transaction::create( [ 'user_email' => 'jeroen@jeroen.jeroen', 'object_id' => $wallet->id, 'object_type' => \App\Wallet::class, 'type' => Transaction::WALLET_DEBIT, 'amount' => $debit * -1, 'description' => 'Payment', ] ); $result[] = $transaction; Transaction::whereIn('id', $entitlementTransactions)->update(['transaction_id' => $transaction->id]); $transaction = Transaction::create( [ 'user_email' => null, 'object_id' => $wallet->id, 'object_type' => \App\Wallet::class, 'type' => Transaction::WALLET_CREDIT, 'amount' => 2000, 'description' => 'Payment', ] ); $transaction->created_at = $date->next(Carbon::MONDAY); $transaction->save(); $result[] = $transaction; $types = [ Transaction::WALLET_AWARD, Transaction::WALLET_PENALTY, ]; // The page size is 10, so we generate so many to have at least two pages $loops = 10; while ($loops-- > 0) { $type = $types[count($result) % count($types)]; $transaction = Transaction::create([ 'user_email' => 'jeroen.@jeroen.jeroen', 'object_id' => $wallet->id, 'object_type' => \App\Wallet::class, 'type' => $type, 'amount' => 11 * (count($result) + 1) * ($type == Transaction::WALLET_PENALTY ? -1 : 1), 'description' => 'TRANS' . $loops, ]); $transaction->created_at = $date->next(Carbon::MONDAY); $transaction->save(); $result[] = $transaction; } return $result; } /** * Delete a test domain whatever it takes. * * @coversNothing */ protected function deleteTestDomain($name) { Queue::fake(); $domain = Domain::withTrashed()->where('namespace', $name)->first(); if (!$domain) { return; } $job = new \App\Jobs\Domain\DeleteJob($domain->id); $job->handle(); $domain->forceDelete(); } /** * Delete a test group whatever it takes. * * @coversNothing */ protected function deleteTestGroup($email) { Queue::fake(); $group = Group::withTrashed()->where('email', $email)->first(); if (!$group) { return; } - $job = new \App\Jobs\Group\DeleteJob($group->id); - $job->handle(); + LDAP::deleteGroup($group); $group->forceDelete(); } /** * Delete a test resource whatever it takes. * * @coversNothing */ protected function deleteTestResource($email) { Queue::fake(); $resource = Resource::withTrashed()->where('email', $email)->first(); if (!$resource) { return; } - $job = new \App\Jobs\Resource\DeleteJob($resource->id); - $job->handle(); + LDAP::deleteResource($resource); $resource->forceDelete(); } + /** + * Delete a test shared folder whatever it takes. + * + * @coversNothing + */ + protected function deleteTestSharedFolder($email) + { + Queue::fake(); + + $folder = SharedFolder::withTrashed()->where('email', $email)->first(); + + if (!$folder) { + return; + } + + LDAP::deleteSharedFolder($folder); + + $folder->forceDelete(); + } + /** * Delete a test user whatever it takes. * * @coversNothing */ protected function deleteTestUser($email) { Queue::fake(); $user = User::withTrashed()->where('email', $email)->first(); if (!$user) { return; } - $job = new \App\Jobs\User\DeleteJob($user->id); - $job->handle(); + LDAP::deleteUser($user); $user->forceDelete(); } /** * Helper to access protected property of an object */ protected static function getObjectProperty($object, $property_name) { $reflection = new \ReflectionClass($object); $property = $reflection->getProperty($property_name); $property->setAccessible(true); return $property->getValue($object); } /** * Get Domain object by namespace, create it if needed. * Skip LDAP jobs. * * @coversNothing */ protected function getTestDomain($name, $attrib = []) { // Disable jobs (i.e. skip LDAP oprations) Queue::fake(); return Domain::firstOrCreate(['namespace' => $name], $attrib); } /** * Get Group object by email, create it if needed. * Skip LDAP jobs. */ protected function getTestGroup($email, $attrib = []) { // Disable jobs (i.e. skip LDAP oprations) Queue::fake(); return Group::firstOrCreate(['email' => $email], $attrib); } /** - * Get Resource object by name+domain, create it if needed. + * Get Resource object by email, create it if needed. * Skip LDAP jobs. */ protected function getTestResource($email, $attrib = []) { // Disable jobs (i.e. skip LDAP oprations) Queue::fake(); $resource = Resource::where('email', $email)->first(); if (!$resource) { list($local, $domain) = explode('@', $email, 2); $resource = new Resource(); $resource->email = $email; $resource->domain = $domain; if (!isset($attrib['name'])) { $resource->name = $local; } } foreach ($attrib as $key => $val) { $resource->{$key} = $val; } $resource->save(); return $resource; } + /** + * Get SharedFolder object by email, create it if needed. + * Skip LDAP jobs. + */ + protected function getTestSharedFolder($email, $attrib = []) + { + // Disable jobs (i.e. skip LDAP oprations) + Queue::fake(); + + $folder = SharedFolder::where('email', $email)->first(); + + if (!$folder) { + list($local, $domain) = explode('@', $email, 2); + + $folder = new SharedFolder(); + $folder->email = $email; + $folder->domain = $domain; + + if (!isset($attrib['name'])) { + $folder->name = $local; + } + } + + foreach ($attrib as $key => $val) { + $folder->{$key} = $val; + } + + $folder->save(); + + return $folder; + } + /** * Get User object by email, create it if needed. * Skip LDAP jobs. * * @coversNothing */ protected function getTestUser($email, $attrib = []) { // Disable jobs (i.e. skip LDAP oprations) Queue::fake(); $user = User::firstOrCreate(['email' => $email], $attrib); if ($user->trashed()) { // Note: we do not want to use user restore here User::where('id', $user->id)->forceDelete(); $user = User::create(['email' => $email] + $attrib); } return $user; } /** * Call protected/private method of a class. * * @param object $object Instantiated object that we will run method on. * @param string $methodName Method name to call * @param array $parameters Array of parameters to pass into method. * * @return mixed Method return. */ protected function invokeMethod($object, $methodName, array $parameters = array()) { $reflection = new \ReflectionClass(get_class($object)); $method = $reflection->getMethod($methodName); $method->setAccessible(true); return $method->invokeArgs($object, $parameters); } protected function setUpTest() { $this->userPassword = \App\Utils::generatePassphrase(); $this->domainHosted = $this->getTestDomain( 'test.domain', [ 'type' => \App\Domain::TYPE_EXTERNAL, 'status' => \App\Domain::STATUS_ACTIVE | \App\Domain::STATUS_CONFIRMED | \App\Domain::STATUS_VERIFIED ] ); $this->getTestDomain( 'test2.domain2', [ 'type' => \App\Domain::TYPE_EXTERNAL, 'status' => \App\Domain::STATUS_ACTIVE | \App\Domain::STATUS_CONFIRMED | \App\Domain::STATUS_VERIFIED ] ); $packageKolab = \App\Package::where('title', 'kolab')->first(); $this->domainOwner = $this->getTestUser('john@test.domain', ['password' => $this->userPassword]); $this->domainOwner->assignPackage($packageKolab); $this->domainOwner->setSettings($this->domainOwnerSettings); $this->domainOwner->setAliases(['alias1@test2.domain2']); // separate for regular user $this->jack = $this->getTestUser('jack@test.domain', ['password' => $this->userPassword]); // separate for wallet controller $this->jane = $this->getTestUser('jane@test.domain', ['password' => $this->userPassword]); $this->joe = $this->getTestUser('joe@test.domain', ['password' => $this->userPassword]); $this->domainUsers[] = $this->jack; $this->domainUsers[] = $this->jane; $this->domainUsers[] = $this->joe; $this->domainUsers[] = $this->getTestUser('jill@test.domain', ['password' => $this->userPassword]); foreach ($this->domainUsers as $user) { $this->domainOwner->assignPackage($packageKolab, $user); } $this->domainUsers[] = $this->domainOwner; // assign second factor to joe $this->joe->assignSku(Sku::where('title', '2fa')->first()); \App\Auth\SecondFactor::seed($this->joe->email); usort( $this->domainUsers, function ($a, $b) { return $a->email > $b->email; } ); $this->domainHosted->assignPackage( \App\Package::where('title', 'domain-hosting')->first(), $this->domainOwner ); $wallet = $this->domainOwner->wallets()->first(); $wallet->addController($this->jane); $this->publicDomain = \App\Domain::where('type', \App\Domain::TYPE_PUBLIC)->first(); $this->publicDomainUser = $this->getTestUser( 'john@' . $this->publicDomain->namespace, ['password' => $this->userPassword] ); $this->publicDomainUser->assignPackage($packageKolab); } public function tearDown(): void { foreach ($this->domainUsers as $user) { if ($user == $this->domainOwner) { continue; } $this->deleteTestUser($user->email); } if ($this->domainOwner) { $this->deleteTestUser($this->domainOwner->email); } if ($this->domainHosted) { $this->deleteTestDomain($this->domainHosted->namespace); } if ($this->publicDomainUser) { $this->deleteTestUser($this->publicDomainUser->email); } parent::tearDown(); } } diff --git a/src/tests/Unit/Rules/ResourceNameTest.php b/src/tests/Unit/Rules/ResourceNameTest.php new file mode 100644 index 00000000..478310f0 --- /dev/null +++ b/src/tests/Unit/Rules/ResourceNameTest.php @@ -0,0 +1,47 @@ +getTestUser('john@kolab.org'); + $rules = ['name' => ['present', new ResourceName($user, 'kolab.org')]]; + + // Empty/invalid input + $v = Validator::make(['name' => null], $rules); + $this->assertSame(['name' => ["The specified name is invalid."]], $v->errors()->toArray()); + + $v = Validator::make(['name' => []], $rules); + $this->assertSame(['name' => ["The specified name is invalid."]], $v->errors()->toArray()); + + // Forbidden chars + $v = Validator::make(['name' => 'Test@'], $rules); + $this->assertSame(['name' => ["The specified name is invalid."]], $v->errors()->toArray()); + + // Length limit + $v = Validator::make(['name' => str_repeat('a', 192)], $rules); + $this->assertSame(['name' => ["The name may not be greater than 191 characters."]], $v->errors()->toArray()); + + // Existing resource + $v = Validator::make(['name' => 'Conference Room #1'], $rules); + $this->assertSame(['name' => ["The specified name is not available."]], $v->errors()->toArray()); + + // Valid name + $v = Validator::make(['name' => 'TestRule'], $rules); + $this->assertSame([], $v->errors()->toArray()); + + // Invalid domain + $rules = ['name' => ['present', new ResourceName($user, 'kolabnow.com')]]; + $v = Validator::make(['name' => 'TestRule'], $rules); + $this->assertSame(['name' => ["The specified domain is invalid."]], $v->errors()->toArray()); + } +} diff --git a/src/tests/Unit/Rules/SharedFolderNameTest.php b/src/tests/Unit/Rules/SharedFolderNameTest.php new file mode 100644 index 00000000..fdaab65c --- /dev/null +++ b/src/tests/Unit/Rules/SharedFolderNameTest.php @@ -0,0 +1,47 @@ +getTestUser('john@kolab.org'); + $rules = ['name' => ['present', new SharedFolderName($user, 'kolab.org')]]; + + // Empty/invalid input + $v = Validator::make(['name' => null], $rules); + $this->assertSame(['name' => ["The specified name is invalid."]], $v->errors()->toArray()); + + $v = Validator::make(['name' => []], $rules); + $this->assertSame(['name' => ["The specified name is invalid."]], $v->errors()->toArray()); + + // Forbidden chars + $v = Validator::make(['name' => 'Test@'], $rules); + $this->assertSame(['name' => ["The specified name is invalid."]], $v->errors()->toArray()); + + // Length limit + $v = Validator::make(['name' => str_repeat('a', 192)], $rules); + $this->assertSame(['name' => ["The name may not be greater than 191 characters."]], $v->errors()->toArray()); + + // Existing resource + $v = Validator::make(['name' => 'Calendar'], $rules); + $this->assertSame(['name' => ["The specified name is not available."]], $v->errors()->toArray()); + + // Valid name + $v = Validator::make(['name' => 'TestRule'], $rules); + $this->assertSame([], $v->errors()->toArray()); + + // Invalid domain + $rules = ['name' => ['present', new SharedFolderName($user, 'kolabnow.com')]]; + $v = Validator::make(['name' => 'TestRule'], $rules); + $this->assertSame(['name' => ["The specified domain is invalid."]], $v->errors()->toArray()); + } +} diff --git a/src/tests/Unit/Rules/SharedFolderTypeTest.php b/src/tests/Unit/Rules/SharedFolderTypeTest.php new file mode 100644 index 00000000..44eab34b --- /dev/null +++ b/src/tests/Unit/Rules/SharedFolderTypeTest.php @@ -0,0 +1,34 @@ + ['present', new SharedFolderType()]]; + + // Empty/invalid input + $v = Validator::make(['type' => null], $rules); + $this->assertSame(['type' => ["The specified type is invalid."]], $v->errors()->toArray()); + + $v = Validator::make(['type' => []], $rules); + $this->assertSame(['type' => ["The specified type is invalid."]], $v->errors()->toArray()); + + $v = Validator::make(['type' => 'Test'], $rules); + $this->assertSame(['type' => ["The specified type is invalid."]], $v->errors()->toArray()); + + // Valid type + foreach (\App\SharedFolder::SUPPORTED_TYPES as $type) { + $v = Validator::make(['type' => $type], $rules); + $this->assertSame([], $v->errors()->toArray()); + } + } +} diff --git a/src/tests/Unit/SharedFolderTest.php b/src/tests/Unit/SharedFolderTest.php new file mode 100644 index 00000000..beb5e479 --- /dev/null +++ b/src/tests/Unit/SharedFolderTest.php @@ -0,0 +1,85 @@ + 'test']); + + foreach ($folders as $folderStatuses) { + $folder->status = \array_sum($folderStatuses); + + $folderStatuses = []; + + foreach ($statuses as $status) { + if ($folder->status & $status) { + $folderStatuses[] = $status; + } + } + + $this->assertSame($folder->status, \array_sum($folderStatuses)); + + // either one is true, but not both + $this->assertSame( + $folder->isNew() === in_array(SharedFolder::STATUS_NEW, $folderStatuses), + $folder->isActive() === in_array(SharedFolder::STATUS_ACTIVE, $folderStatuses) + ); + + $this->assertTrue( + $folder->isNew() === in_array(SharedFolder::STATUS_NEW, $folderStatuses) + ); + + $this->assertTrue( + $folder->isActive() === in_array(SharedFolder::STATUS_ACTIVE, $folderStatuses) + ); + + $this->assertTrue( + $folder->isDeleted() === in_array(SharedFolder::STATUS_DELETED, $folderStatuses) + ); + + $this->assertTrue( + $folder->isLdapReady() === in_array(SharedFolder::STATUS_LDAP_READY, $folderStatuses) + ); + + $this->assertTrue( + $folder->isImapReady() === in_array(SharedFolder::STATUS_IMAP_READY, $folderStatuses) + ); + } + + $this->expectException(\Exception::class); + $folder->status = 111; + } + + /** + * Test basic SharedFolder funtionality + */ + public function testSharedFolderType(): void + { + $folder = new SharedFolder(['name' => 'test']); + + foreach (SharedFolder::SUPPORTED_TYPES as $type) { + $folder->type = $type; + } + + $this->expectException(\Exception::class); + $folder->type = 'unknown'; + } +}