diff --git a/src/app/Backends/LDAP.php b/src/app/Backends/LDAP.php index ee0ee195..894d9d0c 100644 --- a/src/app/Backends/LDAP.php +++ b/src/app/Backends/LDAP.php @@ -1,1385 +1,1386 @@ 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'); $hostedRootDN = \config('ldap.hosted.root_dn'); $domainBaseDN = "ou={$domain->namespace},{$hostedRootDN}"; $aci = [ '(targetattr = "*")' . '(version 3.0; acl "Deny Unauthorized"; deny (all)' . '(userdn != "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN . ' || ldap:///ou=People,' . $domainBaseDN . '??sub?(objectclass=inetorgperson)") ' . 'AND NOT roledn = "ldap:///cn=kolab-admin,' . $mgmtRootDN . '";)', '(targetattr != "userPassword")' . '(version 3.0;acl "Search Access";allow (read,compare,search)' . '(userdn = "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN . ' || ldap:///ou=People,' . $domainBaseDN . '??sub?(objectclass=inetorgperson)");)', '(targetattr = "*")' . '(version 3.0;acl "Kolab Administrators";allow (all)' . '(roledn = "ldap:///cn=kolab-admin,' . $domainBaseDN . ' || ldap:///cn=kolab-admin,' . $mgmtRootDN . '");)' ]; $entry = [ 'aci' => $aci, 'associateddomain' => $domain->namespace, 'inetdomainbasedn' => $domainBaseDN, 'objectclass' => [ 'top', 'domainrelatedobject', 'inetdomain' ], ]; $dn = "associateddomain={$domain->namespace},{$config['domain_base_dn']}"; self::setDomainAttributes($domain, $entry); if (!$ldap->get_entry($dn)) { self::addEntry( $ldap, $dn, $entry, "Failed to create domain {$domain->namespace} in LDAP (" . __LINE__ . ")" ); } // create ou, roles, ous $entry = [ 'description' => $domain->namespace, 'objectclass' => [ 'top', 'organizationalunit' ], 'ou' => $domain->namespace, ]; $entry['aci'] = array( '(targetattr = "*")' . '(version 3.0;acl "Deny Unauthorized"; deny (all)' . '(userdn != "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN . ' || ldap:///ou=People,' . $domainBaseDN . '??sub?(objectclass=inetorgperson)") ' . 'AND NOT roledn = "ldap:///cn=kolab-admin,' . $mgmtRootDN . '";)', '(targetattr != "userPassword")' . '(version 3.0;acl "Search Access";allow (read,compare,search,write)' . '(userdn = "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN . ' || ldap:///ou=People,' . $domainBaseDN . '??sub?(objectclass=inetorgperson)");)', '(targetattr = "*")' . '(version 3.0;acl "Kolab Administrators";allow (all)' . '(roledn = "ldap:///cn=kolab-admin,' . $domainBaseDN . ' || ldap:///cn=kolab-admin,' . $mgmtRootDN . '");)', '(target = "ldap:///ou=*,' . $domainBaseDN . '")' . '(targetattr="objectclass || aci || ou")' . '(version 3.0;acl "Allow Domain sub-OU Registration"; allow (add)' . '(userdn = "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN . '");)', '(target = "ldap:///uid=*,ou=People,' . $domainBaseDN . '")(targetattr="*")' . '(version 3.0;acl "Allow Domain First User Registration"; allow (add)' . '(userdn = "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN . '");)', '(target = "ldap:///cn=*,' . $domainBaseDN . '")(targetattr="objectclass || cn")' . '(version 3.0;acl "Allow Domain Role Registration"; allow (add)' . '(userdn = "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN . '");)', ); if (!$ldap->get_entry($domainBaseDN)) { self::addEntry( $ldap, $domainBaseDN, $entry, "Failed to create domain {$domain->namespace} in LDAP (" . __LINE__ . ")" ); } foreach (['Groups', 'People', 'Resources', 'Shared Folders'] as $item) { $itemDN = "ou={$item},{$domainBaseDN}"; if (!$ldap->get_entry($itemDN)) { $itemEntry = [ 'ou' => $item, 'description' => $item, 'objectclass' => [ 'top', 'organizationalunit' ] ]; self::addEntry( $ldap, $itemDN, $itemEntry, "Failed to create domain {$domain->namespace} in LDAP (" . __LINE__ . ")" ); } } foreach (['kolab-admin'] as $item) { $itemDN = "cn={$item},{$domainBaseDN}"; if (!$ldap->get_entry($itemDN)) { $itemEntry = [ 'cn' => $item, 'description' => "{$item} role", 'objectclass' => [ 'top', 'ldapsubentry', 'nsmanagedroledefinition', 'nsroledefinition', 'nssimpleroledefinition' ] ]; self::addEntry( $ldap, $itemDN, $itemEntry, "Failed to create domain {$domain->namespace} in LDAP (" . __LINE__ . ")" ); } } // TODO: Assign kolab-admin role to the owner? if (empty(self::$ldap)) { $ldap->close(); } } /** * Create a group in LDAP. * * @param \App\Group $group The group to create. * * @throws \Exception */ public static function createGroup(Group $group): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $domainName = explode('@', $group->email, 2)[1]; $cn = $ldap->quote_string($group->name); $dn = "cn={$cn}," . self::baseDN($ldap, $domainName, 'Groups'); $entry = [ 'mail' => $group->email, 'objectclass' => [ 'top', 'groupofuniquenames', 'kolabgroupofuniquenames' ], ]; self::setGroupAttributes($ldap, $group, $entry); self::addEntry( $ldap, $dn, $entry, "Failed to create group {$group->email} in LDAP (" . __LINE__ . ")" ); if (empty(self::$ldap)) { $ldap->close(); } } /** * Create a resource in LDAP. * * @param \App\Resource $resource The resource to create. * * @throws \Exception */ public static function createResource(Resource $resource): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $domainName = explode('@', $resource->email, 2)[1]; $cn = $ldap->quote_string($resource->name); $dn = "cn={$cn}," . self::baseDN($ldap, $domainName, 'Resources'); $entry = [ 'mail' => $resource->email, 'objectclass' => [ 'top', 'kolabresource', 'kolabsharedfolder', 'mailrecipient', ], 'kolabfoldertype' => 'event', ]; self::setResourceAttributes($ldap, $resource, $entry); self::addEntry( $ldap, $dn, $entry, "Failed to create resource {$resource->email} in LDAP (" . __LINE__ . ")" ); if (empty(self::$ldap)) { $ldap->close(); } } /** * Create a shared folder in LDAP. * * @param \App\SharedFolder $folder The shared folder to create. * * @throws \Exception */ public static function createSharedFolder(SharedFolder $folder): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $domainName = explode('@', $folder->email, 2)[1]; $cn = $ldap->quote_string($folder->name); $dn = "cn={$cn}," . self::baseDN($ldap, $domainName, 'Shared Folders'); $entry = [ 'mail' => $folder->email, 'objectclass' => [ 'top', 'kolabsharedfolder', 'mailrecipient', ], ]; self::setSharedFolderAttributes($ldap, $folder, $entry); self::addEntry( $ldap, $dn, $entry, "Failed to create shared folder {$folder->id} in LDAP (" . __LINE__ . ")" ); if (empty(self::$ldap)) { $ldap->close(); } } /** * Create a user in LDAP. * * Only need to add user if in any of the local domains? Figure that out here for now. Should * have Context-Based Access Controls before the job is queued though, probably. * * Use one of three modes; * * 1) The authenticated user account. * * * Only valid if the authenticated user is a domain admin. * * We don't know the originating user here. * * We certainly don't have its password anymore. * * 2) The hosted kolab account. * * 3) The Directory Manager account. * * @param \App\User $user The user account to create. * * @throws \Exception */ public static function createUser(User $user): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $entry = [ 'objectclass' => [ 'top', 'inetorgperson', 'inetuser', 'kolabinetorgperson', 'mailrecipient', 'person' ], 'mail' => $user->email, 'uid' => $user->email, 'nsroledn' => [] ]; if (!self::getUserEntry($ldap, $user->email, $dn)) { if (empty($dn)) { self::throwException($ldap, "Failed to create user {$user->email} in LDAP (" . __LINE__ . ")"); } self::setUserAttributes($user, $entry); self::addEntry( $ldap, $dn, $entry, "Failed to create user {$user->email} in LDAP (" . __LINE__ . ")" ); } if (empty(self::$ldap)) { $ldap->close(); } } /** * Delete a domain from LDAP. * * @param \App\Domain $domain The domain to delete * * @throws \Exception */ public static function deleteDomain(Domain $domain): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $domainBaseDN = self::baseDN($ldap, $domain->namespace); if ($ldap->get_entry($domainBaseDN)) { $result = $ldap->delete_entry_recursive($domainBaseDN); if (!$result) { self::throwException( $ldap, "Failed to delete domain {$domain->namespace} from LDAP (" . __LINE__ . ")" ); } } if ($ldap_domain = $ldap->find_domain($domain->namespace)) { if ($ldap->get_entry($ldap_domain['dn'])) { $result = $ldap->delete_entry($ldap_domain['dn']); if (!$result) { self::throwException( $ldap, "Failed to delete domain {$domain->namespace} from LDAP (" . __LINE__ . ")" ); } } } if (empty(self::$ldap)) { $ldap->close(); } } /** * Delete a group from LDAP. * * @param \App\Group $group The group to delete. * * @throws \Exception */ public static function deleteGroup(Group $group): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); if (self::getGroupEntry($ldap, $group->email, $dn)) { $result = $ldap->delete_entry($dn); if (!$result) { self::throwException( $ldap, "Failed to delete group {$group->email} from LDAP (" . __LINE__ . ")" ); } } if (empty(self::$ldap)) { $ldap->close(); } } /** * Delete a resource from LDAP. * * @param \App\Resource $resource The resource to delete. * * @throws \Exception */ public static function deleteResource(Resource $resource): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); if (self::getResourceEntry($ldap, $resource->email, $dn)) { $result = $ldap->delete_entry($dn); if (!$result) { self::throwException( $ldap, "Failed to delete resource {$resource->email} from LDAP (" . __LINE__ . ")" ); } } if (empty(self::$ldap)) { $ldap->close(); } } /** * Delete a shared folder from LDAP. * * @param \App\SharedFolder $folder The shared folder to delete. * * @throws \Exception */ public static function deleteSharedFolder(SharedFolder $folder): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); if (self::getSharedFolderEntry($ldap, $folder->email, $dn)) { $result = $ldap->delete_entry($dn); if (!$result) { self::throwException( $ldap, "Failed to delete shared folder {$folder->id} from LDAP (" . __LINE__ . ")" ); } } if (empty(self::$ldap)) { $ldap->close(); } } /** * Delete a user from LDAP. * * @param \App\User $user The user account to delete. * * @throws \Exception */ public static function deleteUser(User $user): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); if (self::getUserEntry($ldap, $user->email, $dn)) { $result = $ldap->delete_entry($dn); if (!$result) { self::throwException( $ldap, "Failed to delete user {$user->email} from LDAP (" . __LINE__ . ")" ); } } if (empty(self::$ldap)) { $ldap->close(); } } /** * Get a domain data from LDAP. * * @param string $namespace The domain name * * @return array|false|null * @throws \Exception */ public static function getDomain(string $namespace) { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $ldapDomain = $ldap->find_domain($namespace); if ($ldapDomain) { $domain = $ldap->get_entry($ldapDomain['dn']); } if (empty(self::$ldap)) { $ldap->close(); } return $domain ?? null; } /** * Get a group data from LDAP. * * @param string $email The group email. * * @return array|false|null * @throws \Exception */ public static function getGroup(string $email) { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $group = self::getGroupEntry($ldap, $email, $dn); if (empty(self::$ldap)) { $ldap->close(); } return $group; } /** * Get a resource data from LDAP. * * @param string $email The resource email. * * @return array|false|null * @throws \Exception */ public static function getResource(string $email) { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $resource = self::getResourceEntry($ldap, $email, $dn); if (empty(self::$ldap)) { $ldap->close(); } return $resource; } /** * Get a shared folder data from LDAP. * * @param string $email The resource email. * * @return array|false|null * @throws \Exception */ public static function getSharedFolder(string $email) { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $folder = self::getSharedFolderEntry($ldap, $email, $dn); if (empty(self::$ldap)) { $ldap->close(); } return $folder; } /** * Get a user data from LDAP. * * @param string $email The user email. * * @return array|false|null * @throws \Exception */ public static function getUser(string $email) { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $user = self::getUserEntry($ldap, $email, $dn, true); if (empty(self::$ldap)) { $ldap->close(); } return $user; } /** * Update a domain in LDAP. * * @param \App\Domain $domain The domain to update. * * @throws \Exception */ public static function updateDomain(Domain $domain): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $ldapDomain = $ldap->find_domain($domain->namespace); if (!$ldapDomain) { self::throwException( $ldap, "Failed to update domain {$domain->namespace} in LDAP (domain not found)" ); } $oldEntry = $ldap->get_entry($ldapDomain['dn']); $newEntry = $oldEntry; self::setDomainAttributes($domain, $newEntry); if (array_key_exists('inetdomainstatus', $newEntry)) { $newEntry['inetdomainstatus'] = (string) $newEntry['inetdomainstatus']; } $result = $ldap->modify_entry($ldapDomain['dn'], $oldEntry, $newEntry); if (!is_array($result)) { self::throwException( $ldap, "Failed to update domain {$domain->namespace} in LDAP (" . __LINE__ . ")" ); } if (empty(self::$ldap)) { $ldap->close(); } } /** * Update a group in LDAP. * * @param \App\Group $group The group to update * * @throws \Exception */ public static function updateGroup(Group $group): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $newEntry = $oldEntry = self::getGroupEntry($ldap, $group->email, $dn); if (empty($oldEntry)) { self::throwException( $ldap, "Failed to update group {$group->email} in LDAP (group not found)" ); } self::setGroupAttributes($ldap, $group, $newEntry); $result = $ldap->modify_entry($dn, $oldEntry, $newEntry); if (!is_array($result)) { self::throwException( $ldap, "Failed to update group {$group->email} in LDAP (" . __LINE__ . ")" ); } if (empty(self::$ldap)) { $ldap->close(); } } /** * Update a resource in LDAP. * * @param \App\Resource $resource The resource to update * * @throws \Exception */ public static function updateResource(Resource $resource): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $newEntry = $oldEntry = self::getResourceEntry($ldap, $resource->email, $dn); if (empty($oldEntry)) { self::throwException( $ldap, "Failed to update resource {$resource->email} in LDAP (resource not found)" ); } self::setResourceAttributes($ldap, $resource, $newEntry); $result = $ldap->modify_entry($dn, $oldEntry, $newEntry); if (!is_array($result)) { self::throwException( $ldap, "Failed to update resource {$resource->email} in LDAP (" . __LINE__ . ")" ); } if (empty(self::$ldap)) { $ldap->close(); } } /** * Update a shared folder in LDAP. * * @param \App\SharedFolder $folder The shared folder to update * * @throws \Exception */ public static function updateSharedFolder(SharedFolder $folder): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $newEntry = $oldEntry = self::getSharedFolderEntry($ldap, $folder->email, $dn); if (empty($oldEntry)) { self::throwException( $ldap, "Failed to update shared folder {$folder->id} in LDAP (folder not found)" ); } self::setSharedFolderAttributes($ldap, $folder, $newEntry); $result = $ldap->modify_entry($dn, $oldEntry, $newEntry); if (!is_array($result)) { self::throwException( $ldap, "Failed to update shared folder {$folder->id} in LDAP (" . __LINE__ . ")" ); } if (empty(self::$ldap)) { $ldap->close(); } } /** * Update a user in LDAP. * * @param \App\User $user The user account to update. * * @throws \Exception */ public static function updateUser(User $user): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $newEntry = $oldEntry = self::getUserEntry($ldap, $user->email, $dn, true); if (!$oldEntry) { self::throwException( $ldap, "Failed to update user {$user->email} in LDAP (user not found)" ); } self::setUserAttributes($user, $newEntry); if (array_key_exists('objectclass', $newEntry)) { if (!in_array('inetuser', $newEntry['objectclass'])) { $newEntry['objectclass'][] = 'inetuser'; } } if (array_key_exists('inetuserstatus', $newEntry)) { $newEntry['inetuserstatus'] = (string) $newEntry['inetuserstatus']; } if (array_key_exists('mailquota', $newEntry)) { $newEntry['mailquota'] = (string) $newEntry['mailquota']; } $result = $ldap->modify_entry($dn, $oldEntry, $newEntry); if (!is_array($result)) { self::throwException( $ldap, "Failed to update user {$user->email} in LDAP (" . __LINE__ . ")" ); } if (empty(self::$ldap)) { $ldap->close(); } } /** * Initialize connection to LDAP */ private static function initLDAP(array $config, string $privilege = 'admin') { if (self::$ldap) { return self::$ldap; } $ldap = new \Net_LDAP3($config); $connected = $ldap->connect(); if (!$connected) { throw new \Exception("Failed to connect to LDAP"); } $bound = $ldap->bind( \config("ldap.{$privilege}.bind_dn"), \config("ldap.{$privilege}.bind_pw") ); if (!$bound) { throw new \Exception("Failed to bind to LDAP"); } return $ldap; } /** * Set domain attributes */ private static function setDomainAttributes(Domain $domain, array &$entry) { $entry['inetdomainstatus'] = $domain->status; } /** * Convert group member addresses in to valid entries. */ private static function setGroupAttributes($ldap, Group $group, &$entry) { $settings = $group->getSettings(['sender_policy']); $entry['kolaballowsmtpsender'] = json_decode($settings['sender_policy'] ?: '[]', true); $entry['cn'] = $group->name; $entry['uniquemember'] = []; $groupDomain = explode('@', $group->email, 2)[1]; $domainBaseDN = self::baseDN($ldap, $groupDomain); $validMembers = []; foreach ($group->members as $member) { list($local, $domainName) = explode('@', $member); $memberDN = "uid={$member},ou=People,{$domainBaseDN}"; $memberEntry = $ldap->get_entry($memberDN); // if the member is in the local domain but doesn't exist, drop it if ($domainName == $groupDomain && !$memberEntry) { continue; } // add the member if not in the local domain if (!$memberEntry) { $memberEntry = [ 'cn' => $member, 'mail' => $member, 'objectclass' => [ 'top', 'inetorgperson', 'organizationalperson', 'person' ], 'sn' => 'unknown' ]; $ldap->add_entry($memberDN, $memberEntry); } $entry['uniquemember'][] = $memberDN; $validMembers[] = $member; } // Update members in sql (some might have been removed), // skip model events to not invoke another update job if ($group->members !== $validMembers) { $group->members = $validMembers; Group::withoutEvents(function () use ($group) { $group->save(); }); } } /** * Set common resource attributes */ private static function setResourceAttributes($ldap, Resource $resource, &$entry) { $entry['cn'] = $resource->name; $entry['owner'] = null; $entry['kolabinvitationpolicy'] = null; $entry['acl'] = ''; $settings = $resource->getSettings(['invitation_policy', 'folder']); $entry['kolabtargetfolder'] = $settings['folder'] ?? ''; // Here's how Wallace's resources module works: // - if policy is ACT_MANUAL and owner mail specified: a tentative response is sent, event saved, // and mail sent to the owner to accept/decline the request. // - if policy is ACT_ACCEPT_AND_NOTIFY and owner mail specified: an accept response is sent, // event saved, and notification (not confirmation) mail sent to the owner. // - if there's no owner (policy irrelevant): an accept response is sent, event saved. // - if policy is ACT_REJECT: a decline response is sent // - note that the notification email is being send if COND_NOTIFY policy is set or saving failed. // - all above assume there's no conflict, if there's a conflict the decline response is sent automatically // (notification is sent if policy = ACT_ACCEPT_AND_NOTIFY). // - the only supported policies are: 'ACT_MANUAL', 'ACT_ACCEPT' (defined but not used anywhere), // 'ACT_REJECT', 'ACT_ACCEPT_AND_NOTIFY'. // For now we ignore the notifications feature if (!empty($settings['invitation_policy'])) { if ($settings['invitation_policy'] === 'accept') { $entry['kolabinvitationpolicy'] = 'ACT_ACCEPT'; } elseif ($settings['invitation_policy'] === 'reject') { $entry['kolabinvitationpolicy'] = 'ACT_REJECT'; } elseif (preg_match('/^manual:(\S+@\S+)$/', $settings['invitation_policy'], $m)) { if (self::getUserEntry($ldap, $m[1], $userDN)) { $entry['owner'] = $userDN; $entry['acl'] = $m[1] . ', full'; $entry['kolabinvitationpolicy'] = 'ACT_MANUAL'; } else { $entry['kolabinvitationpolicy'] = 'ACT_ACCEPT'; } } } } /** * Set common shared folder attributes */ private static function setSharedFolderAttributes($ldap, SharedFolder $folder, &$entry) { $settings = $folder->getSettings(['acl', 'folder']); $entry['cn'] = $folder->name; $entry['kolabfoldertype'] = $folder->type; $entry['kolabtargetfolder'] = $settings['folder'] ?? ''; $entry['acl'] = !empty($settings['acl']) ? json_decode($settings['acl'], true) : ''; + $entry['alias'] = $folder->aliases()->pluck('alias')->all(); } /** * Set common user attributes */ private static function setUserAttributes(User $user, array &$entry) { $isDegraded = $user->isDegraded(true); $settings = $user->getSettings(['first_name', 'last_name', 'organization']); $firstName = $settings['first_name']; $lastName = $settings['last_name']; $cn = "unknown"; $displayname = ""; if ($firstName) { if ($lastName) { $cn = "{$firstName} {$lastName}"; $displayname = "{$lastName}, {$firstName}"; } else { $lastName = "unknown"; $cn = "{$firstName}"; $displayname = "{$firstName}"; } } else { $firstName = ""; if ($lastName) { $cn = "{$lastName}"; $displayname = "{$lastName}"; } else { $lastName = "unknown"; } } $entry['cn'] = $cn; $entry['displayname'] = $displayname; $entry['givenname'] = $firstName; $entry['sn'] = $lastName; $entry['userpassword'] = $user->password_ldap; $entry['inetuserstatus'] = $user->status; $entry['o'] = $settings['organization']; $entry['mailquota'] = 0; - $entry['alias'] = $user->aliases->pluck('alias')->toArray(); + $entry['alias'] = $user->aliases()->pluck('alias')->all(); $roles = []; foreach ($user->entitlements as $entitlement) { \Log::debug("Examining {$entitlement->sku->title}"); switch ($entitlement->sku->title) { case "mailbox": break; case "storage": $entry['mailquota'] += 1048576; break; default: $roles[] = $entitlement->sku->title; break; } } $hostedRootDN = \config('ldap.hosted.root_dn'); $entry['nsroledn'] = []; if (in_array("2fa", $roles)) { $entry['nsroledn'][] = "cn=2fa-user,{$hostedRootDN}"; } if ($isDegraded) { $entry['nsroledn'][] = "cn=degraded-user,{$hostedRootDN}"; $entry['mailquota'] = \config('app.storage.min_qty') * 1048576; } else { if (in_array("activesync", $roles)) { $entry['nsroledn'][] = "cn=activesync-user,{$hostedRootDN}"; } if (!in_array("groupware", $roles)) { $entry['nsroledn'][] = "cn=imap-user,{$hostedRootDN}"; } } } /** * Get LDAP configuration for specified access level */ private static function getConfig(string $privilege) { $config = [ 'domain_base_dn' => \config('ldap.domain_base_dn'), 'domain_filter' => \config('ldap.domain_filter'), 'domain_name_attribute' => \config('ldap.domain_name_attribute'), 'hosts' => \config('ldap.hosts'), 'sort' => false, 'vlv' => false, 'log_hook' => 'App\Backends\LDAP::logHook', ]; return $config; } /** * Get group entry from LDAP. * * @param \Net_LDAP3 $ldap Ldap connection * @param string $email Group email (mail) * @param string $dn Reference to group DN * * @return null|array Group entry, False on error, NULL if not found */ private static function getGroupEntry($ldap, $email, &$dn = null) { $domainName = explode('@', $email, 2)[1]; $base_dn = self::baseDN($ldap, $domainName, 'Groups'); $attrs = ['dn', 'cn', 'mail', 'uniquemember', 'objectclass', 'kolaballowsmtpsender']; // For groups we're using search() instead of get_entry() because // a group name is not constant, so e.g. on update we might have // the new name, but not the old one. Email address is constant. return self::searchEntry($ldap, $base_dn, "(mail=$email)", $attrs, $dn); } /** * Get a resource entry from LDAP. * * @param \Net_LDAP3 $ldap Ldap connection * @param string $email Resource email (mail) * @param string $dn Reference to the resource DN * * @return null|array Resource entry, NULL if not found */ private static function getResourceEntry($ldap, $email, &$dn = null) { $domainName = explode('@', $email, 2)[1]; $base_dn = self::baseDN($ldap, $domainName, 'Resources'); $attrs = ['dn', 'cn', 'mail', 'objectclass', 'kolabtargetfolder', 'kolabfoldertype', 'kolabinvitationpolicy', 'owner', 'acl']; // For resources we're using search() instead of get_entry() because // a resource name is not constant, so e.g. on update we might have // the new name, but not the old one. Email address is constant. return self::searchEntry($ldap, $base_dn, "(mail=$email)", $attrs, $dn); } /** * Get a shared folder entry from LDAP. * * @param \Net_LDAP3 $ldap Ldap connection * @param string $email Resource email (mail) * @param string $dn Reference to the shared folder DN * * @return null|array Shared folder entry, NULL if not found */ private static function getSharedFolderEntry($ldap, $email, &$dn = null) { $domainName = explode('@', $email, 2)[1]; $base_dn = self::baseDN($ldap, $domainName, 'Shared Folders'); - $attrs = ['dn', 'cn', 'mail', 'objectclass', 'kolabtargetfolder', 'kolabfoldertype', 'acl']; + $attrs = ['dn', 'cn', 'mail', 'objectclass', 'kolabtargetfolder', 'kolabfoldertype', 'acl', 'alias']; // For shared folders we're using search() instead of get_entry() because // a folder name is not constant, so e.g. on update we might have // the new name, but not the old one. Email address is constant. return self::searchEntry($ldap, $base_dn, "(mail=$email)", $attrs, $dn); } /** * Get user entry from LDAP. * * @param \Net_LDAP3 $ldap Ldap connection * @param string $email User email (uid) * @param string $dn Reference to user DN * @param bool $full Get extra attributes, e.g. nsroledn * * @return ?array User entry, NULL if not found */ private static function getUserEntry($ldap, $email, &$dn = null, $full = false) { $domainName = explode('@', $email, 2)[1]; $dn = "uid={$email}," . self::baseDN($ldap, $domainName, 'People'); $entry = $ldap->get_entry($dn); if ($entry && $full) { if (!array_key_exists('nsroledn', $entry)) { $roles = $ldap->get_entry_attributes($dn, ['nsroledn']); if (!empty($roles)) { $entry['nsroledn'] = (array) $roles['nsroledn']; } } } return $entry ?: null; } /** * Logging callback */ public static function logHook($level, $msg): void { if ( ( $level == LOG_INFO || $level == LOG_DEBUG || $level == LOG_NOTICE ) && !\config('app.debug') ) { return; } switch ($level) { case LOG_CRIT: $function = 'critical'; break; case LOG_EMERG: $function = 'emergency'; break; case LOG_ERR: $function = 'error'; break; case LOG_ALERT: $function = 'alert'; break; case LOG_WARNING: $function = 'warning'; break; case LOG_INFO: $function = 'info'; break; case LOG_DEBUG: $function = 'debug'; break; case LOG_NOTICE: $function = 'notice'; break; default: $function = 'info'; } if (is_array($msg)) { $msg = implode("\n", $msg); } $msg = '[LDAP] ' . $msg; \Log::{$function}($msg); } /** * A wrapper for Net_LDAP3::add_entry() with error handler * * @param \Net_LDAP3 $ldap Ldap connection * @param string $dn Entry DN * @param array $entry Entry attributes * @param ?string $errorMsg A message to throw as an exception on error * * @throws \Exception */ private static function addEntry($ldap, string $dn, array $entry, $errorMsg = null) { // try/catch because Laravel converts warnings into exceptions // and we want more human-friendly error message than that try { $result = $ldap->add_entry($dn, $entry); } catch (\Exception $e) { $result = false; } if (!$result) { if (!$errorMsg) { $errorMsg = "LDAP Error (" . __LINE__ . ")"; } if (isset($e)) { $errorMsg .= ": " . $e->getMessage(); } self::throwException($ldap, $errorMsg); } } /** * Find a single entry in LDAP by using search. * * @param \Net_LDAP3 $ldap Ldap connection * @param string $base_dn Base DN * @param string $filter Search filter * @param array $attrs Result attributes * @param string $dn Reference to a DN of the found entry * * @return null|array LDAP entry, NULL if not found */ private static function searchEntry($ldap, $base_dn, $filter, $attrs, &$dn = null) { $result = $ldap->search($base_dn, $filter, 'sub', $attrs); if ($result && $result->count() == 1) { $entries = $result->entries(true); $dn = key($entries); $entry = $entries[$dn]; $entry['dn'] = $dn; return $entry; } return null; } /** * Throw exception and close the connection when needed * * @param \Net_LDAP3 $ldap Ldap connection * @param string $message Exception message * * @throws \Exception */ private static function throwException($ldap, string $message): void { if (empty(self::$ldap) && !empty($ldap)) { $ldap->close(); } throw new \Exception($message); } /** * Create a base DN string for a specified object. * Note: It makes sense with an existing domain only. * * @param \Net_LDAP3 $ldap Ldap connection * @param string $domainName Domain namespace * @param ?string $ouName Optional name of the sub-tree (OU) * * @return string Full base DN */ private static function baseDN($ldap, string $domainName, string $ouName = null): string { $dn = $ldap->domain_root_dn($domainName); if ($ouName) { $dn = "ou={$ouName},{$dn}"; } return $dn; } } diff --git a/src/app/Console/Commands/Data/Import/LdifCommand.php b/src/app/Console/Commands/Data/Import/LdifCommand.php index 675564f2..cd971d68 100644 --- a/src/app/Console/Commands/Data/Import/LdifCommand.php +++ b/src/app/Console/Commands/Data/Import/LdifCommand.php @@ -1,1000 +1,1009 @@ bigIncrements('id'); $table->text('dn')->index(); $table->string('type')->nullable()->index(); $table->text('data')->nullable(); $table->text('error')->nullable(); $table->text('warning')->nullable(); } ); // Import data from the file to the temp table $this->loadFromFile(); // Check for errors in the data, print them and abort (if not using --force) if ($this->printErrors()) { return 1; } // Prepare packages/skus information $this->preparePackagesAndSkus(); // Import the account owner first $this->importOwner(); // Import domains first $this->importDomains(); // Import other objects $this->importUsers(); $this->importSharedFolders(); $this->importResources(); $this->importGroups(); // Print warnings collected in the whole process $this->printWarnings(); // Finally, drop the temp table Schema::dropIfExists(self::$table); } /** * Check if a domain exists */ protected function domainExists($domain): bool { return in_array($domain, $this->domains); } /** * Load data from the LDIF file into the temp table */ protected function loadFromFile(): void { $file = $this->argument('file'); $numLines = \App\Utils::countLines($file); $bar = $this->createProgressBar($numLines, "Parsing input file"); $fh = fopen($file, 'r'); $inserts = []; $entry = []; $lastAttr = null; $insertFunc = function ($limit = 0) use (&$entry, &$inserts) { if (!empty($entry)) { if ($entry = $this->parseLDAPEntry($entry)) { $inserts[] = $entry; } $entry = []; } if (count($inserts) > $limit) { DB::table(self::$table)->insert($inserts); $inserts = []; } }; while (!feof($fh)) { $line = rtrim(fgets($fh)); $bar->advance(); if (trim($line) === '' || $line[0] === '#') { continue; } if (substr($line, 0, 3) == 'dn:') { $insertFunc(20); $entry['dn'] = strtolower(substr($line, 4)); $lastAttr = 'dn'; } elseif (substr($line, 0, 1) == ' ') { if (is_array($entry[$lastAttr])) { $elemNum = count($entry[$lastAttr]) - 1; $entry[$lastAttr][$elemNum] .= ltrim($line); } else { $entry[$lastAttr] .= ltrim($line); } } else { list ($attr, $remainder) = explode(':', $line, 2); $attr = strtolower($attr); if ($remainder[0] === ':') { $remainder = base64_decode(substr($remainder, 2)); } else { $remainder = ltrim($remainder); } if (array_key_exists($attr, $entry)) { if (!is_array($entry[$attr])) { $entry[$attr] = [$entry[$attr]]; } $entry[$attr][] = $remainder; } else { $entry[$attr] = $remainder; } $lastAttr = $attr; } } $insertFunc(); $bar->finish(); $this->info("DONE"); } /** * Import domains from the temp table */ protected function importDomains(): void { $domains = DB::table(self::$table)->where('type', 'domain')->whereNull('error')->get(); $bar = $this->createProgressBar(count($domains), "Importing domains"); foreach ($domains as $_domain) { $bar->advance(); $data = json_decode($_domain->data); $domain = \App\Domain::withTrashed()->where('namespace', $data->namespace)->first(); if ($domain) { $this->setImportWarning($_domain->id, "Domain already exists"); continue; } $domain = \App\Domain::create([ 'namespace' => $data->namespace, 'type' => \App\Domain::TYPE_EXTERNAL, ]); // Entitlements $domain->assignPackageAndWallet($this->packages['domain'], $this->wallet); $this->domains[] = $domain->namespace; if (!empty($data->aliases)) { foreach ($data->aliases as $alias) { $alias = strtolower($alias); $domain = \App\Domain::withTrashed()->where('namespace', $alias)->first(); if ($domain) { $this->setImportWarning($_domain->id, "Domain already exists"); continue; } $domain = \App\Domain::create([ 'namespace' => $alias, 'type' => \App\Domain::TYPE_EXTERNAL, ]); // Entitlements $domain->assignPackageAndWallet($this->packages['domain'], $this->wallet); $this->domains[] = $domain->namespace; } } } $bar->finish(); $this->info("DONE"); } /** * Import groups from the temp table */ protected function importGroups(): void { $groups = DB::table(self::$table)->where('type', 'group')->whereNull('error')->get(); $bar = $this->createProgressBar(count($groups), "Importing groups"); foreach ($groups as $_group) { $bar->advance(); $data = json_decode($_group->data); // Collect group member email addresses $members = $this->resolveUserDNs($data->members); if (empty($members)) { $this->setImportWarning($_group->id, "Members resolve to an empty array"); continue; } $group = \App\Group::withTrashed()->where('email', $data->email)->first(); if ($group) { $this->setImportWarning($_group->id, "Group already exists"); continue; } // Make sure the domain exists if (!$this->domainExists($data->domain)) { $this->setImportWarning($_group->id, "Domain not found"); continue; } $group = \App\Group::create([ 'name' => $data->name, 'email' => $data->email, 'members' => $members, ]); $group->assignToWallet($this->wallet); // Sender policy if (!empty($data->sender_policy)) { $group->setSetting('sender_policy', json_encode($data->sender_policy)); } } $bar->finish(); $this->info("DONE"); } /** * Import resources from the temp table */ protected function importResources(): void { $resources = DB::table(self::$table)->where('type', 'resource')->whereNull('error')->get(); $bar = $this->createProgressBar(count($resources), "Importing resources"); foreach ($resources as $_resource) { $bar->advance(); $data = json_decode($_resource->data); $resource = \App\Resource::withTrashed() ->where('name', $data->name) ->where('email', 'like', '%@' . $data->domain) ->first(); if ($resource) { $this->setImportWarning($_resource->id, "Resource already exists"); continue; } // Resource invitation policy if (!empty($data->invitation_policy) && $data->invitation_policy == 'manual') { $members = empty($data->owner) ? [] : $this->resolveUserDNs([$data->owner]); if (empty($members)) { $this->setImportWarning($_resource->id, "Failed to resolve the resource owner"); $data->invitation_policy = null; } else { $data->invitation_policy = 'manual:' . $members[0]; } } // Make sure the domain exists if (!$this->domainExists($data->domain)) { $this->setImportWarning($_resource->id, "Domain not found"); continue; } $resource = new \App\Resource(); $resource->name = $data->name; - $resource->domain = $data->domain; + $resource->domainName = $data->domain; $resource->save(); $resource->assignToWallet($this->wallet); // Invitation policy if (!empty($data->invitation_policy)) { $resource->setSetting('invitation_policy', $data->invitation_policy); } // Target folder if (!empty($data->folder)) { $resource->setSetting('folder', $data->folder); } } $bar->finish(); $this->info("DONE"); } /** * Import shared folders from the temp table */ protected function importSharedFolders(): void { $folders = DB::table(self::$table)->where('type', 'sharedFolder')->whereNull('error')->get(); $bar = $this->createProgressBar(count($folders), "Importing shared folders"); foreach ($folders as $_folder) { $bar->advance(); $data = json_decode($_folder->data); $folder = \App\SharedFolder::withTrashed() ->where('name', $data->name) ->where('email', 'like', '%@' . $data->domain) ->first(); if ($folder) { $this->setImportWarning($_folder->id, "Folder already exists"); continue; } // Make sure the domain exists if (!$this->domainExists($data->domain)) { $this->setImportWarning($_folder->id, "Domain not found"); continue; } $folder = new \App\SharedFolder(); $folder->name = $data->name; $folder->type = $data->type ?? 'mail'; - $folder->domain = $data->domain; + $folder->domainName = $data->domain; $folder->save(); $folder->assignToWallet($this->wallet); // Invitation policy if (!empty($data->acl)) { $folder->setSetting('acl', json_encode($data->acl)); } // Target folder if (!empty($data->folder)) { $folder->setSetting('folder', $data->folder); } + + // Import aliases + if (!empty($data->aliases)) { + $this->setObjectAliases($folder, $data->aliases); + } } $bar->finish(); $this->info("DONE"); } /** * Import users from the temp table */ protected function importUsers(): void { $users = DB::table(self::$table)->where('type', 'user')->whereNull('error'); // Skip the (already imported) account owner if ($this->ownerDN) { $users->whereNotIn('dn', [$this->ownerDN]); } // Import aliases of the owner, we got from importOwner() call if (!empty($this->aliases) && $this->wallet) { - $this->setUserAliases($this->wallet->owner, $this->aliases); + $this->setObjectAliases($this->wallet->owner, $this->aliases); } $bar = $this->createProgressBar($users->count(), "Importing users"); foreach ($users->cursor() as $_user) { $bar->advance(); $this->importSingleUser($_user); } $bar->finish(); $this->info("DONE"); } /** * Import the account owner (or find it among the existing accounts) */ protected function importOwner(): void { // The owner email not found in the import data, try existing users $user = $this->getUser($this->argument('owner')); if (!$user && $this->ownerDN) { // The owner email found in the import data $bar = $this->createProgressBar(1, "Importing account owner"); $user = DB::table(self::$table)->where('dn', $this->ownerDN)->first(); $user = $this->importSingleUser($user); // TODO: We should probably make sure the user's domain is to be imported too // and/or create it automatically. $bar->advance(); $bar->finish(); $this->info("DONE"); } if (!$user) { $this->error("Unable to find the specified account owner"); exit(1); } $this->wallet = $user->wallets->first(); } /** * A helper that imports a single user record */ protected function importSingleUser($ldap_user) { $data = json_decode($ldap_user->data); $user = \App\User::withTrashed()->where('email', $data->email)->first(); if ($user) { $this->setImportWarning($ldap_user->id, "User already exists"); return; } // Make sure the domain exists if ($this->wallet && !$this->domainExists($data->domain)) { $this->setImportWarning($ldap_user->id, "Domain not found"); return; } $user = \App\User::create(['email' => $data->email]); // Entitlements $user->assignPackageAndWallet($this->packages['user'], $this->wallet ?: $user->wallets()->first()); if (!empty($data->quota)) { $quota = ceil($data->quota / 1024 / 1024) - $this->packages['quota']; if ($quota > 0) { $user->assignSku($this->packages['storage'], $quota); } } // User settings if (!empty($data->settings)) { $settings = []; foreach ($data->settings as $key => $value) { $settings[] = [ 'user_id' => $user->id, 'key' => $key, 'value' => $value, ]; } DB::table('user_settings')->insert($settings); } // Update password if ($data->password != $user->password_ldap) { \App\User::where('id', $user->id)->update(['password_ldap' => $data->password]); } // Import aliases if (!empty($data->aliases)) { if (!$this->wallet) { // This is the account owner creation, at this point we likely do not have // domain records yet, save the aliases to be inserted later (in importUsers()) $this->aliases = $data->aliases; } else { - $this->setUserAliases($user, $data->aliases); + $this->setObjectAliases($user, $data->aliases); } } return $user; } /** * Convert LDAP entry into an object supported by the migration tool * * @param array $entry LDAP entry attributes * * @return array Record data for inserting to the temp table */ protected function parseLDAPEntry(array $entry): array { $type = null; $data = null; $error = null; $ouTypeMap = [ 'Shared Folders' => 'sharedfolder', 'Resources' => 'resource', 'Groups' => 'group', 'People' => 'user', 'Domains' => 'domain', ]; foreach ($ouTypeMap as $ou => $_type) { if (stripos($entry['dn'], ",ou={$ou}")) { $type = $_type; break; } } if (!$type) { $error = "Unknown record type"; } if (empty($error)) { $method = 'parseLDAP' . ucfirst($type); list($data, $error) = $this->{$method}($entry); if (empty($data['domain']) && !empty($data['email'])) { $data['domain'] = explode('@', $data['email'])[1]; } } return [ 'dn' => $entry['dn'], 'type' => $type, 'data' => json_encode($data), 'error' => $error, ]; } /** * Convert LDAP domain data into Kolab4 "format" */ protected function parseLDAPDomain($entry) { $error = null; $result = []; if (empty($entry['associateddomain'])) { $error = "Missing 'associatedDomain' attribute"; } elseif (!empty($entry['inetdomainstatus']) && $entry['inetdomainstatus'] == 'deleted') { $error = "Domain deleted"; } else { $result['namespace'] = strtolower($this->attrStringValue($entry, 'associateddomain')); if (is_array($entry['associateddomain']) && count($entry['associateddomain']) > 1) { $result['aliases'] = array_slice($entry['associateddomain'], 1); } // TODO: inetdomainstatus = suspended ??? } return [$result, $error]; } /** * Convert LDAP group data into Kolab4 "format" */ protected function parseLDAPGroup($entry) { $error = null; $result = []; if (empty($entry['cn'])) { $error = "Missing 'cn' attribute"; } elseif (empty($entry['mail'])) { $error = "Missing 'mail' attribute"; } elseif (empty($entry['uniquemember'])) { $error = "Missing 'uniqueMember' attribute"; } else { $result['name'] = $this->attrStringValue($entry, 'cn'); $result['email'] = strtolower($this->attrStringValue($entry, 'mail')); $result['members'] = $this->attrArrayValue($entry, 'uniquemember'); if (!empty($entry['kolaballowsmtpsender'])) { $policy = $this->attrArrayValue($entry, 'kolaballowsmtpsender'); $result['sender_policy'] = $this->parseSenderPolicy($policy); } } return [$result, $error]; } /** * Convert LDAP resource data into Kolab4 "format" */ protected function parseLDAPResource($entry) { $error = null; $result = []; if (empty($entry['cn'])) { $error = "Missing 'cn' attribute"; } elseif (empty($entry['mail'])) { $error = "Missing 'mail' attribute"; } else { $result['name'] = $this->attrStringValue($entry, 'cn'); $result['email'] = strtolower($this->attrStringValue($entry, 'mail')); if (!empty($entry['kolabtargetfolder'])) { $result['folder'] = $this->attrStringValue($entry, 'kolabtargetfolder'); } if (!empty($entry['owner'])) { $result['owner'] = $this->attrStringValue($entry, 'owner'); } if (!empty($entry['kolabinvitationpolicy'])) { $policy = $this->attrArrayValue($entry, 'kolabinvitationpolicy'); $result['invitation_policy'] = $this->parseInvitationPolicy($policy); } } return [$result, $error]; } /** * Convert LDAP shared folder data into Kolab4 "format" */ protected function parseLDAPSharedFolder($entry) { $error = null; $result = []; if (empty($entry['cn'])) { $error = "Missing 'cn' attribute"; } elseif (empty($entry['mail'])) { $error = "Missing 'mail' attribute"; } else { $result['name'] = $this->attrStringValue($entry, 'cn'); $result['email'] = strtolower($this->attrStringValue($entry, 'mail')); if (!empty($entry['kolabfoldertype'])) { $result['type'] = $this->attrStringValue($entry, 'kolabfoldertype'); } if (!empty($entry['kolabtargetfolder'])) { $result['folder'] = $this->attrStringValue($entry, 'kolabtargetfolder'); } if (!empty($entry['acl'])) { $result['acl'] = $this->parseACL($this->attrArrayValue($entry, 'acl')); } + + if (!empty($entry['alias'])) { + $result['aliases'] = $this->attrArrayValue($entry, 'alias'); + } } return [$result, $error]; } /** * Convert LDAP user data into Kolab4 "format" */ protected function parseLDAPUser($entry) { $error = null; $result = []; $settingAttrs = [ 'givenname' => 'first_name', 'sn' => 'last_name', 'telephonenumber' => 'phone', 'mailalternateaddress' => 'external_email', 'mobile' => 'phone', 'o' => 'organization', // 'address' => 'billing_address' ]; if (empty($entry['mail'])) { $error = "Missing 'mail' attribute"; } else { $result['email'] = strtolower($this->attrStringValue($entry, 'mail')); $result['settings'] = []; $result['aliases'] = []; foreach ($settingAttrs as $attr => $setting) { if (!empty($entry[$attr])) { $result['settings'][$setting] = $this->attrStringValue($entry, $attr); } } if (!empty($entry['alias'])) { $result['aliases'] = $this->attrArrayValue($entry, 'alias'); } if (!empty($entry['userpassword'])) { $result['password'] = $this->attrStringValue($entry, 'userpassword'); } if (!empty($entry['mailquota'])) { $result['quota'] = $this->attrStringValue($entry, 'mailquota'); } if ($result['email'] == $this->argument('owner')) { $this->ownerDN = $entry['dn']; } } return [$result, $error]; } /** * Print import errors */ protected function printErrors(): bool { if ($this->option('force')) { return false; } $errors = DB::table(self::$table)->whereNotNull('error')->orderBy('id') ->get() ->map(function ($record) { $this->error("ERROR {$record->dn}: {$record->error}"); return $record->id; }) ->all(); return !empty($errors); } /** * Print import warnings (for records that do not have an error specified) */ protected function printWarnings(): void { DB::table(self::$table)->whereNotNull('warning')->whereNull('error')->orderBy('id') ->each(function ($record) { $this->warn("WARNING {$record->dn}: {$record->warning}"); return $record->id; }); } /** * Convert ldap attribute value to an array */ protected static function attrArrayValue($entry, $attribute) { return is_array($entry[$attribute]) ? $entry[$attribute] : [$entry[$attribute]]; } /** * Convert ldap attribute to a string */ protected static function attrStringValue($entry, $attribute) { return is_array($entry[$attribute]) ? $entry[$attribute][0] : $entry[$attribute]; } /** * Resolve a list of user DNs into email addresses. Makes sure * the returned addresses exist in Kolab4 database. */ protected function resolveUserDNs($user_dns): array { // Get email addresses from the import data $users = DB::table(self::$table)->whereIn('dn', $user_dns) ->where('type', 'user') ->whereNull('error') ->get() ->map(function ($user) { $mdata = json_decode($user->data); return $mdata->email; }) // Make sure to skip these with unknown domains ->filter(function ($email) { return $this->domainExists(explode('@', $email)[1]); }) ->all(); // Get email addresses for existing Kolab4 users if (!empty($users)) { $users = \App\User::whereIn('email', $users)->get()->pluck('email')->all(); } return $users; } /** * Validate/convert acl to Kolab4 format */ protected static function parseACL(array $acl): array { $map = [ 'lrswipkxtecdn' => 'full', 'lrs' => 'read-only', 'read' => 'read-only', 'lrswitedn' => 'read-write', ]; $supportedRights = ['full', 'read-only', 'read-write']; foreach ($acl as $idx => $entry) { $parts = explode(',', $entry); $entry = null; if (count($parts) == 2) { $label = trim($parts[0]); $rights = trim($parts[1]); $rights = $map[$rights] ?? $rights; if (in_array($rights, $supportedRights) && ($label === 'anyone' || strpos($label, '@'))) { $entry = "{$label}, {$rights}"; } // TODO: Throw an error or log a warning on unsupported acl entry? } $acl[$idx] = $entry; } return array_values(array_filter($acl)); } /** * Validate/convert invitation policy to Kolab4 format */ protected static function parseInvitationPolicy(array $policies): ?string { foreach ($policies as $policy) { if ($policy == 'ACT_MANUAL') { // 'owner' attribute handling in another place return 'manual'; } if ($policy == 'ACT_ACCEPT_AND_NOTIFY') { break; // use the default 'accept' (null) policy } if ($policy == 'ACT_REJECT') { return 'reject'; } } return null; } /** * Validate/convert sender policy to Kolab4 format */ protected static function parseSenderPolicy(array $rules): array { foreach ($rules as $idx => $rule) { $entry = trim($rule); $rule = null; // 'deny' rules aren't supported if (isset($entry[0]) && $entry[0] !== '-') { $rule = $entry; } $rules[$idx] = $rule; } $rules = array_values(array_filter($rules)); if (!empty($rules) && $rules[count($rules) - 1] != '-') { $rules[] = '-'; } return $rules; } /** * Get/prepare packages/skus information */ protected function preparePackagesAndSkus(): void { // Find the tenant if (empty($this->ownerDN)) { if ($user = $this->getUser($this->argument('owner'))) { $tenant_id = $user->tenant_id; } } // TODO: Tenant id could be a command option if (empty($tenant_id)) { $tenant_id = \config('app.tenant_id'); } // TODO: We should probably make package titles configurable with command options $this->packages = [ 'user' => \App\Package::where('title', 'kolab')->where('tenant_id', $tenant_id)->first(), 'domain' => \App\Package::where('title', 'domain-hosting')->where('tenant_id', $tenant_id)->first(), ]; // Count storage skus $sku = $this->packages['user']->skus()->where('title', 'storage')->first(); $this->packages['quota'] = $sku ? $sku->pivot->qty : 0; $this->packages['storage'] = \App\Sku::where('title', 'storage')->where('tenant_id', $tenant_id)->first(); } /** - * Set aliases for the user + * Set aliases for for an object */ - protected function setUserAliases(\App\User $user, array $aliases = []) + protected function setObjectAliases($object, array $aliases = []) { if (!empty($aliases)) { // Some users might have alias entry with their main address, remove it $aliases = array_map('strtolower', $aliases); - $aliases = array_diff(array_unique($aliases), [$user->email]); + $aliases = array_diff(array_unique($aliases), [$object->email]); // Remove aliases for domains that do not exist if (!empty($aliases)) { $aliases = array_filter( $aliases, function ($alias) { return $this->domainExists(explode('@', $alias)[1]); } ); } if (!empty($aliases)) { - $user->setAliases($aliases); + $object->setAliases($aliases); } } } /** * Set error message for specified import data record */ protected static function setImportError($id, $error): void { DB::table(self::$table)->where('id', $id)->update(['error' => $error]); } /** * Set warning message for specified import data record */ protected static function setImportWarning($id, $warning): void { DB::table(self::$table)->where('id', $id)->update(['warning' => $warning]); } } diff --git a/src/app/Console/Commands/SharedFolder/AddAliasCommand.php b/src/app/Console/Commands/SharedFolder/AddAliasCommand.php new file mode 100644 index 00000000..83e1339d --- /dev/null +++ b/src/app/Console/Commands/SharedFolder/AddAliasCommand.php @@ -0,0 +1,58 @@ +getSharedFolder($this->argument('folder')); + + if (!$folder) { + $this->error("Folder not found."); + return 1; + } + + $alias = \strtolower($this->argument('alias')); + + // Check if the alias already exists + if ($folder->aliases()->where('alias', $alias)->first()) { + $this->error("Address is already assigned to the folder."); + return 1; + } + + // Validate the alias + $error = UsersController::validateAlias($alias, $folder->walletOwner()); + + if ($error) { + if (!$this->option('force')) { + $this->error($error); + return 1; + } + } + + $folder->aliases()->create(['alias' => $alias]); + } +} diff --git a/src/app/Console/Commands/User/AliasesCommand.php b/src/app/Console/Commands/SharedFolder/AliasesCommand.php similarity index 50% copy from src/app/Console/Commands/User/AliasesCommand.php copy to src/app/Console/Commands/SharedFolder/AliasesCommand.php index aa240f98..526aae40 100644 --- a/src/app/Console/Commands/User/AliasesCommand.php +++ b/src/app/Console/Commands/SharedFolder/AliasesCommand.php @@ -1,41 +1,41 @@ getUser($this->argument('user')); + $folder = $this->getSharedFolder($this->argument('folder')); - if (!$user) { - $this->error("User not found."); + if (!$folder) { + $this->error("Folder not found."); return 1; } - foreach ($user->aliases as $alias) { - $this->info("{$alias->alias}"); + foreach ($folder->aliases()->pluck('alias')->all() as $alias) { + $this->info($alias); } } } diff --git a/src/app/Console/Commands/SharedFolder/CreateCommand.php b/src/app/Console/Commands/SharedFolder/CreateCommand.php index 766fb9e7..9107a7d5 100644 --- a/src/app/Console/Commands/SharedFolder/CreateCommand.php +++ b/src/app/Console/Commands/SharedFolder/CreateCommand.php @@ -1,101 +1,101 @@ argument('domain'); $name = $this->argument('name'); $type = $this->option('type'); $acl = $this->option('acl'); if (empty($type)) { $type = 'mail'; } $domain = $this->getDomain($domainName); if (!$domain) { $this->error("No such domain {$domainName}."); return 1; } if ($domain->isPublic()) { $this->error("Domain {$domainName} is public."); return 1; } $owner = $domain->wallet()->owner; // Validate folder name and type $rules = [ 'name' => ['required', 'string', new SharedFolderName($owner, $domain->namespace)], 'type' => ['required', 'string', new SharedFolderType()] ]; $v = Validator::make(['name' => $name, 'type' => $type], $rules); if ($v->fails()) { $this->error($v->errors()->all()[0]); return 1; } DB::beginTransaction(); // Create the shared folder $folder = new SharedFolder(); $folder->name = $name; $folder->type = $type; - $folder->domain = $domainName; + $folder->domainName = $domainName; $folder->save(); $folder->assignToWallet($owner->wallets->first()); if (!empty($acl)) { $errors = $folder->setConfig(['acl' => $acl]); if (!empty($errors)) { $this->error("Invalid --acl entry."); DB::rollBack(); return 1; } } DB::commit(); $this->info($folder->id); } } diff --git a/src/app/Console/Commands/SharedFolderAliasesCommand.php b/src/app/Console/Commands/SharedFolderAliasesCommand.php new file mode 100644 index 00000000..6a3fe1ae --- /dev/null +++ b/src/app/Console/Commands/SharedFolderAliasesCommand.php @@ -0,0 +1,13 @@ +getUser($this->argument('user')); if (!$user) { $this->error("User not found."); return 1; } - foreach ($user->aliases as $alias) { - $this->info("{$alias->alias}"); + foreach ($user->aliases()->pluck('alias')->all() as $alias) { + $this->info($alias); } } } diff --git a/src/app/Group.php b/src/app/Group.php index 58e15649..e56a625c 100644 --- a/src/app/Group.php +++ b/src/app/Group.php @@ -1,126 +1,81 @@ email); - - return Domain::where('namespace', $domainName)->first(); - } - - /** - * Find whether an email address exists as a group (including deleted groups). - * - * @param string $email Email address - * @param bool $return_group Return Group instance instead of boolean - * - * @return \App\Group|bool True or Group model object if found, False otherwise - */ - public static function emailExists(string $email, bool $return_group = false) - { - if (strpos($email, '@') === false) { - return false; - } - - $email = \strtolower($email); - - $group = self::withTrashed()->where('email', $email)->first(); - - if ($group) { - return $return_group ? $group : true; - } - - return false; - } - /** * Group members propert accessor. Converts internal comma-separated list into an array * * @param string $members Comma-separated list of email addresses * * @return array Email addresses of the group members, as an array */ public function getMembersAttribute($members): array { return $members ? explode(',', $members) : []; } - /** - * Ensure the email is appropriately cased. - * - * @param string $email Group email address - */ - public function setEmailAttribute(string $email) - { - $this->attributes['email'] = strtolower($email); - } - /** * Ensure the members are appropriately formatted. * * @param array $members Email addresses of the group members */ public function setMembersAttribute(array $members): void { $members = array_unique(array_filter(array_map('strtolower', $members))); sort($members); $this->attributes['members'] = implode(',', $members); } } diff --git a/src/app/Http/Controllers/API/V4/Admin/UsersController.php b/src/app/Http/Controllers/API/V4/Admin/UsersController.php index 57286355..929323fe 100644 --- a/src/app/Http/Controllers/API/V4/Admin/UsersController.php +++ b/src/app/Http/Controllers/API/V4/Admin/UsersController.php @@ -1,344 +1,346 @@ 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 + // Search by an email of a group, resource, shared folder, etc. 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(); + } elseif ($alias = \App\SharedFolderAlias::where('alias', $search)->first()) { + $user_ids = $user_ids->merge([$alias->sharedFolder->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, ] ]); } /** * 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/Reseller/UsersController.php b/src/app/Http/Controllers/API/V4/Reseller/UsersController.php index 7b3de67c..1fd8dcaa 100644 --- a/src/app/Http/Controllers/API/V4/Reseller/UsersController.php +++ b/src/app/Http/Controllers/API/V4/Reseller/UsersController.php @@ -1,112 +1,114 @@ 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 + // Search by an email of a group, resource, shared folder, etc. 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(); + } elseif ($alias = \App\SharedFolderAlias::where('alias', $search)->first()) { + $user_ids = $user_ids->merge([$alias->sharedFolder->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 add86e72..19aeb4db 100644 --- a/src/app/Http/Controllers/API/V4/ResourcesController.php +++ b/src/app/Http/Controllers/API/V4/ResourcesController.php @@ -1,192 +1,192 @@ true, 'resource-ldap-ready' => $resource->isLdapReady(), 'resource-imap-ready' => $resource->isImapReady(), ] ); } /** * Create a new resource record. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function store(Request $request) { $current_user = $this->guard()->user(); $owner = $current_user->wallet()->owner; if ($owner->id != $current_user->id) { return $this->errorResponse(403); } $domain = request()->input('domain'); $rules = ['name' => ['required', 'string', new ResourceName($owner, $domain)]]; $v = Validator::make($request->all(), $rules); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } DB::beginTransaction(); // Create the resource $resource = new Resource(); $resource->name = request()->input('name'); - $resource->domain = $domain; + $resource->domainName = $domain; $resource->save(); $resource->assignToWallet($owner->wallets->first()); DB::commit(); return response()->json([ 'status' => 'success', 'message' => \trans('app.resource-create-success'), ]); } /** * Update a resource. * * @param \Illuminate\Http\Request $request The API request. * @param string $id Resource identifier * * @return \Illuminate\Http\JsonResponse The response */ public function update(Request $request, $id) { $resource = Resource::find($id); if (!$this->checkTenant($resource)) { return $this->errorResponse(404); } $current_user = $this->guard()->user(); if (!$current_user->canUpdate($resource)) { return $this->errorResponse(403); } $owner = $resource->wallet()->owner; $name = $request->input('name'); $errors = []; // Validate the resource name if ($name !== null && $name != $resource->name) { $domainName = explode('@', $resource->email, 2)[1]; $rules = ['name' => ['required', 'string', new ResourceName($owner, $domainName)]]; $v = Validator::make($request->all(), $rules); if ($v->fails()) { $errors = $v->errors()->toArray(); } else { $resource->name = $name; } } if (!empty($errors)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } $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; } } diff --git a/src/app/Http/Controllers/API/V4/SharedFoldersController.php b/src/app/Http/Controllers/API/V4/SharedFoldersController.php index 2d86d1fe..179d38c4 100644 --- a/src/app/Http/Controllers/API/V4/SharedFoldersController.php +++ b/src/app/Http/Controllers/API/V4/SharedFoldersController.php @@ -1,197 +1,262 @@ 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; + $owner = $current_user->walletOwner(); - if ($owner->id != $current_user->id) { + if (empty($owner) || $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); + if ($error_response = $this->validateFolderRequest($request, null, $owner)) { + return $error_response; } DB::beginTransaction(); // Create the shared folder $folder = new SharedFolder(); - $folder->name = request()->input('name'); - $folder->type = request()->input('type'); - $folder->domain = $domain; + $folder->name = $request->input('name'); + $folder->type = $request->input('type'); + $folder->domainName = $request->input('domain'); $folder->save(); + if (!empty($request->aliases) && $folder->type === 'mail') { + $folder->setAliases($request->aliases); + } + $folder->assignToWallet($owner->wallets->first()); DB::commit(); return response()->json([ 'status' => 'success', 'message' => \trans('app.shared-folder-create-success'), ]); } /** * Update a shared folder. * * @param \Illuminate\Http\Request $request The API request. * @param string $id Shared folder identifier * * @return \Illuminate\Http\JsonResponse The response */ public function update(Request $request, $id) { $folder = SharedFolder::find($id); if (!$this->checkTenant($folder)) { return $this->errorResponse(404); } $current_user = $this->guard()->user(); if (!$current_user->canUpdate($folder)) { return $this->errorResponse(403); } - $owner = $folder->wallet()->owner; + if ($error_response = $this->validateFolderRequest($request, $folder, $folder->walletOwner())) { + return $error_response; + } $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); + DB::beginTransaction(); - if ($v->fails()) { - $errors = $v->errors()->toArray(); - } else { - $folder->name = $name; - } + if ($name && $name != $folder->name) { + $folder->name = $name; } - if (!empty($errors)) { - return response()->json(['status' => 'error', 'errors' => $errors], 422); + $folder->save(); + + if (isset($request->aliases) && $folder->type === 'mail') { + $folder->setAliases($request->aliases); } - $folder->save(); + DB::commit(); return response()->json([ 'status' => 'success', 'message' => \trans('app.shared-folder-update-success'), ]); } /** * Execute (synchronously) specified step in a shared folder setup process. * * @param \App\SharedFolder $folder Shared folder object * @param string $step Step identifier (as in self::statusInfo()) * * @return bool|null True if the execution succeeded, False if not, Null when * the job has been sent to the worker (result unknown) */ public static function execProcessStep(SharedFolder $folder, string $step): ?bool { try { if (strpos($step, 'domain-') === 0) { return DomainsController::execProcessStep($folder->domain(), $step); } switch ($step) { case 'shared-folder-ldap-ready': // Shared folder not in LDAP, create it $job = new \App\Jobs\SharedFolder\CreateJob($folder->id); $job->handle(); $folder->refresh(); return $folder->isLdapReady(); case 'shared-folder-imap-ready': // Shared folder not in IMAP? Verify again // Do it synchronously if the imap admin credentials are available // otherwise let the worker do the job if (!\config('imap.admin_password')) { \App\Jobs\SharedFolder\VerifyJob::dispatch($folder->id); return null; } $job = new \App\Jobs\SharedFolder\VerifyJob($folder->id); $job->handle(); $folder->refresh(); return $folder->isImapReady(); } } catch (\Exception $e) { \Log::error($e); } return false; } + + /** + * Validate shared folder input + * + * @param \Illuminate\Http\Request $request The API request. + * @param \App\SharedFolder|null $folder Shared folder + * @param \App\User|null $owner Account owner + * + * @return \Illuminate\Http\JsonResponse|null The error response on error + */ + protected function validateFolderRequest(Request $request, $folder, $owner) + { + $errors = []; + + if (empty($folder)) { + $domain = $request->input('domain'); + $rules = [ + 'name' => ['required', 'string', new SharedFolderName($owner, $domain)], + 'type' => ['required', 'string', new SharedFolderType()], + ]; + } else { + // On update validate the folder name (if changed) + $name = $request->input('name'); + if ($name !== null && $name != $folder->name) { + $domain = explode('@', $folder->email, 2)[1]; + $rules = ['name' => ['required', 'string', new SharedFolderName($owner, $domain)]]; + } + } + + if (!empty($rules)) { + $v = Validator::make($request->all(), $rules); + + if ($v->fails()) { + $errors = $v->errors()->toArray(); + } + } + + // Validate aliases input + if (isset($request->aliases)) { + $aliases = []; + $existing_aliases = $owner->aliases()->get()->pluck('alias')->toArray(); + + foreach ($request->aliases as $idx => $alias) { + if (is_string($alias) && !empty($alias)) { + // Alias cannot be the same as the email address + if (!empty($folder) && Str::lower($alias) == Str::lower($folder->email)) { + continue; + } + + // validate new aliases + if ( + !in_array($alias, $existing_aliases) + && ($error = UsersController::validateAlias($alias, $owner)) + ) { + if (!isset($errors['aliases'])) { + $errors['aliases'] = []; + } + $errors['aliases'][$idx] = $error; + continue; + } + + $aliases[] = $alias; + } + } + + $request->aliases = $aliases; + } + + if (!empty($errors)) { + return response()->json(['status' => 'error', 'errors' => $errors], 422); + } + + return null; + } } diff --git a/src/app/Http/Controllers/API/V4/UsersController.php b/src/app/Http/Controllers/API/V4/UsersController.php index 5eb4e0ca..669acd64 100644 --- a/src/app/Http/Controllers/API/V4/UsersController.php +++ b/src/app/Http/Controllers/API/V4/UsersController.php @@ -1,773 +1,762 @@ guard()->user(); $search = trim(request()->input('search')); $page = intval(request()->input('page')) ?: 1; $pageSize = 20; $hasMore = false; $result = $user->users(); // Search by user email, alias or name if (strlen($search) > 0) { // thanks to cloning we skip some extra queries in $user->users() $allUsers1 = clone $result; $allUsers2 = clone $result; $result->whereLike('email', $search) ->union( $allUsers1->join('user_aliases', 'users.id', '=', 'user_aliases.user_id') ->whereLike('alias', $search) ) ->union( $allUsers2->join('user_settings', 'users.id', '=', 'user_settings.user_id') ->whereLike('value', $search) ->whereIn('key', ['first_name', 'last_name']) ); } $result = $result->orderBy('email') ->limit($pageSize + 1) ->offset($pageSize * ($page - 1)) ->get(); if (count($result) > $pageSize) { $result->pop(); $hasMore = true; } // Process the result $result = $result->map( function ($user) { return $this->objectToClient($user); } ); $result = [ 'list' => $result, 'count' => count($result), 'hasMore' => $hasMore, ]; return response()->json($result); } /** * Display information on the user account specified by $id. * * @param string $id The account to show information for. * * @return \Illuminate\Http\JsonResponse */ public function show($id) { $user = User::find($id); if (!$this->checkTenant($user)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canRead($user)) { return $this->errorResponse(403); } $response = $this->userResponse($user); $response['skus'] = \App\Entitlement::objectEntitlementsSummary($user); $response['config'] = $user->getConfig(); + $response['aliases'] = $user->aliases()->pluck('alias')->all(); $code = $user->verificationcodes()->where('active', true) ->where('expires_at', '>', \Carbon\Carbon::now()) ->first(); if ($code) { $response['passwordLinkCode'] = $code->short_code . '-' . $code->code; } return response()->json($response); } /** * User status (extended) information * * @param \App\User $user User object * * @return array Status information */ public static function statusInfo($user): array { $process = self::processStateInfo( $user, [ 'user-new' => true, 'user-ldap-ready' => $user->isLdapReady(), 'user-imap-ready' => $user->isImapReady(), ] ); // Check if the user is a controller of his wallet $isController = $user->canDelete($user); $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('beta-distlists', $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), 'enableSettings' => $isController, '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; + $owner = $current_user->walletOwner(); if ($owner->id != $current_user->id) { return $this->errorResponse(403); } $this->deleteBeforeCreate = null; if ($error_response = $this->validateUserRequest($request, null, $settings)) { return $error_response; } if (empty($request->package) || !($package = \App\Package::withEnvTenantContext()->find($request->package))) { $errors = ['package' => \trans('validation.packagerequired')]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } if ($package->isDomain()) { $errors = ['package' => \trans('validation.packageinvalid')]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } DB::beginTransaction(); // @phpstan-ignore-next-line if ($this->deleteBeforeCreate) { $this->deleteBeforeCreate->forceDelete(); } // Create user record $user = User::create([ 'email' => $request->email, 'password' => $request->password, ]); $this->activatePassCode($user); $owner->assignPackage($package, $user); if (!empty($settings)) { $user->setSettings($settings); } if (!empty($request->aliases)) { $user->setAliases($request->aliases); } DB::commit(); return response()->json([ 'status' => 'success', 'message' => \trans('app.user-create-success'), ]); } /** * Update user data. * * @param \Illuminate\Http\Request $request The API request. * @param string $id User identifier * * @return \Illuminate\Http\JsonResponse The response */ public function update(Request $request, $id) { $user = User::withEnvTenantContext()->find($id); if (empty($user)) { return $this->errorResponse(404); } $current_user = $this->guard()->user(); // TODO: Decide what attributes a user can change on his own profile if (!$current_user->canUpdate($user)) { return $this->errorResponse(403); } if ($error_response = $this->validateUserRequest($request, $user, $settings)) { return $error_response; } // Entitlements, only controller can do that if ($request->skus !== null && !$current_user->canDelete($user)) { return $this->errorResponse(422, "You have no permission to change entitlements"); } DB::beginTransaction(); $this->updateEntitlements($user, $request->skus); if (!empty($settings)) { $user->setSettings($settings); } if (!empty($request->password)) { $user->password = $request->password; $user->save(); } $this->activatePassCode($user); if (isset($request->aliases)) { $user->setAliases($request->aliases); } DB::commit(); $response = [ 'status' => 'success', 'message' => \trans('app.user-update-success'), ]; // For self-update refresh the statusInfo in the UI if ($user->id == $current_user->id) { $response['statusInfo'] = self::statusInfo($user); } return response()->json($response); } /** * 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 = array_merge($user->toArray(), self::objectState($user)); // Settings $response['settings'] = []; foreach ($user->settings()->whereIn('key', self::USER_SETTINGS)->get() as $item) { $response['settings'][$item->key] = $item->value; } - // 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): array { $state = parent::objectState($user); $state['isAccountDegraded'] = $user->isDegraded(true); return $state; } /** * Validate user input * * @param \Illuminate\Http\Request $request The API request. * @param \App\User|null $user User identifier * @param array $settings User settings (from the request) * * @return \Illuminate\Http\JsonResponse|null The error response on error */ protected function validateUserRequest(Request $request, $user, &$settings = []) { $rules = [ 'external_email' => 'nullable|email', 'phone' => 'string|nullable|max:64|regex:/^[0-9+() -]+$/', 'first_name' => 'string|nullable|max:128', 'last_name' => 'string|nullable|max:128', 'organization' => 'string|nullable|max:512', 'billing_address' => 'string|nullable|max:1024', 'country' => 'string|nullable|alpha|size:2', 'currency' => 'string|nullable|alpha|size:3', 'aliases' => 'array|nullable', ]; $controller = ($user ?: $this->guard()->user())->walletOwner(); // Handle generated password reset code if ($code = $request->input('passwordLinkCode')) { // Accept - input if (strpos($code, '-')) { $code = explode('-', $code)[1]; } $this->passCode = $this->guard()->user()->verificationcodes() ->where('code', $code)->where('active', false)->first(); // Generate a password for a new user with password reset link // FIXME: Should/can we have a user with no password set? if ($this->passCode && empty($user)) { $request->password = $request->password_confirmation = Str::random(16); $ignorePassword = true; } } if (empty($user) || !empty($request->password) || !empty($request->password_confirmation)) { if (empty($ignorePassword)) { $rules['password'] = ['required', 'confirmed', new Password($controller)]; } } $errors = []; // Validate input $v = Validator::make($request->all(), $rules); if ($v->fails()) { $errors = $v->errors()->toArray(); } // For new user validate email address if (empty($user)) { $email = $request->email; if (empty($email)) { $errors['email'] = \trans('validation.required', ['attribute' => 'email']); } elseif ($error = self::validateEmail($email, $controller, $this->deleteBeforeCreate)) { $errors['email'] = $error; } } // Validate aliases input if (isset($request->aliases)) { $aliases = []; $existing_aliases = $user ? $user->aliases()->get()->pluck('alias')->toArray() : []; foreach ($request->aliases as $idx => $alias) { if (is_string($alias) && !empty($alias)) { // Alias cannot be the same as the email address (new user) if (!empty($email) && Str::lower($alias) == Str::lower($email)) { continue; } // validate new aliases if ( !in_array($alias, $existing_aliases) && ($error = self::validateAlias($alias, $controller)) ) { if (!isset($errors['aliases'])) { $errors['aliases'] = []; } $errors['aliases'][$idx] = $error; continue; } $aliases[] = $alias; } } $request->aliases = $aliases; } if (!empty($errors)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } // Update user settings $settings = $request->only(array_keys($rules)); unset($settings['password'], $settings['aliases'], $settings['email']); return null; } /** * Execute (synchronously) specified step in a user setup process. * * @param \App\User $user User object * @param string $step Step identifier (as in self::statusInfo()) * * @return bool|null True if the execution succeeded, False if not, Null when * the job has been sent to the worker (result unknown) */ public static function execProcessStep(User $user, string $step): ?bool { try { if (strpos($step, 'domain-') === 0) { list ($local, $domain) = explode('@', $user->email); $domain = Domain::where('namespace', $domain)->first(); return DomainsController::execProcessStep($domain, $step); } switch ($step) { case 'user-ldap-ready': // User not in LDAP, create it $job = new \App\Jobs\User\CreateJob($user->id); $job->handle(); $user->refresh(); return $user->isLdapReady(); case 'user-imap-ready': // User not in IMAP? Verify again // Do it synchronously if the imap admin credentials are available // otherwise let the worker do the job if (!\config('imap.admin_password')) { \App\Jobs\User\VerifyJob::dispatch($user->id); return null; } $job = new \App\Jobs\User\VerifyJob($user->id); $job->handle(); $user->refresh(); return $user->isImapReady(); } } catch (\Exception $e) { \Log::error($e); } return false; } /** * Email address validation for use as a user mailbox (login). * * @param string $email Email address * @param \App\User $user The account owner * @param null|\App\User|\App\Group $deleted Filled with an instance of a deleted user or group * with the specified email address, if exists * * @return ?string Error message on validation error */ public static function validateEmail(string $email, \App\User $user, &$deleted = null): ?string { $deleted = null; if (strpos($email, '@') === false) { return \trans('validation.entryinvalid', ['attribute' => 'email']); } list($login, $domain) = explode('@', Str::lower($email)); if (strlen($login) === 0 || strlen($domain) === 0) { return \trans('validation.entryinvalid', ['attribute' => 'email']); } // Check if domain exists $domain = Domain::withObjectTenantContext($user)->where('namespace', $domain)->first(); if (empty($domain)) { return \trans('validation.domaininvalid'); } // Validate login part alone $v = Validator::make( ['email' => $login], ['email' => ['required', new UserEmailLocal(!$domain->isPublic())]] ); if ($v->fails()) { return $v->errors()->toArray()['email'][0]; } // Check if it is one of domains available to the user - if (!$user->domains()->where('namespace', $domain->namespace)->exists()) { + if (!$domain->isPublic() && $user->id != $domain->walletOwner()->id) { return \trans('validation.entryexists', ['attribute' => 'domain']); } - // Check if a user with specified address already exists - if ($existing_user = User::emailExists($email, true)) { - // 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 + // Check if a user/group/resource/shared folder with specified address already exists if ( - ($existing = Group::emailExists($email, true)) + ($existing = User::emailExists($email, true)) + || ($existing = \App\Group::emailExists($email, true)) || ($existing = \App\Resource::emailExists($email, true)) + || ($existing = \App\SharedFolder::emailExists($email, true)) ) { - // If this is a deleted group/resource in the same custom domain - // we'll force delete it before + // If this is a deleted user/group/resource/folder in the same custom domain + // we'll force delete it before creating the target user if (!$domain->isPublic() && $existing->trashed()) { $deleted = $existing; } else { return \trans('validation.entryexists', ['attribute' => 'email']); } } + // Check if an alias with specified address already exists. + if (User::aliasExists($email) || \App\SharedFolder::aliasExists($email)) { + return \trans('validation.entryexists', ['attribute' => 'email']); + } + return null; } /** * Email address validation for use as an alias. * * @param string $email Email address * @param \App\User $user The account owner * * @return ?string Error message on validation error */ public static function validateAlias(string $email, \App\User $user): ?string { if (strpos($email, '@') === false) { return \trans('validation.entryinvalid', ['attribute' => 'alias']); } list($login, $domain) = explode('@', Str::lower($email)); if (strlen($login) === 0 || strlen($domain) === 0) { return \trans('validation.entryinvalid', ['attribute' => 'alias']); } // Check if domain exists $domain = Domain::withObjectTenantContext($user)->where('namespace', $domain)->first(); if (empty($domain)) { return \trans('validation.domaininvalid'); } // Validate login part alone $v = Validator::make( ['alias' => $login], ['alias' => ['required', new UserEmailLocal(!$domain->isPublic())]] ); if ($v->fails()) { return $v->errors()->toArray()['alias'][0]; } // Check if it is one of domains available to the user - if (!$user->domains()->where('namespace', $domain->namespace)->exists()) { + if (!$domain->isPublic() && $user->id != $domain->walletOwner()->id) { return \trans('validation.entryexists', ['attribute' => 'domain']); } // Check if a user with specified address already exists if ($existing_user = User::emailExists($email, true)) { // Allow an alias in a custom domain to an address that was a user before if ($domain->isPublic() || !$existing_user->trashed()) { return \trans('validation.entryexists', ['attribute' => 'alias']); } } + // Check if a group/resource/shared folder with specified address already exists + if ( + \App\Group::emailExists($email) + || \App\Resource::emailExists($email) + || \App\SharedFolder::emailExists($email) + ) { + return \trans('validation.entryexists', ['attribute' => 'alias']); + } + // Check if an alias with specified address already exists - if (User::aliasExists($email)) { + if (User::aliasExists($email) || \App\SharedFolder::aliasExists($email)) { // Allow assigning the same alias to a user in the same group account, // but only for non-public domains if ($domain->isPublic()) { return \trans('validation.entryexists', ['attribute' => 'alias']); } } - // Check if a group with specified address already exists - if (Group::emailExists($email)) { - return \trans('validation.entryexists', ['attribute' => 'alias']); - } - return null; } /** * Activate password reset code (if set), and assign it to a user. * * @param \App\User $user The user */ protected function activatePassCode(User $user): void { // Activate the password reset code if ($this->passCode) { $this->passCode->user_id = $user->id; $this->passCode->active = true; $this->passCode->save(); } } } diff --git a/src/app/Http/Controllers/RelationController.php b/src/app/Http/Controllers/RelationController.php index d9173e4c..fc8b521e 100644 --- a/src/app/Http/Controllers/RelationController.php +++ b/src/app/Http/Controllers/RelationController.php @@ -1,350 +1,354 @@ model::find($id); if (!$this->checkTenant($resource)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canDelete($resource)) { return $this->errorResponse(403); } $resource->delete(); return response()->json([ 'status' => 'success', 'message' => \trans("app.{$this->label}-delete-success"), ]); } /** * Listing of resources belonging to the authenticated user. * * The resource entitlements billed to the current user wallet(s) * * @return \Illuminate\Http\JsonResponse */ public function index() { $user = $this->guard()->user(); $method = Str::plural(\lcfirst(\class_basename($this->model))); $query = call_user_func_array([$user, $method], $this->relationArgs); if (!empty($this->order)) { foreach ($this->order as $col) { $query->orderBy($col); } } $result = $query->get() ->map(function ($resource) { return $this->objectToClient($resource); }); return response()->json($result); } /** * Prepare resource statuses for the UI * * @param object $resource Resource object * * @return array Statuses array */ protected static function objectState($resource): array { $state = []; $reflect = new \ReflectionClass(get_class($resource)); foreach (array_keys($reflect->getConstants()) as $const) { if (strpos($const, 'STATUS_') === 0 && $const != 'STATUS_NEW') { $method = Str::camel('is_' . strtolower(substr($const, 7))); $state[$method] = $resource->{$method}(); } } if (empty($state['isDeleted']) && method_exists($resource, 'trashed')) { $state['isDeleted'] = $resource->trashed(); } return $state; } /** * Prepare a resource object for the UI. * * @param object $object An object * @param bool $full Include all object properties * * @return array Object information */ protected function objectToClient($object, bool $full = false): array { if ($full) { $result = $object->toArray(); } else { $result = ['id' => $object->id]; foreach ($this->objectProps as $prop) { $result[$prop] = $object->{$prop}; } } $result = array_merge($result, $this->objectState($object)); return $result; } /** * Object status' process information. * * @param object $object The object to process * @param array $steps The steps definition * * @return array Process state information */ protected static function processStateInfo($object, array $steps): array { $process = []; // Create a process check list foreach ($steps as $step_name => $state) { $step = [ 'label' => $step_name, 'title' => \trans("app.process-{$step_name}"), ]; if (is_array($state)) { $step['link'] = $state[1]; $state = $state[0]; } $step['state'] = $state; $process[] = $step; } // Add domain specific steps if (method_exists($object, 'domain')) { $domain = $object->domain(); // If that is not a public domain if ($domain && !$domain->isPublic()) { $domain_status = API\V4\DomainsController::statusInfo($domain); $process = array_merge($process, $domain_status['process']); } } $all = count($process); $checked = count(array_filter($process, function ($v) { return $v['state']; })); $state = $all === $checked ? 'done' : 'running'; // After 180 seconds assume the process is in failed state, // this should unlock the Refresh button in the UI if ($all !== $checked && $object->created_at->diffInSeconds(\Carbon\Carbon::now()) > 180) { $state = 'failed'; } return [ 'process' => $process, 'processState' => $state, 'isReady' => $all === $checked, ]; } /** * Object status' process information update. * * @param object $object The object to process * * @return array Process state information */ protected function processStateUpdate($object): array { $response = $this->statusInfo($object); if (!empty(request()->input('refresh'))) { $updated = false; $async = false; $last_step = 'none'; foreach ($response['process'] as $idx => $step) { $last_step = $step['label']; if (!$step['state']) { $exec = $this->execProcessStep($object, $step['label']); // @phpstan-ignore-line if (!$exec) { if ($exec === null) { $async = true; } break; } $updated = true; } } if ($updated) { $response = $this->statusInfo($object); } $success = $response['isReady']; $suffix = $success ? 'success' : 'error-' . $last_step; $response['status'] = $success ? 'success' : 'error'; $response['message'] = \trans('app.process-' . $suffix); if ($async && !$success) { $response['processState'] = 'waiting'; $response['status'] = 'success'; $response['message'] = \trans('app.process-async'); } } return $response; } /** * Set the resource configuration. * * @param int $id Resource identifier * * @return \Illuminate\Http\JsonResponse|void */ public function setConfig($id) { $resource = $this->model::find($id); if (!method_exists($this->model, 'setConfig')) { return $this->errorResponse(404); } if (!$this->checkTenant($resource)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canUpdate($resource)) { return $this->errorResponse(403); } $errors = $resource->setConfig(request()->input()); if (!empty($errors)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } return response()->json([ 'status' => 'success', 'message' => \trans("app.{$this->label}-setconfig-success"), ]); } /** * Display information of a resource specified by $id. * * @param string $id The resource to show information for. * * @return \Illuminate\Http\JsonResponse */ public function show($id) { $resource = $this->model::find($id); if (!$this->checkTenant($resource)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canRead($resource)) { return $this->errorResponse(403); } $response = $this->objectToClient($resource, true); if (!empty($statusInfo = $this->statusInfo($resource))) { $response['statusInfo'] = $statusInfo; } // Resource configuration, e.g. sender_policy, invitation_policy, acl if (method_exists($resource, 'getConfig')) { $response['config'] = $resource->getConfig(); } + if (method_exists($resource, 'aliases')) { + $response['aliases'] = $resource->aliases()->pluck('alias')->all(); + } + 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 = $this->model::find($id); if (!$this->checkTenant($resource)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canRead($resource)) { return $this->errorResponse(403); } $response = $this->processStateUpdate($resource); $response = array_merge($response, $this->objectState($resource)); return response()->json($response); } /** * Resource status (extended) information * * @param object $resource Resource object * * @return array Status information */ public static function statusInfo($resource): array { return []; } } diff --git a/src/app/Observers/ResourceObserver.php b/src/app/Observers/ResourceObserver.php index 83cdfece..b46f7d54 100644 --- a/src/app/Observers/ResourceObserver.php +++ b/src/app/Observers/ResourceObserver.php @@ -1,104 +1,92 @@ email)) { - if (!isset($resource->domain)) { - throw new \Exception("Missing 'domain' property for a new resource"); - } - - $domainName = \strtolower($resource->domain); - - $resource->email = "resource-{$resource->id}@{$domainName}"; - } else { - $resource->email = \strtolower($resource->email); - } - $resource->status |= Resource::STATUS_NEW | Resource::STATUS_ACTIVE; } /** * Handle the resource "created" event. * * @param \App\Resource $resource The resource * * @return void */ public function created(Resource $resource) { $domainName = explode('@', $resource->email, 2)[1]; $settings = [ 'folder' => "shared/Resources/{$resource->name}@{$domainName}", ]; foreach ($settings as $key => $value) { $settings[$key] = [ 'key' => $key, 'value' => $value, 'resource_id' => $resource->id, ]; } // Note: Don't use setSettings() here to bypass ResourceSetting observers // Note: This is a single multi-insert query $resource->settings()->insert(array_values($settings)); // Create resource record in LDAP, then check if it is created in IMAP $chain = [ new \App\Jobs\Resource\VerifyJob($resource->id), ]; \App\Jobs\Resource\CreateJob::withChain($chain)->dispatch($resource->id); } /** * Handle the resource "deleted" event. * * @param \App\Resource $resource The resource * * @return void */ public function deleted(Resource $resource) { if ($resource->isForceDeleting()) { return; } \App\Jobs\Resource\DeleteJob::dispatch($resource->id); } /** * Handle the resource "updated" event. * * @param \App\Resource $resource The resource * * @return void */ public function updated(Resource $resource) { \App\Jobs\Resource\UpdateJob::dispatch($resource->id); // Update the folder property if name changed if ($resource->name != $resource->getOriginal('name')) { $domainName = explode('@', $resource->email, 2)[1]; $folder = "shared/Resources/{$resource->name}@{$domainName}"; // Note: This does not invoke ResourceSetting observer events, good. $resource->settings()->where('key', 'folder')->update(['value' => $folder]); } } } diff --git a/src/app/Observers/SharedFolderAliasObserver.php b/src/app/Observers/SharedFolderAliasObserver.php new file mode 100644 index 00000000..2e863706 --- /dev/null +++ b/src/app/Observers/SharedFolderAliasObserver.php @@ -0,0 +1,82 @@ +alias = \strtolower($alias->alias); + + $domainName = explode('@', $alias->alias)[1]; + + $domain = Domain::where('namespace', $domainName)->first(); + + if (!$domain) { + \Log::error("Failed creating alias {$alias->alias}. Domain does not exist."); + return false; + } + + if ($alias->sharedFolder) { + if ($alias->sharedFolder->tenant_id != $domain->tenant_id) { + \Log::error("Reseller for folder '{$alias->sharedFolder->email}' and domain '{$domainName}' differ."); + return false; + } + } + + return true; + } + + /** + * Handle the shared folder alias "created" event. + * + * @param \App\SharedFolderAlias $alias Shared folder email alias + * + * @return void + */ + public function created(SharedFolderAlias $alias) + { + if ($alias->sharedFolder) { + \App\Jobs\SharedFolder\UpdateJob::dispatch($alias->shared_folder_id); + } + } + + /** + * Handle the shared folder alias "updated" event. + * + * @param \App\SharedFolderAlias $alias Shared folder email alias + * + * @return void + */ + public function updated(SharedFolderAlias $alias) + { + if ($alias->sharedFolder) { + \App\Jobs\SharedFolder\UpdateJob::dispatch($alias->shared_folder_id); + } + } + + /** + * Handle the shared folder alias "deleted" event. + * + * @param \App\SharedFolderAlias $alias Shared folder email alias + * + * @return void + */ + public function deleted(SharedFolderAlias $alias) + { + if ($alias->sharedFolder) { + \App\Jobs\SharedFolder\UpdateJob::dispatch($alias->shared_folder_id); + } + } +} diff --git a/src/app/Observers/SharedFolderObserver.php b/src/app/Observers/SharedFolderObserver.php index d8efa588..10af85e1 100644 --- a/src/app/Observers/SharedFolderObserver.php +++ b/src/app/Observers/SharedFolderObserver.php @@ -1,108 +1,96 @@ type)) { $folder->type = 'mail'; } - if (empty($folder->email)) { - if (!isset($folder->domain)) { - 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 "deleted" event. * * @param \App\SharedFolder $folder The folder * * @return void */ public function deleted(SharedFolder $folder) { if ($folder->isForceDeleting()) { return; } \App\Jobs\SharedFolder\DeleteJob::dispatch($folder->id); } /** * Handle the shared folder "updated" event. * * @param \App\SharedFolder $folder The folder * * @return void */ public function updated(SharedFolder $folder) { \App\Jobs\SharedFolder\UpdateJob::dispatch($folder->id); // Update the folder property if name changed if ($folder->name != $folder->getOriginal('name')) { $domainName = explode('@', $folder->email, 2)[1]; $folderName = "shared/{$folder->name}@{$domainName}"; // Note: This does not invoke SharedFolderSetting observer events, good. $folder->settings()->where('key', 'folder')->update(['value' => $folderName]); } } } diff --git a/src/app/Observers/UserAliasObserver.php b/src/app/Observers/UserAliasObserver.php index bd013cf6..54067d9d 100644 --- a/src/app/Observers/UserAliasObserver.php +++ b/src/app/Observers/UserAliasObserver.php @@ -1,93 +1,91 @@ alias = \strtolower($alias->alias); list($login, $domain) = explode('@', $alias->alias); $domain = Domain::where('namespace', $domain)->first(); if (!$domain) { \Log::error("Failed creating alias {$alias->alias}. Domain does not exist."); return false; } if ($alias->user) { if ($alias->user->tenant_id != $domain->tenant_id) { \Log::error("Reseller for user '{$alias->user->email}' and domain '{$domain->namespace}' differ."); return false; } } return true; } /** * Handle the user alias "created" event. * * @param \App\UserAlias $alias User email alias * * @return void */ public function created(UserAlias $alias) { if ($alias->user) { \App\Jobs\User\UpdateJob::dispatch($alias->user_id); if (Tenant::getConfig($alias->user->tenant_id, 'pgp.enable')) { \App\Jobs\PGP\KeyCreateJob::dispatch($alias->user_id, $alias->alias); } } } /** - * Handle the user setting "updated" event. + * Handle the user alias "updated" event. * * @param \App\UserAlias $alias User email alias * * @return void */ public function updated(UserAlias $alias) { if ($alias->user) { \App\Jobs\User\UpdateJob::dispatch($alias->user_id); } } /** - * Handle the user setting "deleted" event. + * Handle the user alias "deleted" event. * * @param \App\UserAlias $alias User email alias * * @return void */ public function deleted(UserAlias $alias) { if ($alias->user) { \App\Jobs\User\UpdateJob::dispatch($alias->user_id); if (Tenant::getConfig($alias->user->tenant_id, 'pgp.enable')) { \App\Jobs\PGP\KeyDeleteJob::dispatch($alias->user_id, $alias->alias); } } } } diff --git a/src/app/Providers/AppServiceProvider.php b/src/app/Providers/AppServiceProvider.php index ac784b42..3881435b 100644 --- a/src/app/Providers/AppServiceProvider.php +++ b/src/app/Providers/AppServiceProvider.php @@ -1,166 +1,167 @@ 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\SharedFolderAlias::observe(\App\Observers\SharedFolderAliasObserver::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 de893241..f7f9a630 100644 --- a/src/app/Resource.php +++ b/src/app/Resource.php @@ -1,97 +1,57 @@ 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; - } } diff --git a/src/app/SharedFolder.php b/src/app/SharedFolder.php index 5aa28943..573e5a30 100644 --- a/src/app/SharedFolder.php +++ b/src/app/SharedFolder.php @@ -1,117 +1,78 @@ 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 shared folder (including deleted folders). - * - * @param string $email Email address - * @param bool $return_folder Return SharedFolder instance instead of boolean - * - * @return \App\SharedFolder|bool True or Resource model object if found, False otherwise - */ - public static function emailExists(string $email, bool $return_folder = false) - { - if (strpos($email, '@') === false) { - return false; - } - - $email = \strtolower($email); - - $folder = self::withTrashed()->where('email', $email)->first(); - - if ($folder) { - return $return_folder ? $folder : true; - } - - return false; - } - /** * 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/SharedFolderAlias.php b/src/app/SharedFolderAlias.php new file mode 100644 index 00000000..146f51e6 --- /dev/null +++ b/src/app/SharedFolderAlias.php @@ -0,0 +1,39 @@ +attributes['alias'] = \strtolower($alias); + } + + /** + * The shared folder to which this alias belongs. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function sharedFolder() + { + return $this->belongsTo('\App\SharedFolder', 'shared_folder_id', 'id'); + } +} diff --git a/src/app/Traits/UserAliasesTrait.php b/src/app/Traits/AliasesTrait.php similarity index 72% rename from src/app/Traits/UserAliasesTrait.php rename to src/app/Traits/AliasesTrait.php index d2be2c6e..5731afa5 100644 --- a/src/app/Traits/UserAliasesTrait.php +++ b/src/app/Traits/AliasesTrait.php @@ -1,61 +1,72 @@ hasMany(static::class . 'Alias'); + } + /** * Find whether an email address exists as an alias - * (including aliases of deleted users). * * @param string $email Email address * * @return bool True if found, False otherwise */ public static function aliasExists(string $email): bool { if (strpos($email, '@') === false) { return false; } $email = \strtolower($email); + $class = static::class . 'Alias'; - $count = \App\UserAlias::where('alias', $email)->count(); - - return $count > 0; + return $class::where('alias', $email)->count() > 0; } /** - * A helper to update user aliases list. + * A helper to update object's aliases list. * * Example Usage: * * ```php * $user = User::firstOrCreate(['email' => 'some@other.org']); * $user->setAliases(['alias1@other.org', 'alias2@other.org']); * ``` * * @param array $aliases An array of email addresses * * @return void */ public function setAliases(array $aliases): void { $aliases = array_map('strtolower', $aliases); $aliases = array_unique($aliases); $existing_aliases = []; foreach ($this->aliases()->get() as $alias) { + /** @var \App\UserAlias|\App\SharedFolderAlias $alias */ if (!in_array($alias->alias, $aliases)) { $alias->delete(); } else { $existing_aliases[] = $alias->alias; } } foreach (array_diff($aliases, $existing_aliases) as $alias) { $this->aliases()->create(['alias' => $alias]); } } } diff --git a/src/app/Traits/EmailPropertyTrait.php b/src/app/Traits/EmailPropertyTrait.php new file mode 100644 index 00000000..ac5bf2cd --- /dev/null +++ b/src/app/Traits/EmailPropertyTrait.php @@ -0,0 +1,92 @@ +email) && defined('static::EMAIL_TEMPLATE')) { + $template = static::EMAIL_TEMPLATE; // @phpstan-ignore-line + $defaults = [ + 'type' => 'mail', + ]; + + foreach (['id', 'domainName', 'type'] as $prop) { + if (strpos($template, "{{$prop}}") === false) { + continue; + } + + $value = $model->{$prop} ?? ($defaults[$prop] ?? ''); + + if ($value === '' || $value === null) { + throw new \Exception("Missing '{$prop}' property for " . static::class); + } + + $template = str_replace("{{$prop}}", $value, $template); + } + + $model->email = strtolower($template); + } + }); + } + + /** + * Returns the object's domain (including soft-deleted). + * + * @return ?\App\Domain The domain to which the object belongs to, NULL if it does not exist + */ + public function domain(): ?\App\Domain + { + if (empty($this->email) && isset($this->domainName)) { + $domainName = $this->domainName; + } else { + list($local, $domainName) = explode('@', $this->email); + } + + return \App\Domain::withTrashed()->where('namespace', $domainName)->first(); + } + + /** + * Find whether an email address exists as a model object (including soft-deleted). + * + * @param string $email Email address + * @param bool $return_object Return model instance instead of a boolean + * + * @return object|bool True or Model object if found, False otherwise + */ + public static function emailExists(string $email, bool $return_object = false) + { + if (strpos($email, '@') === false) { + return false; + } + + $email = \strtolower($email); + + $object = static::withTrashed()->where('email', $email)->first(); + + if ($object) { + return $return_object ? $object : true; + } + + return false; + } + + /** + * Ensure the email is appropriately cased. + * + * @param string $email Email address + */ + public function setEmailAttribute(string $email): void + { + $this->attributes['email'] = strtolower($email); + } +} diff --git a/src/app/User.php b/src/app/User.php index 1b3a221e..a9428c74 100644 --- a/src/app/User.php +++ b/src/app/User.php @@ -1,758 +1,709 @@ 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; } return $user->assignPackageAndWallet($package, $this->wallets()->first()); } /** * 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; } /** * 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); } /** * Degrade the user * * @return void */ public function degrade(): void { if ($this->isDegraded()) { return; } $this->status |= User::STATUS_DEGRADED; $this->save(); } - /** - * 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 \Illuminate\Database\Eloquent\Builder Query builder */ public function domains($with_accounts = true, $with_public = true) { $domains = $this->entitleables(Domain::class, $with_accounts); if ($with_public) { $domains->orWhere(function ($query) { if (!$this->tenant_id) { $query->where('tenant_id', $this->tenant_id); } else { $query->withEnvTenantContext(); } $query->whereRaw(sprintf('(domains.type & %s)', Domain::TYPE_PUBLIC)) ->whereRaw(sprintf('(domains.status & %s)', Domain::STATUS_ACTIVE)); }); } 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; - } - /** * Return entitleable objects of a specified type controlled by the current user. * * @param string $class Object class * @param bool $with_accounts Include objects assigned to wallets * the current user controls, but not owns. * * @return \Illuminate\Database\Eloquent\Builder Query builder */ private function entitleables(string $class, bool $with_accounts = true) { $wallets = $this->wallets()->pluck('id')->all(); if ($with_accounts) { $wallets = array_merge($wallets, $this->accounts()->pluck('wallet_id')->all()); } $object = new $class(); $table = $object->getTable(); return $object->select("{$table}.*") ->whereExists(function ($query) use ($table, $wallets, $class) { $query->select(DB::raw(1)) ->from('entitlements') ->whereColumn('entitleable_id', "{$table}.id") ->whereIn('entitlements.wallet_id', $wallets) ->where('entitlements.entitleable_type', $class); }); } /** * 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(); + $aliases = \App\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) { return $this->entitleables(Group::class, $with_accounts); } /** * Returns whether this user (or its wallet owner) is degraded. * * @param bool $owner Check also the wallet owner instead just the user himself * * @return bool */ public function isDegraded(bool $owner = false): bool { if ($this->status & self::STATUS_DEGRADED) { return true; } if ($owner && ($wallet = $this->wallet())) { return $wallet->owner && $wallet->owner->isDegraded(); } return false; } /** * 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; } /** * Old passwords for this user. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function passwords() { return $this->hasMany('App\UserPassword'); } /** * 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) { return $this->entitleables(\App\Resource::class, $with_accounts); } /** * 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) { return $this->entitleables(\App\SharedFolder::class, $with_accounts); } 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; } /** * Un-degrade this user. * * @return void */ public function undegrade(): void { if (!$this->isDegraded()) { return; } $this->status ^= User::STATUS_DEGRADED; $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) { return $this->entitleables(User::class, $with_accounts); } /** * 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'] = Hash::make($password); $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, self::STATUS_DEGRADED, ]; 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/app/UserAlias.php b/src/app/UserAlias.php index 15ca4c39..46ce47b0 100644 --- a/src/app/UserAlias.php +++ b/src/app/UserAlias.php @@ -1,29 +1,39 @@ attributes['alias'] = \strtolower($alias); + } + /** * The user to which this alias belongs. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function user() { return $this->belongsTo('\App\User', 'user_id', 'id'); } } diff --git a/src/database/migrations/2022_01_25_100000_create_shared_folder_aliases_table.php b/src/database/migrations/2022_01_25_100000_create_shared_folder_aliases_table.php new file mode 100644 index 00000000..7ddc0c9b --- /dev/null +++ b/src/database/migrations/2022_01_25_100000_create_shared_folder_aliases_table.php @@ -0,0 +1,43 @@ +bigIncrements('id'); + $table->unsignedBigInteger('shared_folder_id'); + $table->string('alias'); + $table->timestamp('created_at')->useCurrent(); + $table->timestamp('updated_at')->useCurrent(); + + $table->unique(['alias', 'shared_folder_id']); + + $table->foreign('shared_folder_id')->references('id')->on('shared_folders') + ->onDelete('cascade')->onUpdate('cascade'); + } + ); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('shared_folder_aliases'); + } +} diff --git a/src/resources/js/app.js b/src/resources/js/app.js index f62ce3f1..427913d5 100644 --- a/src/resources/js/app.js +++ b/src/resources/js/app.js @@ -1,512 +1,512 @@ /** * 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 // Close the mobile menu if ($('#header-menu .navbar-collapse.show').length) { $('#header-menu .navbar-toggler').click(); } }) 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() const status = error.response ? error.response.status : 500 const message = error.response ? error.response.statusText : '' if (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(status, message) } }, 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') } }, isDegraded() { return store.state.authInfo && store.state.authInfo.isAccountDegraded }, 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' }, 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() }, statusClass(obj) { if (obj.isDeleted) { return 'text-muted' } if (obj.isDegraded || obj.isAccountDegraded || obj.isSuspended) { return 'text-warning' } if (obj.isImapReady === false || obj.isLdapReady === false || obj.isVerified === false || obj.isConfirmed === false) { return 'text-danger' } return 'text-success' }, statusText(obj) { if (obj.isDeleted) { return this.$t('status.deleted') } if (obj.isDegraded || obj.isAccountDegraded) { return this.$t('status.degraded') } if (obj.isSuspended) { return this.$t('status.suspended') } if (obj.isImapReady === false || obj.isLdapReady === false || obj.isVerified === false || obj.isConfirmed === false) { 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 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 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 (axios.isCancel(error) || 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() + form.find('.is-invalid:not(.list-input)').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/lang/en/ui.php b/src/resources/lang/en/ui.php index a2f95d2e..a251b94e 100644 --- a/src/resources/lang/en/ui.php +++ b/src/resources/lang/en/ui.php @@ -1,489 +1,491 @@ [ 'faq' => "FAQ", ], 'btn' => [ 'add' => "Add", 'accept' => "Accept", 'back' => "Back", 'cancel' => "Cancel", 'close' => "Close", 'continue' => "Continue", 'copy' => "Copy", '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", 'settings' => "Settings", '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", + 'emails' => "Email Addresses", '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-title' => "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' => [ + 'aliases-none' => "This shared folder has no email aliases.", '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", 'degraded' => "Degraded", '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.", 'degraded-warning' => "The account is degraded. Some features have been disabled.", 'degraded-hint' => "Please, make a payment.", '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", 'pass-input' => "Enter password", 'pass-link' => "Set via link", 'pass-link-label' => "Link:", 'passwordpolicy' => "Password Policy", '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/vue/Admin/SharedFolder.vue b/src/resources/vue/Admin/SharedFolder.vue index b18b0235..91e01b48 100644 --- a/src/resources/vue/Admin/SharedFolder.vue +++ b/src/resources/vue/Admin/SharedFolder.vue @@ -1,91 +1,119 @@ diff --git a/src/resources/vue/SharedFolder/Info.vue b/src/resources/vue/SharedFolder/Info.vue index 501ec9ac..737f5ff3 100644 --- a/src/resources/vue/SharedFolder/Info.vue +++ b/src/resources/vue/SharedFolder/Info.vue @@ -1,175 +1,181 @@ diff --git a/src/resources/vue/SharedFolder/List.vue b/src/resources/vue/SharedFolder/List.vue index b3d2753f..e6c8746d 100644 --- a/src/resources/vue/SharedFolder/List.vue +++ b/src/resources/vue/SharedFolder/List.vue @@ -1,61 +1,59 @@ diff --git a/src/tests/Browser/Admin/SharedFolderTest.php b/src/tests/Browser/Admin/SharedFolderTest.php index 7e1dcb93..e836c482 100644 --- a/src/tests/Browser/Admin/SharedFolderTest.php +++ b/src/tests/Browser/Admin/SharedFolderTest.php @@ -1,101 +1,109 @@ browse(function (Browser $browser) { $user = $this->getTestUser('john@kolab.org'); $folder = $this->getTestSharedFolder('folder-event@kolab.org'); $browser->visit('/shared-folder/' . $folder->id)->on(new Home()); }); } /** * Test shared folder info page */ public function testInfo(): void { Queue::fake(); $this->browse(function (Browser $browser) { $user = $this->getTestUser('john@kolab.org'); $folder = $this->getTestSharedFolder('folder-event@kolab.org'); $folder->setConfig(['acl' => ['anyone, read-only', 'jack@kolab.org, read-write']]); + $folder->setAliases(['folder-alias1@kolab.org', 'folder-alias2@kolab.org']); $folder->status = SharedFolder::STATUS_NEW | SharedFolder::STATUS_ACTIVE | SharedFolder::STATUS_LDAP_READY | SharedFolder::STATUS_IMAP_READY; $folder->save(); $folder_page = new SharedFolderPage($folder->id); $user_page = new UserPage($user->id); // 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-shared-folders') ->pause(1000) ->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) #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', $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') + ->assertElementsCount('ul.nav-tabs .nav-item', 2) + ->assertSeeIn('ul.nav-tabs .nav-item:nth-child(1) .nav-link', 'Settings') ->with('@folder-settings form', function (Browser $browser) { $browser->assertElementsCount('.row', 1) ->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'); + }) + ->assertSeeIn('ul.nav-tabs .nav-item:nth-child(2) .nav-link', 'Email Aliases (2)') + ->click('ul.nav-tabs .nav-item:nth-child(2) .nav-link') + ->with('@folder-aliases table', function (Browser $browser) { + $browser->assertElementsCount('tbody tr', 2) + ->assertSeeIn('tbody tr:nth-child(1) td', 'folder-alias1@kolab.org') + ->assertSeeIn('tbody tr:nth-child(2) td', 'folder-alias2@kolab.org'); }); // Test invalid shared folder identifier $browser->visit('/shared-folder/abc')->assertErrorPage(404); }); } } diff --git a/src/tests/Browser/Pages/Admin/SharedFolder.php b/src/tests/Browser/Pages/Admin/SharedFolder.php index 0985e5aa..9aed148a 100644 --- a/src/tests/Browser/Pages/Admin/SharedFolder.php +++ b/src/tests/Browser/Pages/Admin/SharedFolder.php @@ -1,57 +1,58 @@ 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', + '@folder-aliases' => '#folder-aliases', ]; } } diff --git a/src/tests/Browser/Reseller/SharedFolderTest.php b/src/tests/Browser/Reseller/SharedFolderTest.php index 2e0b8125..5d1bed71 100644 --- a/src/tests/Browser/Reseller/SharedFolderTest.php +++ b/src/tests/Browser/Reseller/SharedFolderTest.php @@ -1,101 +1,109 @@ browse(function (Browser $browser) { $user = $this->getTestUser('john@kolab.org'); $folder = $this->getTestSharedFolder('folder-event@kolab.org'); $browser->visit('/shared-folder/' . $folder->id)->on(new Home()); }); } /** * Test shared folder info page */ public function testInfo(): void { Queue::fake(); $this->browse(function (Browser $browser) { $user = $this->getTestUser('john@kolab.org'); $folder = $this->getTestSharedFolder('folder-event@kolab.org'); $folder->setConfig(['acl' => ['anyone, read-only', 'jack@kolab.org, read-write']]); + $folder->setAliases(['folder-alias1@kolab.org', 'folder-alias2@kolab.org']); $folder->status = SharedFolder::STATUS_NEW | SharedFolder::STATUS_ACTIVE | SharedFolder::STATUS_LDAP_READY | SharedFolder::STATUS_IMAP_READY; $folder->save(); $folder_page = new SharedFolderPage($folder->id); $user_page = new UserPage($user->id); // 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-shared-folders') ->pause(1000) ->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) #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', $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') + ->assertElementsCount('ul.nav-tabs .nav-item', 2) + ->assertSeeIn('ul.nav-tabs .nav-item:nth-child(1) .nav-link', 'Settings') ->with('@folder-settings form', function (Browser $browser) { $browser->assertElementsCount('.row', 1) ->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'); + }) + ->assertSeeIn('ul.nav-tabs .nav-item:nth-child(2) .nav-link', 'Email Aliases (2)') + ->click('ul.nav-tabs .nav-item:nth-child(2) .nav-link') + ->with('@folder-aliases table', function (Browser $browser) { + $browser->assertElementsCount('tbody tr', 2) + ->assertSeeIn('tbody tr:nth-child(1) td', 'folder-alias1@kolab.org') + ->assertSeeIn('tbody tr:nth-child(2) td', 'folder-alias2@kolab.org'); }); // Test invalid shared folder identifier $browser->visit('/shared-folder/abc')->assertErrorPage(404); }); } } diff --git a/src/tests/Browser/SharedFolderTest.php b/src/tests/Browser/SharedFolderTest.php index 31c5bf39..103ff02e 100644 --- a/src/tests/Browser/SharedFolderTest.php +++ b/src/tests/Browser/SharedFolderTest.php @@ -1,333 +1,397 @@ 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') + ->assertElementsCount('thead th', 2) ->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.shared-folder-new', 'Create folder') ->click('button.shared-folder-new') ->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('div.row:nth-child(4) label', 'Email Addresses') + ->with(new ListInput('#aliases'), function (Browser $browser) { + $browser->assertListInputValue([]) + ->assertValue('@input', ''); + }) ->assertSeeIn('button[type=submit]', 'Submit'); }) // Test error conditions ->type('#name', str_repeat('A', 192)) + ->select('#type', 'event') + ->assertMissing('#aliases') ->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 + // Test error handling on aliases input ->type('#name', 'Test Folder') + ->select('#type', 'mail') + ->with(new ListInput('#aliases'), function (Browser $browser) { + $browser->addListEntry('folder-alias@unknown'); + }) + ->click('@general button[type=submit]') + ->assertMissing('#name + .invalid-feedback') + ->waitFor('#aliases + .invalid-feedback') + ->with(new ListInput('#aliases'), function (Browser $browser) { + $browser->assertFormError(1, "The specified domain is invalid.", true); + }) + ->assertToast(Toast::TYPE_ERROR, 'Form validation error') + // Test successful folder creation ->select('#type', 'event') ->click('@general button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'Shared folder created successfully.') ->on(new SharedFolderList()) ->assertElementsCount('@table tbody tr', 3); + $this->assertSame(1, SharedFolder::where('name', 'Test Folder')->count()); + $this->assertSame(0, SharedFolder::where('name', 'Test Folder')->first()->aliases()->count()); + // 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$/' - ) + ->assertMissing('#aliases') ->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 creation/updating a mail folder with mail aliases + $this->browse(function (Browser $browser) { + $browser->on(new SharedFolderList()) + ->click('button.create-folder') + ->on(new SharedFolderInfo()) + ->type('#name', 'Test Folder2') + ->with(new ListInput('#aliases'), function (Browser $browser) { + $browser->addListEntry('folder-alias1@kolab.org') + ->addListEntry('folder-alias2@kolab.org'); + }) + ->click('@general button[type=submit]') + ->assertToast(Toast::TYPE_SUCCESS, 'Shared folder created successfully.') + ->on(new SharedFolderList()) + ->assertElementsCount('@table tbody tr', 3); + + $folder = SharedFolder::where('name', 'Test Folder2')->first(); + + $this->assertSame( + ['folder-alias1@kolab.org', 'folder-alias2@kolab.org'], + $folder->aliases()->pluck('alias')->all() + ); + + // Test folder update + $browser->click('@table tr:nth-child(3) td:first-child a') + ->on(new SharedFolderInfo()) + ->with('@general', function (Browser $browser) { + // Assert form content + $browser->assertFocused('#name') + ->assertValue('div.row:nth-child(2) input[type=text]', 'Test Folder2') + ->assertSelected('div.row:nth-child(3) select:disabled', 'mail') + ->with(new ListInput('#aliases'), function (Browser $browser) { + $browser->assertListInputValue(['folder-alias1@kolab.org', 'folder-alias2@kolab.org']) + ->assertValue('@input', ''); + }); + }) + // change folder name, and remove one alias + ->type('#name', 'Test Folder Update2') + ->with(new ListInput('#aliases'), function (Browser $browser) { + $browser->removeListEntry(2); + }) + ->click('@general button[type=submit]') + ->assertToast(Toast::TYPE_SUCCESS, 'Shared folder updated successfully.'); + + $folder->refresh(); + $this->assertSame('Test Folder Update2', $folder->name); + $this->assertSame(['folder-alias1@kolab.org'], $folder->aliases()->pluck('alias')->all()); + }); } /** * 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/Feature/Backends/LDAPTest.php b/src/tests/Feature/Backends/LDAPTest.php index 79fd532b..e9cdd45f 100644 --- a/src/tests/Feature/Backends/LDAPTest.php +++ b/src/tests/Feature/Backends/LDAPTest.php @@ -1,668 +1,672 @@ 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, 'acl' => 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'; $expected['acl'] = 'john@kolab.org, full'; $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"); } // Remove the invitation policy $resource->setSetting('invitation_policy', '[]'); LDAP::updateResource($resource); $expected['acl'] = null; $expected['kolabinvitationpolicy'] = null; $expected['owner'] = null; $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, + 'alias' => 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"]'); + $aliases = ['t1-' . $folder->email, 't2-' . $folder->email]; + $folder->setAliases($aliases); 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'; + $expected['alias'] = $aliases; $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); // Test degraded user $sku_storage = \App\Sku::withEnvTenantContext()->where('title', 'storage')->first(); $sku_2fa = \App\Sku::withEnvTenantContext()->where('title', '2fa')->first(); $user->status |= User::STATUS_DEGRADED; $user->update(['status' => $user->status]); $user->assignSku($sku_storage, 2); $user->assignSku($sku_2fa, 1); LDAP::updateUser($user->fresh()); $expected['inetuserstatus'] = $user->status; $expected['mailquota'] = \config('app.storage.min_qty') * 1048576; $expected['nsroledn'] = [ 'cn=2fa-user,' . \config('ldap.hosted.root_dn'), 'cn=degraded-user,' . \config('ldap.hosted.root_dn') ]; $ldap_user = LDAP::getUser($user->email); foreach ($expected as $attr => $value) { $this->assertEquals($value, isset($ldap_user[$attr]) ? $ldap_user[$attr] : null); } // TODO: Test user who's owner is degraded // 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' => 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/Console/Data/Import/LdifTest.php b/src/tests/Feature/Console/Data/Import/LdifTest.php index 7a29f91b..65e6073f 100644 --- a/src/tests/Feature/Console/Data/Import/LdifTest.php +++ b/src/tests/Feature/Console/Data/Import/LdifTest.php @@ -1,432 +1,439 @@ deleteTestUser('owner@kolab3.com'); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('owner@kolab3.com'); parent::tearDown(); } /** * Test the command */ public function testHandle(): void { $code = \Artisan::call("data:import:ldif tests/data/kolab3.ldif owner@kolab3.com"); $output = trim(\Artisan::output()); $this->assertSame(1, $code); $this->assertStringNotContainsString("Importing", $output); $this->assertStringNotContainsString("WARNING", $output); $this->assertStringContainsString( "ERROR cn=error,ou=groups,ou=kolab3.com,dc=hosted,dc=com: Missing 'mail' attribute", $output ); $this->assertStringContainsString( "ERROR cn=error,ou=resources,ou=kolab3.com,dc=hosted,dc=com: Missing 'mail' attribute", $output ); $code = \Artisan::call("data:import:ldif tests/data/kolab3.ldif owner@kolab3.com --force"); $output = trim(\Artisan::output()); $this->assertSame(0, $code); $this->assertStringContainsString("Importing domains... DONE", $output); $this->assertStringContainsString("Importing users... DONE", $output); $this->assertStringContainsString("Importing resources... DONE", $output); $this->assertStringContainsString("Importing shared folders... DONE", $output); $this->assertStringContainsString("Importing groups... DONE", $output); $this->assertStringNotContainsString("ERROR", $output); $this->assertStringContainsString( "WARNING cn=unknowndomain,ou=groups,ou=kolab3.org,dc=hosted,dc=com: Domain not found", $output ); $owner = \App\User::where('email', 'owner@kolab3.com')->first(); $this->assertNull($owner->password); $this->assertSame( '{SSHA512}g74+SECTLsM1x0aYkSrTG9sOFzEp8wjCflhshr2DjE7mi1G3iNb4ClH3ljorPRlTgZ105PsQGEpNtNr+XRjigg==', $owner->password_ldap ); // User settings $this->assertSame('Aleksander', $owner->getSetting('first_name')); $this->assertSame('Machniak', $owner->getSetting('last_name')); $this->assertSame('123456789', $owner->getSetting('phone')); $this->assertSame('external@gmail.com', $owner->getSetting('external_email')); $this->assertSame('Organization AG', $owner->getSetting('organization')); // User aliases $aliases = $owner->aliases()->orderBy('alias')->pluck('alias')->all(); $this->assertSame(['alias@kolab3-alias.com', 'alias@kolab3.com'], $aliases); // Wallet, entitlements $wallet = $owner->wallets->first(); $this->assertEntitlements($owner, [ 'groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', ]); // Users $this->assertSame(2, $owner->users(false)->count()); $user = $owner->users(false)->where('email', 'user@kolab3.com')->first(); // User settings $this->assertSame('Jane', $user->getSetting('first_name')); $this->assertSame('Doe', $user->getSetting('last_name')); $this->assertSame('1234567890', $user->getSetting('phone')); $this->assertSame('ext@gmail.com', $user->getSetting('external_email')); $this->assertSame('Org AG', $user->getSetting('organization')); // User aliases $aliases = $user->aliases()->orderBy('alias')->pluck('alias')->all(); $this->assertSame(['alias2@kolab3.com'], $aliases); $this->assertEntitlements($user, [ 'groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage', ]); // Domains $domains = $owner->domains(false, false)->orderBy('namespace')->get(); $this->assertCount(2, $domains); $this->assertSame('kolab3-alias.com', $domains[0]->namespace); $this->assertSame('kolab3.com', $domains[1]->namespace); $this->assertSame(\App\Domain::TYPE_EXTERNAL, $domains[0]->type); $this->assertSame(\App\Domain::TYPE_EXTERNAL, $domains[1]->type); $this->assertEntitlements($domains[0], ['domain-hosting']); $this->assertEntitlements($domains[1], ['domain-hosting']); // Shared folders $folders = $owner->sharedFolders(false)->orderBy('email')->get(); $this->assertCount(2, $folders); $this->assertMatchesRegularExpression('/^event-[0-9]+@kolab3\.com$/', $folders[0]->email); $this->assertMatchesRegularExpression('/^mail-[0-9]+@kolab3\.com$/', $folders[1]->email); $this->assertSame('Folder2', $folders[0]->name); $this->assertSame('Folder1', $folders[1]->name); $this->assertSame('event', $folders[0]->type); $this->assertSame('mail', $folders[1]->type); $this->assertSame('["anyone, read-only"]', $folders[0]->getSetting('acl')); $this->assertSame('shared/Folder2@kolab3.com', $folders[0]->getSetting('folder')); $this->assertSame('["anyone, read-write","owner@kolab3.com, full"]', $folders[1]->getSetting('acl')); $this->assertSame('shared/Folder1@kolab3.com', $folders[1]->getSetting('folder')); + $this->assertSame([], $folders[0]->aliases()->orderBy('alias')->pluck('alias')->all()); + $this->assertSame( + ['folder-alias1@kolab3.com', 'folder-alias2@kolab3.com'], + $folders[1]->aliases()->orderBy('alias')->pluck('alias')->all() + ); // Groups $groups = $owner->groups(false)->orderBy('email')->get(); $this->assertCount(1, $groups); $this->assertSame('Group', $groups[0]->name); $this->assertSame('group@kolab3.com', $groups[0]->email); $this->assertSame(['owner@kolab3.com', 'user@kolab3.com'], $groups[0]->members); $this->assertSame('["sender@gmail.com","-"]', $groups[0]->getSetting('sender_policy')); // Resources $resources = $owner->resources(false)->orderBy('email')->get(); $this->assertCount(1, $resources); $this->assertSame('Resource', $resources[0]->name); $this->assertMatchesRegularExpression('/^resource-[0-9]+@kolab3\.com$/', $resources[0]->email); $this->assertSame('shared/Resource@kolab3.com', $resources[0]->getSetting('folder')); $this->assertSame('manual:user@kolab3.com', $resources[0]->getSetting('invitation_policy')); } /** * Test parseACL() method */ public function testParseACL(): void { $command = new \App\Console\Commands\Data\Import\LdifCommand(); $result = $this->invokeMethod($command, 'parseACL', [[]]); $this->assertSame([], $result); $acl = [ 'anyone, read-write', 'read-only@kolab3.com, read-only', 'read-only@kolab3.com, read', 'full@kolab3.com,full', 'lrswipkxtecdn@kolab3.com, lrswipkxtecdn', // full 'lrs@kolab3.com, lrs', // read-only 'lrswitedn@kolab3.com, lrswitedn', // read-write // unsupported: 'anonymous, read-only', 'group:test, lrs', 'test@kolab3.com, lrspkxtdn', ]; $expected = [ 'anyone, read-write', 'read-only@kolab3.com, read-only', 'read-only@kolab3.com, read-only', 'full@kolab3.com, full', 'lrswipkxtecdn@kolab3.com, full', 'lrs@kolab3.com, read-only', 'lrswitedn@kolab3.com, read-write', ]; $result = $this->invokeMethod($command, 'parseACL', [$acl]); $this->assertSame($expected, $result); } /** * Test parseInvitationPolicy() method */ public function testParseInvitationPolicy(): void { $command = new \App\Console\Commands\Data\Import\LdifCommand(); $result = $this->invokeMethod($command, 'parseInvitationPolicy', [[]]); $this->assertSame(null, $result); $result = $this->invokeMethod($command, 'parseInvitationPolicy', [['UNKNOWN']]); $this->assertSame(null, $result); $result = $this->invokeMethod($command, 'parseInvitationPolicy', [['ACT_ACCEPT']]); $this->assertSame(null, $result); $result = $this->invokeMethod($command, 'parseInvitationPolicy', [['ACT_MANUAL']]); $this->assertSame('manual', $result); $result = $this->invokeMethod($command, 'parseInvitationPolicy', [['ACT_REJECT']]); $this->assertSame('reject', $result); $result = $this->invokeMethod($command, 'parseInvitationPolicy', [['ACT_ACCEPT_AND_NOTIFY', 'ACT_REJECT']]); $this->assertSame(null, $result); } /** * Test parseSenderPolicy() method */ public function testParseSenderPolicy(): void { $command = new \App\Console\Commands\Data\Import\LdifCommand(); $result = $this->invokeMethod($command, 'parseSenderPolicy', [[]]); $this->assertSame([], $result); $result = $this->invokeMethod($command, 'parseSenderPolicy', [['test']]); $this->assertSame(['test', '-'], $result); $result = $this->invokeMethod($command, 'parseSenderPolicy', [['test', '-test2', 'test3', '']]); $this->assertSame(['test', 'test3', '-'], $result); } /** * Test parseLDAPDomain() method */ public function testParseLDAPDomain(): void { $command = new \App\Console\Commands\Data\Import\LdifCommand(); $entry = []; $result = $this->invokeMethod($command, 'parseLDAPDomain', [$entry]); $this->assertSame([], $result[0]); $this->assertSame("Missing 'associatedDomain' attribute", $result[1]); $entry = ['associateddomain' => 'test.com']; $result = $this->invokeMethod($command, 'parseLDAPDomain', [$entry]); $this->assertSame(['namespace' => 'test.com'], $result[0]); $this->assertSame(null, $result[1]); $entry = ['associateddomain' => 'test.com', 'inetdomainstatus' => 'deleted']; $result = $this->invokeMethod($command, 'parseLDAPDomain', [$entry]); $this->assertSame([], $result[0]); $this->assertSame("Domain deleted", $result[1]); } /** * Test parseLDAPGroup() method */ public function testParseLDAPGroup(): void { $command = new \App\Console\Commands\Data\Import\LdifCommand(); $entry = []; $result = $this->invokeMethod($command, 'parseLDAPGroup', [$entry]); $this->assertSame([], $result[0]); $this->assertSame("Missing 'cn' attribute", $result[1]); $entry = ['cn' => 'Test']; $result = $this->invokeMethod($command, 'parseLDAPGroup', [$entry]); $this->assertSame([], $result[0]); $this->assertSame("Missing 'mail' attribute", $result[1]); $entry = ['cn' => 'Test', 'mail' => 'test@domain.tld']; $result = $this->invokeMethod($command, 'parseLDAPGroup', [$entry]); $this->assertSame([], $result[0]); $this->assertSame("Missing 'uniqueMember' attribute", $result[1]); $entry = [ 'cn' => 'Test', 'mail' => 'Test@domain.tld', 'uniquemember' => 'uid=user@kolab3.com,ou=People,ou=kolab3.com,dc=hosted,dc=com', 'kolaballowsmtpsender' => ['sender1@gmail.com', 'sender2@gmail.com'], ]; $expected = [ 'name' => 'Test', 'email' => 'test@domain.tld', 'members' => ['uid=user@kolab3.com,ou=People,ou=kolab3.com,dc=hosted,dc=com'], 'sender_policy' => ['sender1@gmail.com', 'sender2@gmail.com', '-'], ]; $result = $this->invokeMethod($command, 'parseLDAPGroup', [$entry]); $this->assertSame($expected, $result[0]); $this->assertSame(null, $result[1]); } /** * Test parseLDAPResource() method */ public function testParseLDAPResource(): void { $command = new \App\Console\Commands\Data\Import\LdifCommand(); $entry = []; $result = $this->invokeMethod($command, 'parseLDAPResource', [$entry]); $this->assertSame([], $result[0]); $this->assertSame("Missing 'cn' attribute", $result[1]); $entry = ['cn' => 'Test']; $result = $this->invokeMethod($command, 'parseLDAPResource', [$entry]); $this->assertSame([], $result[0]); $this->assertSame("Missing 'mail' attribute", $result[1]); $entry = [ 'cn' => 'Test', 'mail' => 'Test@domain.tld', 'owner' => 'uid=user@kolab3.com,ou=People,ou=kolab3.com,dc=hosted,dc=com', 'kolabtargetfolder' => 'Folder', 'kolabinvitationpolicy' => 'ACT_REJECT' ]; $expected = [ 'name' => 'Test', 'email' => 'test@domain.tld', 'folder' => 'Folder', 'owner' => 'uid=user@kolab3.com,ou=People,ou=kolab3.com,dc=hosted,dc=com', 'invitation_policy' => 'reject', ]; $result = $this->invokeMethod($command, 'parseLDAPResource', [$entry]); $this->assertSame($expected, $result[0]); $this->assertSame(null, $result[1]); } /** * Test parseLDAPSharedFolder() method */ public function testParseLDAPSharedFolder(): void { $command = new \App\Console\Commands\Data\Import\LdifCommand(); $entry = []; $result = $this->invokeMethod($command, 'parseLDAPSharedFolder', [$entry]); $this->assertSame([], $result[0]); $this->assertSame("Missing 'cn' attribute", $result[1]); $entry = ['cn' => 'Test']; $result = $this->invokeMethod($command, 'parseLDAPSharedFolder', [$entry]); $this->assertSame([], $result[0]); $this->assertSame("Missing 'mail' attribute", $result[1]); $entry = [ 'cn' => 'Test', 'mail' => 'Test@domain.tld', 'kolabtargetfolder' => 'Folder', 'kolabfoldertype' => 'event', 'acl' => 'anyone, read-write', + 'alias' => ['test1@domain.tld', 'test2@domain.tld'], ]; $expected = [ 'name' => 'Test', 'email' => 'test@domain.tld', 'type' => 'event', 'folder' => 'Folder', 'acl' => ['anyone, read-write'], + 'aliases' => ['test1@domain.tld', 'test2@domain.tld'], ]; $result = $this->invokeMethod($command, 'parseLDAPSharedFolder', [$entry]); $this->assertSame($expected, $result[0]); $this->assertSame(null, $result[1]); } /** * Test parseLDAPUser() method */ public function testParseLDAPUser(): void { // Note: If we do not initialize the command input we'll get an error $args = [ 'file' => 'test.ldif', 'owner' => 'test@domain.tld', ]; $command = new \App\Console\Commands\Data\Import\LdifCommand(); $command->setInput(new \Symfony\Component\Console\Input\ArrayInput($args, $command->getDefinition())); $entry = ['cn' => 'Test']; $result = $this->invokeMethod($command, 'parseLDAPUser', [$entry]); $this->assertSame([], $result[0]); $this->assertSame("Missing 'mail' attribute", $result[1]); $entry = [ 'dn' => 'user dn', 'givenname' => 'Given', 'mail' => 'Test@domain.tld', 'sn' => 'Surname', 'telephonenumber' => '123', 'o' => 'Org', 'mailalternateaddress' => 'test@ext.com', 'alias' => ['test1@domain.tld', 'test2@domain.tld'], 'userpassword' => 'pass', 'mailquota' => '12345678', ]; $expected = [ 'email' => 'test@domain.tld', 'settings' => [ 'first_name' => 'Given', 'last_name' => 'Surname', 'phone' => '123', 'external_email' => 'test@ext.com', 'organization' => 'Org', ], 'aliases' => ['test1@domain.tld', 'test2@domain.tld'], 'password' => 'pass', 'quota' => '12345678', ]; $result = $this->invokeMethod($command, 'parseLDAPUser', [$entry]); $this->assertSame($expected, $result[0]); $this->assertSame(null, $result[1]); $this->assertSame($entry['dn'], $this->getObjectProperty($command, 'ownerDN')); } } diff --git a/src/tests/Feature/Console/SharedFolder/AddAliasTest.php b/src/tests/Feature/Console/SharedFolder/AddAliasTest.php new file mode 100644 index 00000000..eaf5254d --- /dev/null +++ b/src/tests/Feature/Console/SharedFolder/AddAliasTest.php @@ -0,0 +1,90 @@ +deleteTestSharedFolder('folder-test@force-delete.com'); + $this->deleteTestUser('user@force-delete.com'); + $this->deleteTestDomain('force-delete.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestSharedFolder('folder-test@force-delete.com'); + $this->deleteTestUser('user@force-delete.com'); + $this->deleteTestDomain('force-delete.com'); + + parent::tearDown(); + } + + /** + * Test the command + */ + public function testHandle(): void + { + // Non-existing folder + $code = \Artisan::call("sharedfolder:add-alias unknown unknown"); + $output = trim(\Artisan::output()); + + $this->assertSame(1, $code); + $this->assertSame("Folder not found.", $output); + + $user = $this->getTestUser('user@force-delete.com'); + $domain = $this->getTestDomain('force-delete.com', [ + 'status' => \App\Domain::STATUS_NEW, + 'type' => \App\Domain::TYPE_EXTERNAL, + ]); + $package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); + $package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); + $user->assignPackage($package_kolab); + $domain->assignPackage($package_domain, $user); + $folder = $this->getTestSharedFolder('folder-test@force-delete.com'); + $folder->assignToWallet($user->wallets->first()); + + // Invalid alias + $code = \Artisan::call("sharedfolder:add-alias {$folder->email} invalid"); + $output = trim(\Artisan::output()); + + $this->assertSame(1, $code); + $this->assertSame("The specified alias is invalid.", $output); + $this->assertSame([], $folder->aliases()->pluck('alias')->all()); + + // A domain of another user + $code = \Artisan::call("sharedfolder:add-alias {$folder->email} test@kolab.org"); + $output = trim(\Artisan::output()); + + $this->assertSame(1, $code); + $this->assertSame("The specified domain is not available.", $output); + $this->assertSame([], $folder->aliases()->pluck('alias')->all()); + + // Test success + $code = \Artisan::call("sharedfolder:add-alias {$folder->email} test@force-delete.com"); + $output = trim(\Artisan::output()); + + $this->assertSame(0, $code); + $this->assertSame("", $output); + $this->assertSame(['test@force-delete.com'], $folder->aliases()->pluck('alias')->all()); + + // Alias already exists + $code = \Artisan::call("sharedfolder:add-alias {$folder->email} test@force-delete.com"); + $output = trim(\Artisan::output()); + + $this->assertSame(1, $code); + $this->assertSame("Address is already assigned to the folder.", $output); + + // TODO: test --force option + } +} diff --git a/src/tests/Feature/Controller/Admin/UsersTest.php b/src/tests/Feature/Controller/Admin/UsersTest.php index 02159f94..b5f70919 100644 --- a/src/tests/Feature/Controller/Admin/UsersTest.php +++ b/src/tests/Feature/Controller/Admin/UsersTest.php @@ -1,512 +1,527 @@ 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); + \App\SharedFolderAlias::truncate(); + 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']); + // Search by shared folder alias + $folder = $this->getTestSharedFolder('folder-event@kolab.org'); + $folder->setAliases(['folder-alias@kolab.org']); + $response = $this->actingAs($admin)->get("api/v4/users?search=folder-alias@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/AuthTest.php b/src/tests/Feature/Controller/AuthTest.php index ac6a20f2..3ee29b78 100644 --- a/src/tests/Feature/Controller/AuthTest.php +++ b/src/tests/Feature/Controller/AuthTest.php @@ -1,242 +1,239 @@ app['auth']->guard($guard); if ($guard instanceof \Illuminate\Auth\SessionGuard) { $guard->logout(); } } $protectedProperty = new \ReflectionProperty($this->app['auth'], 'guards'); $protectedProperty->setAccessible(true); $protectedProperty->setValue($this->app['auth'], []); } /** * {@inheritDoc} */ public function setUp(): void { parent::setUp(); $this->deleteTestUser('UsersControllerTest1@userscontroller.com'); $this->deleteTestDomain('userscontroller.com'); $this->expectedExpiry = \config('auth.token_expiry_minutes') * 60; } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('UsersControllerTest1@userscontroller.com'); $this->deleteTestDomain('userscontroller.com'); parent::tearDown(); } /** * Test fetching current user info (/api/auth/info) */ public function testInfo(): void { $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $domain = $this->getTestDomain('userscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_PUBLIC, ]); $response = $this->actingAs($user)->get("api/auth/info"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals($user->id, $json['id']); $this->assertEquals($user->email, $json['email']); $this->assertEquals(User::STATUS_NEW | User::STATUS_ACTIVE, $json['status']); $this->assertTrue(is_array($json['statusInfo'])); $this->assertTrue(is_array($json['settings'])); - $this->assertTrue(is_array($json['aliases'])); $this->assertTrue(!isset($json['access_token'])); // Note: Details of the content are tested in testUserResponse() // Test token refresh via the info request // First we log in to get the refresh token $post = ['email' => 'john@kolab.org', 'password' => 'simple123']; $user = $this->getTestUser('john@kolab.org'); $response = $this->post("api/auth/login", $post); $json = $response->json(); $response = $this->actingAs($user) ->post("api/auth/info?refresh=1", ['refresh_token' => $json['refresh_token']]); $response->assertStatus(200); $json = $response->json(); $this->assertEquals('john@kolab.org', $json['email']); $this->assertTrue(is_array($json['statusInfo'])); $this->assertTrue(is_array($json['settings'])); - $this->assertTrue(is_array($json['aliases'])); $this->assertTrue(!empty($json['access_token'])); $this->assertTrue(!empty($json['expires_in'])); } /** * Test /api/auth/login */ public function testLogin(): string { // Request with no data $response = $this->post("api/auth/login", []); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertArrayHasKey('email', $json['errors']); $this->assertArrayHasKey('password', $json['errors']); // Request with invalid password $post = ['email' => 'john@kolab.org', 'password' => 'wrong']; $response = $this->post("api/auth/login", $post); $response->assertStatus(401); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame('Invalid username or password.', $json['message']); // Valid user+password $user = $this->getTestUser('john@kolab.org'); $post = ['email' => 'john@kolab.org', 'password' => 'simple123']; $response = $this->post("api/auth/login", $post); $json = $response->json(); $response->assertStatus(200); $this->assertTrue(!empty($json['access_token'])); $this->assertTrue( ($this->expectedExpiry - 5) < $json['expires_in'] && $json['expires_in'] < ($this->expectedExpiry + 5) ); $this->assertEquals('bearer', $json['token_type']); $this->assertEquals($user->id, $json['id']); $this->assertEquals($user->email, $json['email']); $this->assertTrue(is_array($json['statusInfo'])); $this->assertTrue(is_array($json['settings'])); - $this->assertTrue(is_array($json['aliases'])); // Valid user+password (upper-case) $post = ['email' => 'John@Kolab.org', 'password' => 'simple123']; $response = $this->post("api/auth/login", $post); $json = $response->json(); $response->assertStatus(200); $this->assertTrue(!empty($json['access_token'])); $this->assertTrue( ($this->expectedExpiry - 5) < $json['expires_in'] && $json['expires_in'] < ($this->expectedExpiry + 5) ); $this->assertEquals('bearer', $json['token_type']); // TODO: We have browser tests for 2FA but we should probably also test it here return $json['access_token']; } /** * Test /api/auth/logout * * @depends testLogin */ public function testLogout($token): void { // Request with no token, testing that it requires auth $response = $this->post("api/auth/logout"); $response->assertStatus(401); // Test the same using JSON mode $response = $this->json('POST', "api/auth/logout", []); $response->assertStatus(401); // Request with invalid token $response = $this->withHeaders(['Authorization' => 'Bearer ' . "foobar"])->post("api/auth/logout"); $response->assertStatus(401); // Request with valid token $response = $this->withHeaders(['Authorization' => 'Bearer ' . $token])->post("api/auth/logout"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals('success', $json['status']); $this->assertEquals('Successfully logged out.', $json['message']); $this->resetAuth(); // Check if it really destroyed the token? $response = $this->withHeaders(['Authorization' => 'Bearer ' . $token])->get("api/auth/info"); $response->assertStatus(401); } /** * Test /api/auth/refresh */ public function testRefresh(): void { // Request with no token, testing that it requires auth $response = $this->post("api/auth/refresh"); $response->assertStatus(401); // Test the same using JSON mode $response = $this->json('POST', "api/auth/refresh", []); $response->assertStatus(401); // Login the user to get a valid token $post = ['email' => 'john@kolab.org', 'password' => 'simple123']; $response = $this->post("api/auth/login", $post); $response->assertStatus(200); $json = $response->json(); $token = $json['access_token']; $user = $this->getTestUser('john@kolab.org'); // Request with a valid token $response = $this->actingAs($user)->post("api/auth/refresh", ['refresh_token' => $json['refresh_token']]); $response->assertStatus(200); $json = $response->json(); $this->assertTrue(!empty($json['access_token'])); $this->assertTrue($json['access_token'] != $token); $this->assertTrue( ($this->expectedExpiry - 5) < $json['expires_in'] && $json['expires_in'] < ($this->expectedExpiry + 5) ); $this->assertEquals('bearer', $json['token_type']); $new_token = $json['access_token']; // TODO: Shall we invalidate the old token? // And if the new token is working $response = $this->withHeaders(['Authorization' => 'Bearer ' . $new_token])->get("api/auth/info"); $response->assertStatus(200); } } diff --git a/src/tests/Feature/Controller/Reseller/UsersTest.php b/src/tests/Feature/Controller/Reseller/UsersTest.php index 3172c33d..3a583692 100644 --- a/src/tests/Feature/Controller/Reseller/UsersTest.php +++ b/src/tests/Feature/Controller/Reseller/UsersTest.php @@ -1,516 +1,542 @@ deleteTestUser('UsersControllerTest1@userscontroller.com'); $this->deleteTestUser('test@testsearch.com'); $this->deleteTestDomain('testsearch.com'); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('UsersControllerTest1@userscontroller.com'); $this->deleteTestUser('test@testsearch.com'); $this->deleteTestDomain('testsearch.com'); + \App\SharedFolderAlias::truncate(); + parent::tearDown(); } /** * Test user deleting (DELETE /api/v4/users/) */ public function testDestroy(): void { $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); // Test unauth access $response = $this->delete("api/v4/users/{$user->id}"); $response->assertStatus(401); // The end-point does not exist $response = $this->actingAs($reseller1)->delete("api/v4/users/{$user->id}"); $response->assertStatus(404); } /** * Test users searching (/api/v4/users) */ public function testIndex(): void { Queue::fake(); $user = $this->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'); // Guess access $response = $this->get("api/v4/users"); $response->assertStatus(401); // Normal user $response = $this->actingAs($user)->get("api/v4/users"); $response->assertStatus(403); // Admin user $response = $this->actingAs($admin)->get("api/v4/users"); $response->assertStatus(403); // Search with no search criteria $response = $this->actingAs($reseller2)->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($reseller2)->get("api/v4/users?search=abcd1234efgh5678"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertSame([], $json['list']); // Search by domain in another tenant $response = $this->actingAs($reseller2)->get("api/v4/users?search=kolab.org"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertSame([], $json['list']); // Search by user ID in another tenant $response = $this->actingAs($reseller2)->get("api/v4/users?search={$user->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertSame([], $json['list']); // Search by email (primary) - existing user in another tenant $response = $this->actingAs($reseller2)->get("api/v4/users?search=john@kolab.org"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertSame([], $json['list']); // Search by owner - existing user in another tenant $response = $this->actingAs($reseller2)->get("api/v4/users?owner={$user->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertSame([], $json['list']); + // Search by shared folder email + $response = $this->actingAs($reseller1)->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']); + + // Search by shared folder alias + $folder = $this->getTestSharedFolder('folder-event@kolab.org'); + $folder->setAliases(['folder-alias@kolab.org']); + $response = $this->actingAs($reseller1)->get("api/v4/users?search=folder-alias@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']); + // Create a domain with some users in the Sample Tenant so we have anything to search for $domain = $this->getTestDomain('testsearch.com', ['type' => \App\Domain::TYPE_EXTERNAL]); $domain->tenant_id = $reseller2->tenant_id; $domain->save(); $user = $this->getTestUser('test@testsearch.com'); $user->tenant_id = $reseller2->tenant_id; $user->save(); $plan = \App\Plan::where('title', 'group')->first(); $user->assignPlan($plan, $domain); $user->setAliases(['alias@testsearch.com']); $user->setSetting('external_email', 'john.doe.external@gmail.com'); // Search by domain $response = $this->actingAs($reseller2)->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']); // Search by user ID $response = $this->actingAs($reseller2)->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) - existing user in reseller's tenant $response = $this->actingAs($reseller2)->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']); // Search by email (alias) $response = $this->actingAs($reseller2)->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']); // Search by email (external), there are two users with this email, but only one // in the reseller's tenant $response = $this->actingAs($reseller2)->get("api/v4/users?search=john.doe.external@gmail.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']); // Search by owner $response = $this->actingAs($reseller2)->get("api/v4/users?owner={$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']); // Deleted users/domains $user->delete(); $response = $this->actingAs($reseller2)->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($reseller2)->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($reseller2)->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']); } /** * Test reseting 2FA (POST /api/v4/users//reset2FA) */ public function testReset2FA(): void { Queue::fake(); // disable jobs $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local'); $sku2fa = \App\Sku::withEnvTenantContext()->where('title', '2fa')->first(); $user->assignSku($sku2fa); \App\Auth\SecondFactor::seed('userscontrollertest1@userscontroller.com'); // Test unauthorized access $response = $this->actingAs($user)->post("/api/v4/users/{$user->id}/reset2FA", []); $response->assertStatus(403); $response = $this->actingAs($admin)->post("/api/v4/users/{$user->id}/reset2FA", []); $response->assertStatus(403); $response = $this->actingAs($reseller2)->post("/api/v4/users/{$user->id}/reset2FA", []); $response->assertStatus(404); // Touching admins is forbidden $response = $this->actingAs($reseller1)->post("/api/v4/users/{$admin->id}/reset2FA", []); $response->assertStatus(403); $entitlements = $user->fresh()->entitlements()->where('sku_id', $sku2fa->id)->get(); $this->assertCount(1, $entitlements); $sf = new \App\Auth\SecondFactor($user); $this->assertCount(1, $sf->factors()); // Test reseting 2FA $response = $this->actingAs($reseller1)->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 \App\Auth\SecondFactor($user); $this->assertCount(0, $sf->factors()); } /** * Test adding beta SKU (POST /api/v4/users//skus/beta) */ public function testAddBetaSku(): void { Queue::fake(); // disable jobs $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local'); $sku = Sku::withEnvTenantContext()->where(['title' => 'beta'])->first(); // Test unauthorized access to reseller API $response = $this->actingAs($user)->post("/api/v4/users/{$user->id}/skus/beta", []); $response->assertStatus(403); $response = $this->actingAs($admin)->post("/api/v4/users/{$user->id}/skus/beta", []); $response->assertStatus(403); $response = $this->actingAs($reseller2)->post("/api/v4/users/{$user->id}/skus/beta", []); $response->assertStatus(404); // Touching admins is forbidden $response = $this->actingAs($reseller1)->post("/api/v4/users/{$admin->id}/skus/beta", []); $response->assertStatus(403); // For now we allow only the beta sku $response = $this->actingAs($reseller1)->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($reseller1)->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($reseller1)->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 { $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); // The end-point does not exist $response = $this->actingAs($reseller1)->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'); $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local'); // Test unauthorized access $response = $this->actingAs($user)->post("/api/v4/users/{$user->id}/suspend", []); $response->assertStatus(403); $response = $this->actingAs($admin)->post("/api/v4/users/{$user->id}/suspend", []); $response->assertStatus(403); $response = $this->actingAs($reseller2)->post("/api/v4/users/{$user->id}/suspend", []); $response->assertStatus(404); $response = $this->actingAs($reseller1)->post("/api/v4/users/{$admin->id}/suspend", []); $response->assertStatus(403); $this->assertFalse($user->isSuspended()); // Test suspending the user $response = $this->actingAs($reseller1)->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'); $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local'); // Test unauthorized access to admin API $response = $this->actingAs($user)->post("/api/v4/users/{$user->id}/unsuspend", []); $response->assertStatus(403); $response = $this->actingAs($admin)->post("/api/v4/users/{$user->id}/unsuspend", []); $response->assertStatus(403); $response = $this->actingAs($reseller2)->post("/api/v4/users/{$user->id}/unsuspend", []); $response->assertStatus(404); $response = $this->actingAs($reseller1)->post("/api/v4/users/{$admin->id}/unsuspend", []); $response->assertStatus(403); $this->assertFalse($user->isSuspended()); $user->suspend(); $this->assertTrue($user->isSuspended()); // Test suspending the user $response = $this->actingAs($reseller1)->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'); $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local'); // Test unauthorized access $response = $this->actingAs($user)->put("/api/v4/users/{$user->id}", []); $response->assertStatus(403); $response = $this->actingAs($admin)->put("/api/v4/users/{$user->id}", []); $response->assertStatus(403); $response = $this->actingAs($reseller2)->put("/api/v4/users/{$user->id}", []); $response->assertStatus(404); $response = $this->actingAs($reseller1)->put("/api/v4/users/{$admin->id}", []); $response->assertStatus(403); // Test updatig the user data (empty data) $response = $this->actingAs($reseller1)->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($reseller1)->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($reseller1)->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/SharedFoldersTest.php b/src/tests/Feature/Controller/SharedFoldersTest.php index b803b468..9518f63b 100644 --- a/src/tests/Feature/Controller/SharedFoldersTest.php +++ b/src/tests/Feature/Controller/SharedFoldersTest.php @@ -1,488 +1,550 @@ 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"]'); + $folder->setAliases(['folder-alias@kolab.org']); // Test unauthenticated access $response = $this->get("/api/v4/shared-folders/{$folder->id}"); $response->assertStatus(401); // Test unauthorized access to a shared folder of another user $response = $this->actingAs($jack)->get("/api/v4/shared-folders/{$folder->id}"); $response->assertStatus(403); // John: Account owner - non-existing folder $response = $this->actingAs($john)->get("/api/v4/shared-folders/abc"); $response->assertStatus(404); // John: Account owner $response = $this->actingAs($john)->get("/api/v4/shared-folders/{$folder->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame($folder->id, $json['id']); $this->assertSame($folder->email, $json['email']); $this->assertSame($folder->name, $json['name']); $this->assertSame($folder->type, $json['type']); + $this->assertSame(['folder-alias@kolab.org'], $json['aliases']); $this->assertTrue(!empty($json['statusInfo'])); $this->assertArrayHasKey('isDeleted', $json); $this->assertArrayHasKey('isActive', $json); $this->assertArrayHasKey('isLdapReady', $json); $this->assertArrayHasKey('isImapReady', $json); $this->assertSame(['acl' => ['anyone, full']], $json['config']); } /** * 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']; + // Test too long name, invalid alias domain + $post = [ + 'domain' => 'kolab.org', + 'name' => str_repeat('A', 192), + 'type' => 'unknown', + 'aliases' => ['folder-alias@unknown.org'], + ]; + $response = $this->actingAs($john)->post("/api/v4/shared-folders", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json); $this->assertSame(["The name may not be greater than 191 characters."], $json['errors']['name']); $this->assertSame(["The specified type is invalid."], $json['errors']['type']); - $this->assertCount(2, $json['errors']); + $this->assertSame(["The specified domain is invalid."], $json['errors']['aliases']); + $this->assertCount(3, $json['errors']); // Test successful folder creation $post['name'] = 'Test Folder'; $post['type'] = 'event'; + $post['aliases'] = ['folder-alias@kolab.org']; // expected to be ignored + $response = $this->actingAs($john)->post("/api/v4/shared-folders", $post); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertSame("Shared folder created successfully.", $json['message']); $this->assertCount(2, $json); $folder = SharedFolder::where('name', $post['name'])->first(); $this->assertInstanceOf(SharedFolder::class, $folder); $this->assertSame($post['type'], $folder->type); $this->assertTrue($john->sharedFolders()->get()->contains($folder)); + $this->assertSame([], $folder->aliases()->pluck('alias')->all()); // Shared folder name must be unique within a domain $post['type'] = 'mail'; $response = $this->actingAs($john)->post("/api/v4/shared-folders", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json); $this->assertCount(1, $json['errors']); $this->assertSame("The specified name is not available.", $json['errors']['name'][0]); + + $folder->forceDelete(); + + // Test successful folder creation with aliases + $post['name'] = 'Test Folder'; + $post['type'] = 'mail'; + $post['aliases'] = ['folder-alias@kolab.org']; + $response = $this->actingAs($john)->post("/api/v4/shared-folders", $post); + $json = $response->json(); + + $response->assertStatus(200); + + $folder = SharedFolder::where('name', $post['name'])->first(); + $this->assertSame(['folder-alias@kolab.org'], $folder->aliases()->pluck('alias')->all()); } /** * Test shared folder update (PUT /api/v4/shared-folders/getTestUser('jack@kolab.org'); $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $folder = $this->getTestSharedFolder('folder-test@kolab.org'); $folder->assignToWallet($john->wallets->first()); // Test unauthorized update $response = $this->get("/api/v4/shared-folders/{$folder->id}", []); $response->assertStatus(401); // Test unauthorized update $response = $this->actingAs($jack)->get("/api/v4/shared-folders/{$folder->id}", []); $response->assertStatus(403); // Name change $post = [ 'name' => 'Test Res', ]; $response = $this->actingAs($john)->put("/api/v4/shared-folders/{$folder->id}", $post); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertSame("Shared folder updated successfully.", $json['message']); $this->assertCount(2, $json); $folder->refresh(); $this->assertSame($post['name'], $folder->name); + + // Aliases with error + $post['aliases'] = ['folder-alias1@kolab.org', 'folder-alias2@unknown.com']; + + $response = $this->actingAs($john)->put("/api/v4/shared-folders/{$folder->id}", $post); + $json = $response->json(); + + $response->assertStatus(422); + + $this->assertSame('error', $json['status']); + $this->assertCount(2, $json); + $this->assertCount(1, $json['errors']); + $this->assertCount(1, $json['errors']['aliases']); + $this->assertSame("The specified domain is invalid.", $json['errors']['aliases'][1]); + $this->assertSame([], $folder->aliases()->pluck('alias')->all()); + + // Aliases with success expected + $post['aliases'] = ['folder-alias1@kolab.org', 'folder-alias2@kolab.org']; + + $response = $this->actingAs($john)->put("/api/v4/shared-folders/{$folder->id}", $post); + $json = $response->json(); + + $response->assertStatus(200); + + $this->assertSame('success', $json['status']); + $this->assertSame("Shared folder updated successfully.", $json['message']); + $this->assertCount(2, $json); + $this->assertSame($post['aliases'], $folder->aliases()->pluck('alias')->all()); + + // All aliases removal + $post['aliases'] = []; + + $response = $this->actingAs($john)->put("/api/v4/shared-folders/{$folder->id}", $post); + $response->assertStatus(200); + + $this->assertSame($post['aliases'], $folder->aliases()->pluck('alias')->all()); } } diff --git a/src/tests/Feature/Controller/UsersTest.php b/src/tests/Feature/Controller/UsersTest.php index 5ce5a8ac..c10c7e46 100644 --- a/src/tests/Feature/Controller/UsersTest.php +++ b/src/tests/Feature/Controller/UsersTest.php @@ -1,1487 +1,1508 @@ deleteTestUser('jane@kolabnow.com'); $this->deleteTestUser('UsersControllerTest1@userscontroller.com'); $this->deleteTestUser('UsersControllerTest2@userscontroller.com'); $this->deleteTestUser('UsersControllerTest3@userscontroller.com'); $this->deleteTestUser('UserEntitlement2A@UserEntitlement.com'); $this->deleteTestUser('john2.doe2@kolab.org'); $this->deleteTestUser('deleted@kolab.org'); $this->deleteTestUser('deleted@kolabnow.com'); $this->deleteTestDomain('userscontroller.com'); $this->deleteTestGroup('group-test@kolabnow.com'); $this->deleteTestGroup('group-test@kolab.org'); + $this->deleteTestSharedFolder('folder-test@kolabnow.com'); + $this->deleteTestResource('resource-test@kolabnow.com'); $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); $wallet->discount()->dissociate(); $wallet->settings()->whereIn('key', ['mollie_id', 'stripe_id'])->delete(); $wallet->save(); $user->status |= User::STATUS_IMAP_READY; $user->save(); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('jane@kolabnow.com'); $this->deleteTestUser('UsersControllerTest1@userscontroller.com'); $this->deleteTestUser('UsersControllerTest2@userscontroller.com'); $this->deleteTestUser('UsersControllerTest3@userscontroller.com'); $this->deleteTestUser('UserEntitlement2A@UserEntitlement.com'); $this->deleteTestUser('john2.doe2@kolab.org'); $this->deleteTestUser('deleted@kolab.org'); $this->deleteTestUser('deleted@kolabnow.com'); $this->deleteTestDomain('userscontroller.com'); $this->deleteTestGroup('group-test@kolabnow.com'); $this->deleteTestGroup('group-test@kolab.org'); + $this->deleteTestSharedFolder('folder-test@kolabnow.com'); + $this->deleteTestResource('resource-test@kolabnow.com'); $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); $wallet->discount()->dissociate(); $wallet->settings()->whereIn('key', ['mollie_id', 'stripe_id'])->delete(); $wallet->save(); $user->settings()->whereIn('key', ['greylist_enabled'])->delete(); $user->status |= User::STATUS_IMAP_READY; $user->save(); parent::tearDown(); } /** * Test user deleting (DELETE /api/v4/users/) */ public function testDestroy(): void { // First create some users/accounts to delete $package_kolab = \App\Package::where('title', 'kolab')->first(); $package_domain = \App\Package::where('title', 'domain-hosting')->first(); $john = $this->getTestUser('john@kolab.org'); $user1 = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $user2 = $this->getTestUser('UsersControllerTest2@userscontroller.com'); $user3 = $this->getTestUser('UsersControllerTest3@userscontroller.com'); $domain = $this->getTestDomain('userscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_PUBLIC, ]); $user1->assignPackage($package_kolab); $domain->assignPackage($package_domain, $user1); $user1->assignPackage($package_kolab, $user2); $user1->assignPackage($package_kolab, $user3); // Test unauth access $response = $this->delete("api/v4/users/{$user2->id}"); $response->assertStatus(401); // Test access to other user/account $response = $this->actingAs($john)->delete("api/v4/users/{$user2->id}"); $response->assertStatus(403); $response = $this->actingAs($john)->delete("api/v4/users/{$user1->id}"); $response->assertStatus(403); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame("Access denied", $json['message']); $this->assertCount(2, $json); // Test that non-controller cannot remove himself $response = $this->actingAs($user3)->delete("api/v4/users/{$user3->id}"); $response->assertStatus(403); // Test removing a non-controller user $response = $this->actingAs($user1)->delete("api/v4/users/{$user3->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals('success', $json['status']); $this->assertEquals('User deleted successfully.', $json['message']); // Test removing self (an account with users) $response = $this->actingAs($user1)->delete("api/v4/users/{$user1->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals('success', $json['status']); $this->assertEquals('User deleted successfully.', $json['message']); } /** * Test user deleting (DELETE /api/v4/users/) */ public function testDestroyByController(): void { // Create an account with additional controller - $user2 $package_kolab = \App\Package::where('title', 'kolab')->first(); $package_domain = \App\Package::where('title', 'domain-hosting')->first(); $user1 = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $user2 = $this->getTestUser('UsersControllerTest2@userscontroller.com'); $user3 = $this->getTestUser('UsersControllerTest3@userscontroller.com'); $domain = $this->getTestDomain('userscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_PUBLIC, ]); $user1->assignPackage($package_kolab); $domain->assignPackage($package_domain, $user1); $user1->assignPackage($package_kolab, $user2); $user1->assignPackage($package_kolab, $user3); $user1->wallets()->first()->addController($user2); // TODO/FIXME: // For now controller can delete himself, as well as // the whole account he has control to, including the owner // Probably he should not be able to do none of those // However, this is not 0-regression scenario as we // do not fully support additional controllers. //$response = $this->actingAs($user2)->delete("api/v4/users/{$user2->id}"); //$response->assertStatus(403); $response = $this->actingAs($user2)->delete("api/v4/users/{$user3->id}"); $response->assertStatus(200); $response = $this->actingAs($user2)->delete("api/v4/users/{$user1->id}"); $response->assertStatus(200); // Note: More detailed assertions in testDestroy() above $this->assertTrue($user1->fresh()->trashed()); $this->assertTrue($user2->fresh()->trashed()); $this->assertTrue($user3->fresh()->trashed()); } /** * Test user listing (GET /api/v4/users) */ public function testIndex(): void { // Test unauth access $response = $this->get("api/v4/users"); $response->assertStatus(401); $jack = $this->getTestUser('jack@kolab.org'); $joe = $this->getTestUser('joe@kolab.org'); $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $response = $this->actingAs($jack)->get("/api/v4/users"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(false, $json['hasMore']); $this->assertSame(0, $json['count']); $this->assertCount(0, $json['list']); $response = $this->actingAs($john)->get("/api/v4/users"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(false, $json['hasMore']); $this->assertSame(4, $json['count']); $this->assertCount(4, $json['list']); $this->assertSame($jack->email, $json['list'][0]['email']); $this->assertSame($joe->email, $json['list'][1]['email']); $this->assertSame($john->email, $json['list'][2]['email']); $this->assertSame($ned->email, $json['list'][3]['email']); // Values below are tested by Unit tests $this->assertArrayHasKey('isDeleted', $json['list'][0]); $this->assertArrayHasKey('isDegraded', $json['list'][0]); $this->assertArrayHasKey('isAccountDegraded', $json['list'][0]); $this->assertArrayHasKey('isSuspended', $json['list'][0]); $this->assertArrayHasKey('isActive', $json['list'][0]); $this->assertArrayHasKey('isLdapReady', $json['list'][0]); $this->assertArrayHasKey('isImapReady', $json['list'][0]); $response = $this->actingAs($ned)->get("/api/v4/users"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(false, $json['hasMore']); $this->assertSame(4, $json['count']); $this->assertCount(4, $json['list']); $this->assertSame($jack->email, $json['list'][0]['email']); $this->assertSame($joe->email, $json['list'][1]['email']); $this->assertSame($john->email, $json['list'][2]['email']); $this->assertSame($ned->email, $json['list'][3]['email']); // Search by user email $response = $this->actingAs($john)->get("/api/v4/users?search=jack@k"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(false, $json['hasMore']); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($jack->email, $json['list'][0]['email']); // Search by alias $response = $this->actingAs($john)->get("/api/v4/users?search=monster"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(false, $json['hasMore']); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($joe->email, $json['list'][0]['email']); // Search by name $response = $this->actingAs($john)->get("/api/v4/users?search=land"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(false, $json['hasMore']); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($ned->email, $json['list'][0]['email']); // TODO: Test paging } /** * Test fetching user data/profile (GET /api/v4/users/) */ public function testShow(): void { $userA = $this->getTestUser('UserEntitlement2A@UserEntitlement.com'); // Test getting profile of self $response = $this->actingAs($userA)->get("/api/v4/users/{$userA->id}"); $json = $response->json(); $response->assertStatus(200); $this->assertEquals($userA->id, $json['id']); $this->assertEquals($userA->email, $json['email']); $this->assertTrue(is_array($json['statusInfo'])); $this->assertTrue(is_array($json['settings'])); - $this->assertTrue(is_array($json['aliases'])); $this->assertTrue($json['config']['greylist_enabled']); $this->assertSame([], $json['skus']); + $this->assertSame([], $json['aliases']); // Values below are tested by Unit tests $this->assertArrayHasKey('isDeleted', $json); $this->assertArrayHasKey('isDegraded', $json); $this->assertArrayHasKey('isAccountDegraded', $json); $this->assertArrayHasKey('isSuspended', $json); $this->assertArrayHasKey('isActive', $json); $this->assertArrayHasKey('isLdapReady', $json); $this->assertArrayHasKey('isImapReady', $json); $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); // Test unauthorized access to a profile of other user $response = $this->actingAs($jack)->get("/api/v4/users/{$userA->id}"); $response->assertStatus(403); // Test authorized access to a profile of other user // Ned: Additional account controller $response = $this->actingAs($ned)->get("/api/v4/users/{$john->id}"); $response->assertStatus(200); + + $json = $response->json(); + $this->assertSame(['john.doe@kolab.org'], $json['aliases']); + $response = $this->actingAs($ned)->get("/api/v4/users/{$jack->id}"); $response->assertStatus(200); // John: Account owner $response = $this->actingAs($john)->get("/api/v4/users/{$jack->id}"); $response->assertStatus(200); $response = $this->actingAs($john)->get("/api/v4/users/{$ned->id}"); $response->assertStatus(200); $json = $response->json(); $storage_sku = Sku::withEnvTenantContext()->where('title', 'storage')->first(); $groupware_sku = Sku::withEnvTenantContext()->where('title', 'groupware')->first(); $mailbox_sku = Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); $secondfactor_sku = Sku::withEnvTenantContext()->where('title', '2fa')->first(); $this->assertCount(5, $json['skus']); $this->assertSame(5, $json['skus'][$storage_sku->id]['count']); $this->assertSame([0,0,0,0,0], $json['skus'][$storage_sku->id]['costs']); $this->assertSame(1, $json['skus'][$groupware_sku->id]['count']); $this->assertSame([490], $json['skus'][$groupware_sku->id]['costs']); $this->assertSame(1, $json['skus'][$mailbox_sku->id]['count']); $this->assertSame([500], $json['skus'][$mailbox_sku->id]['costs']); $this->assertSame(1, $json['skus'][$secondfactor_sku->id]['count']); $this->assertSame([0], $json['skus'][$secondfactor_sku->id]['costs']); + + $this->assertSame([], $json['aliases']); } /** * Test fetching user status (GET /api/v4/users//status) * and forcing setup process update (?refresh=1) * * @group imap * @group dns */ public function testStatus(): void { Queue::fake(); $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); // Test unauthorized access $response = $this->actingAs($jack)->get("/api/v4/users/{$john->id}/status"); $response->assertStatus(403); if ($john->isImapReady()) { $john->status ^= User::STATUS_IMAP_READY; $john->save(); } // Get user status $response = $this->actingAs($john)->get("/api/v4/users/{$john->id}/status"); $response->assertStatus(200); $json = $response->json(); $this->assertFalse($json['isImapReady']); $this->assertFalse($json['isReady']); $this->assertCount(7, $json['process']); $this->assertSame('user-imap-ready', $json['process'][2]['label']); $this->assertSame(false, $json['process'][2]['state']); $this->assertTrue(empty($json['status'])); $this->assertTrue(empty($json['message'])); // Make sure the domain is confirmed (other test might unset that status) $domain = $this->getTestDomain('kolab.org'); $domain->status |= Domain::STATUS_CONFIRMED; $domain->save(); // Now "reboot" the process and verify the user in imap synchronously $response = $this->actingAs($john)->get("/api/v4/users/{$john->id}/status?refresh=1"); $response->assertStatus(200); $json = $response->json(); $this->assertTrue($json['isImapReady']); $this->assertTrue($json['isReady']); $this->assertCount(7, $json['process']); $this->assertSame('user-imap-ready', $json['process'][2]['label']); $this->assertSame(true, $json['process'][2]['state']); $this->assertSame('success', $json['status']); $this->assertSame('Setup process finished successfully.', $json['message']); Queue::size(1); // Test case for when the verify job is dispatched to the worker $john->refresh(); $john->status ^= User::STATUS_IMAP_READY; $john->save(); \config(['imap.admin_password' => null]); $response = $this->actingAs($john)->get("/api/v4/users/{$john->id}/status?refresh=1"); $response->assertStatus(200); $json = $response->json(); $this->assertFalse($json['isImapReady']); $this->assertFalse($json['isReady']); $this->assertSame('success', $json['status']); $this->assertSame('waiting', $json['processState']); $this->assertSame('Setup process has been pushed. Please wait.', $json['message']); Queue::assertPushed(\App\Jobs\User\VerifyJob::class, 1); } /** * Test UsersController::statusInfo() */ public function testStatusInfo(): void { $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $domain = $this->getTestDomain('userscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_PUBLIC, ]); $user->created_at = Carbon::now(); $user->status = User::STATUS_NEW; $user->save(); $result = UsersController::statusInfo($user); $this->assertFalse($result['isReady']); $this->assertSame([], $result['skus']); $this->assertCount(3, $result['process']); $this->assertSame('user-new', $result['process'][0]['label']); $this->assertSame(true, $result['process'][0]['state']); $this->assertSame('user-ldap-ready', $result['process'][1]['label']); $this->assertSame(false, $result['process'][1]['state']); $this->assertSame('user-imap-ready', $result['process'][2]['label']); $this->assertSame(false, $result['process'][2]['state']); $this->assertSame('running', $result['processState']); $user->created_at = Carbon::now()->subSeconds(181); $user->save(); $result = UsersController::statusInfo($user); $this->assertSame('failed', $result['processState']); $user->status |= User::STATUS_LDAP_READY | User::STATUS_IMAP_READY; $user->save(); $result = UsersController::statusInfo($user); $this->assertTrue($result['isReady']); $this->assertCount(3, $result['process']); $this->assertSame('user-new', $result['process'][0]['label']); $this->assertSame(true, $result['process'][0]['state']); $this->assertSame('user-ldap-ready', $result['process'][1]['label']); $this->assertSame(true, $result['process'][1]['state']); $this->assertSame('user-imap-ready', $result['process'][2]['label']); $this->assertSame(true, $result['process'][2]['state']); $this->assertSame('done', $result['processState']); $domain->status |= Domain::STATUS_VERIFIED; $domain->type = Domain::TYPE_EXTERNAL; $domain->save(); $result = UsersController::statusInfo($user); $this->assertFalse($result['isReady']); $this->assertSame([], $result['skus']); $this->assertCount(7, $result['process']); $this->assertSame('user-new', $result['process'][0]['label']); $this->assertSame(true, $result['process'][0]['state']); $this->assertSame('user-ldap-ready', $result['process'][1]['label']); $this->assertSame(true, $result['process'][1]['state']); $this->assertSame('user-imap-ready', $result['process'][2]['label']); $this->assertSame(true, $result['process'][2]['state']); $this->assertSame('domain-new', $result['process'][3]['label']); $this->assertSame(true, $result['process'][3]['state']); $this->assertSame('domain-ldap-ready', $result['process'][4]['label']); $this->assertSame(false, $result['process'][4]['state']); $this->assertSame('domain-verified', $result['process'][5]['label']); $this->assertSame(true, $result['process'][5]['state']); $this->assertSame('domain-confirmed', $result['process'][6]['label']); $this->assertSame(false, $result['process'][6]['state']); // Test 'skus' property $user->assignSku(Sku::withEnvTenantContext()->where('title', 'beta')->first()); $result = UsersController::statusInfo($user); $this->assertSame(['beta'], $result['skus']); $user->assignSku(Sku::withEnvTenantContext()->where('title', 'meet')->first()); $result = UsersController::statusInfo($user); $this->assertSame(['beta', 'meet'], $result['skus']); $user->assignSku(Sku::withEnvTenantContext()->where('title', 'meet')->first()); $result = UsersController::statusInfo($user); $this->assertSame(['beta', 'meet'], $result['skus']); } /** * Test user config update (POST /api/v4/users//config) */ public function testSetConfig(): void { $jack = $this->getTestUser('jack@kolab.org'); $john = $this->getTestUser('john@kolab.org'); $john->setSetting('greylist_enabled', null); $john->setSetting('password_policy', null); // Test unknown user id $post = ['greylist_enabled' => 1]; $response = $this->actingAs($john)->post("/api/v4/users/123/config", $post); $json = $response->json(); $response->assertStatus(404); // Test access by user not being a wallet controller $post = ['greylist_enabled' => 1]; $response = $this->actingAs($jack)->post("/api/v4/users/{$john->id}/config", $post); $json = $response->json(); $response->assertStatus(403); $this->assertSame('error', $json['status']); $this->assertSame("Access denied", $json['message']); $this->assertCount(2, $json); // Test some invalid data $post = ['grey' => 1, 'password_policy' => 'min:1,max:255']; $response = $this->actingAs($john)->post("/api/v4/users/{$john->id}/config", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json); $this->assertCount(2, $json['errors']); $this->assertSame("The requested configuration parameter is not supported.", $json['errors']['grey']); $this->assertSame("Minimum password length cannot be less than 6.", $json['errors']['password_policy']); $this->assertNull($john->fresh()->getSetting('greylist_enabled')); // Test some valid data $post = ['greylist_enabled' => 1, 'password_policy' => 'min:10,max:255,upper,lower,digit,special']; $response = $this->actingAs($john)->post("/api/v4/users/{$john->id}/config", $post); $response->assertStatus(200); $json = $response->json(); $this->assertCount(2, $json); $this->assertSame('success', $json['status']); $this->assertSame('User settings updated successfully.', $json['message']); $this->assertSame('true', $john->fresh()->getSetting('greylist_enabled')); $this->assertSame('min:10,max:255,upper,lower,digit,special', $john->fresh()->getSetting('password_policy')); // Test some valid data, acting as another account controller $ned = $this->getTestUser('ned@kolab.org'); $post = ['greylist_enabled' => 0, 'password_policy' => 'min:10,max:255,upper,last:1']; $response = $this->actingAs($ned)->post("/api/v4/users/{$john->id}/config", $post); $response->assertStatus(200); $json = $response->json(); $this->assertCount(2, $json); $this->assertSame('success', $json['status']); $this->assertSame('User settings updated successfully.', $json['message']); $this->assertSame('false', $john->fresh()->getSetting('greylist_enabled')); $this->assertSame('min:10,max:255,upper,last:1', $john->fresh()->getSetting('password_policy')); } /** * Test user creation (POST /api/v4/users) */ public function testStore(): void { Queue::fake(); $jack = $this->getTestUser('jack@kolab.org'); $john = $this->getTestUser('john@kolab.org'); $john->setSetting('password_policy', 'min:8,max:100,digit'); $deleted_priv = $this->getTestUser('deleted@kolab.org'); $deleted_priv->delete(); // Test empty request $response = $this->actingAs($john)->post("/api/v4/users", []); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame("The email field is required.", $json['errors']['email']); $this->assertSame("The password field is required.", $json['errors']['password'][0]); $this->assertCount(2, $json); // Test access by user not being a wallet controller $post = ['first_name' => 'Test']; $response = $this->actingAs($jack)->post("/api/v4/users", $post); $json = $response->json(); $response->assertStatus(403); $this->assertSame('error', $json['status']); $this->assertSame("Access denied", $json['message']); $this->assertCount(2, $json); // Test some invalid data $post = ['password' => '12345678', 'email' => 'invalid']; $response = $this->actingAs($john)->post("/api/v4/users", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json); $this->assertSame('The password confirmation does not match.', $json['errors']['password'][0]); $this->assertSame('The specified email is invalid.', $json['errors']['email']); // Test existing user email $post = [ 'password' => 'simple123', 'password_confirmation' => 'simple123', 'first_name' => 'John2', 'last_name' => 'Doe2', 'email' => 'jack.daniels@kolab.org', ]; $response = $this->actingAs($john)->post("/api/v4/users", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json); $this->assertSame('The specified email is not available.', $json['errors']['email']); $package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $post = [ 'password' => 'simple123', 'password_confirmation' => 'simple123', 'first_name' => 'John2', 'last_name' => 'Doe2', 'email' => 'john2.doe2@kolab.org', 'organization' => 'TestOrg', 'aliases' => ['useralias1@kolab.org', 'deleted@kolab.org'], ]; // Missing package $response = $this->actingAs($john)->post("/api/v4/users", $post); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertSame("Package is required.", $json['errors']['package']); $this->assertCount(2, $json); // Invalid package $post['package'] = $package_domain->id; $response = $this->actingAs($john)->post("/api/v4/users", $post); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertSame("Invalid package selected.", $json['errors']['package']); $this->assertCount(2, $json); // Test password policy checking $post['package'] = $package_kolab->id; $post['password'] = 'password'; $response = $this->actingAs($john)->post("/api/v4/users", $post); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertSame("The password confirmation does not match.", $json['errors']['password'][0]); $this->assertSame("Specified password does not comply with the policy.", $json['errors']['password'][1]); $this->assertCount(2, $json); // Test password confirmation $post['password_confirmation'] = 'password'; $response = $this->actingAs($john)->post("/api/v4/users", $post); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertSame("Specified password does not comply with the policy.", $json['errors']['password'][0]); $this->assertCount(2, $json); // Test full and valid data $post['password'] = 'password123'; $post['password_confirmation'] = 'password123'; $response = $this->actingAs($john)->post("/api/v4/users", $post); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertSame("User created successfully.", $json['message']); $this->assertCount(2, $json); $user = User::where('email', 'john2.doe2@kolab.org')->first(); $this->assertInstanceOf(User::class, $user); $this->assertSame('John2', $user->getSetting('first_name')); $this->assertSame('Doe2', $user->getSetting('last_name')); $this->assertSame('TestOrg', $user->getSetting('organization')); $aliases = $user->aliases()->orderBy('alias')->get(); $this->assertCount(2, $aliases); $this->assertSame('deleted@kolab.org', $aliases[0]->alias); $this->assertSame('useralias1@kolab.org', $aliases[1]->alias); // Assert the new user entitlements $this->assertEntitlements($user, ['groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage']); // Assert the wallet to which the new user should be assigned to $wallet = $user->wallet(); $this->assertSame($john->wallets->first()->id, $wallet->id); // Attempt to create a user previously deleted $user->delete(); $post['package'] = $package_kolab->id; $post['aliases'] = []; $response = $this->actingAs($john)->post("/api/v4/users", $post); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertSame("User created successfully.", $json['message']); $this->assertCount(2, $json); $user = User::where('email', 'john2.doe2@kolab.org')->first(); $this->assertInstanceOf(User::class, $user); $this->assertSame('John2', $user->getSetting('first_name')); $this->assertSame('Doe2', $user->getSetting('last_name')); $this->assertSame('TestOrg', $user->getSetting('organization')); $this->assertCount(0, $user->aliases()->get()); $this->assertEntitlements($user, ['groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage']); // Test password reset link "mode" $code = new \App\VerificationCode(['mode' => 'password-reset', 'active' => false]); $john->verificationcodes()->save($code); $post = [ 'first_name' => 'John2', 'last_name' => 'Doe2', 'email' => 'deleted@kolab.org', 'organization' => '', 'aliases' => [], 'passwordLinkCode' => $code->short_code . '-' . $code->code, 'package' => $package_kolab->id, ]; $response = $this->actingAs($john)->post("/api/v4/users", $post); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertSame("User created successfully.", $json['message']); $this->assertCount(2, $json); $user = $this->getTestUser('deleted@kolab.org'); $code->refresh(); $this->assertSame($user->id, $code->user_id); $this->assertTrue($code->active); $this->assertTrue(is_string($user->password) && strlen($user->password) >= 60); // Test acting as account controller not owner, which is not yet supported $john->wallets->first()->addController($user); $response = $this->actingAs($user)->post("/api/v4/users", []); $response->assertStatus(403); } /** * Test user update (PUT /api/v4/users/) */ public function testUpdate(): void { $userA = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $userA->setSetting('password_policy', 'min:8,digit'); $jack = $this->getTestUser('jack@kolab.org'); $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $domain = $this->getTestDomain( 'userscontroller.com', ['status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL] ); // Test unauthorized update of other user profile $response = $this->actingAs($jack)->get("/api/v4/users/{$userA->id}", []); $response->assertStatus(403); // Test authorized update of account owner by account controller $response = $this->actingAs($ned)->get("/api/v4/users/{$john->id}", []); $response->assertStatus(200); // Test updating of self (empty request) $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", []); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("User data updated successfully.", $json['message']); $this->assertTrue(!empty($json['statusInfo'])); $this->assertCount(3, $json); // Test some invalid data $post = ['password' => '1234567', 'currency' => 'invalid']; $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json); $this->assertSame("The password confirmation does not match.", $json['errors']['password'][0]); $this->assertSame("Specified password does not comply with the policy.", $json['errors']['password'][1]); $this->assertSame("The currency must be 3 characters.", $json['errors']['currency'][0]); // Test full profile update including password $post = [ 'password' => 'simple123', 'password_confirmation' => 'simple123', 'first_name' => 'John2', 'last_name' => 'Doe2', 'organization' => 'TestOrg', 'phone' => '+123 123 123', 'external_email' => 'external@gmail.com', 'billing_address' => 'billing', 'country' => 'CH', 'currency' => 'CHF', 'aliases' => ['useralias1@' . \config('app.domain'), 'useralias2@' . \config('app.domain')] ]; $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertSame("User data updated successfully.", $json['message']); $this->assertTrue(!empty($json['statusInfo'])); $this->assertCount(3, $json); $this->assertTrue($userA->password != $userA->fresh()->password); unset($post['password'], $post['password_confirmation'], $post['aliases']); foreach ($post as $key => $value) { $this->assertSame($value, $userA->getSetting($key)); } $aliases = $userA->aliases()->orderBy('alias')->get(); $this->assertCount(2, $aliases); $this->assertSame('useralias1@' . \config('app.domain'), $aliases[0]->alias); $this->assertSame('useralias2@' . \config('app.domain'), $aliases[1]->alias); // Test unsetting values $post = [ 'first_name' => '', 'last_name' => '', 'organization' => '', 'phone' => '', 'external_email' => '', 'billing_address' => '', 'country' => '', 'currency' => '', 'aliases' => ['useralias2@' . \config('app.domain')] ]; $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertSame("User data updated successfully.", $json['message']); $this->assertTrue(!empty($json['statusInfo'])); $this->assertCount(3, $json); unset($post['aliases']); foreach ($post as $key => $value) { $this->assertNull($userA->getSetting($key)); } $aliases = $userA->aliases()->get(); $this->assertCount(1, $aliases); $this->assertSame('useralias2@' . \config('app.domain'), $aliases[0]->alias); // Test error on some invalid aliases missing password confirmation $post = [ 'password' => 'simple123', 'aliases' => [ 'useralias2@' . \config('app.domain'), 'useralias1@kolab.org', '@kolab.org', ] ]; $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertCount(2, $json['errors']['aliases']); $this->assertSame("The specified domain is not available.", $json['errors']['aliases'][1]); $this->assertSame("The specified alias is invalid.", $json['errors']['aliases'][2]); $this->assertSame("The password confirmation does not match.", $json['errors']['password'][0]); // Test authorized update of other user $response = $this->actingAs($ned)->put("/api/v4/users/{$jack->id}", []); $response->assertStatus(200); $json = $response->json(); $this->assertTrue(empty($json['statusInfo'])); // TODO: Test error on aliases with invalid/non-existing/other-user's domain // Create entitlements and additional user for following tests $owner = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $user = $this->getTestUser('UsersControllerTest2@userscontroller.com'); $package_domain = Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $package_kolab = Package::withEnvTenantContext()->where('title', 'kolab')->first(); $package_lite = Package::withEnvTenantContext()->where('title', 'lite')->first(); $sku_mailbox = Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); $sku_storage = Sku::withEnvTenantContext()->where('title', 'storage')->first(); $sku_groupware = Sku::withEnvTenantContext()->where('title', 'groupware')->first(); $domain = $this->getTestDomain( 'userscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ] ); $domain->assignPackage($package_domain, $owner); $owner->assignPackage($package_kolab); $owner->assignPackage($package_lite, $user); // Non-controller cannot update his own entitlements $post = ['skus' => []]; $response = $this->actingAs($user)->put("/api/v4/users/{$user->id}", $post); $response->assertStatus(422); // Test updating entitlements $post = [ 'skus' => [ $sku_mailbox->id => 1, $sku_storage->id => 6, $sku_groupware->id => 1, ], ]; $response = $this->actingAs($owner)->put("/api/v4/users/{$user->id}", $post); $response->assertStatus(200); $json = $response->json(); $storage_cost = $user->entitlements() ->where('sku_id', $sku_storage->id) ->orderBy('cost') ->pluck('cost')->all(); $this->assertEntitlements( $user, ['groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage'] ); $this->assertSame([0, 0, 0, 0, 0, 25], $storage_cost); $this->assertTrue(empty($json['statusInfo'])); // Test password reset link "mode" $code = new \App\VerificationCode(['mode' => 'password-reset', 'active' => false]); $owner->verificationcodes()->save($code); $post = ['passwordLinkCode' => $code->short_code . '-' . $code->code]; $response = $this->actingAs($owner)->put("/api/v4/users/{$user->id}", $post); $json = $response->json(); $response->assertStatus(200); $code->refresh(); $this->assertSame($user->id, $code->user_id); $this->assertTrue($code->active); $this->assertSame($user->password, $user->fresh()->password); } /** * Test UsersController::updateEntitlements() */ public function testUpdateEntitlements(): void { $jane = $this->getTestUser('jane@kolabnow.com'); $kolab = Package::withEnvTenantContext()->where('title', 'kolab')->first(); $storage = Sku::withEnvTenantContext()->where('title', 'storage')->first(); $activesync = Sku::withEnvTenantContext()->where('title', 'activesync')->first(); $groupware = Sku::withEnvTenantContext()->where('title', 'groupware')->first(); $mailbox = Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); // standard package, 1 mailbox, 1 groupware, 2 storage $jane->assignPackage($kolab); // add 2 storage, 1 activesync $post = [ 'skus' => [ $mailbox->id => 1, $groupware->id => 1, $storage->id => 7, $activesync->id => 1 ] ]; $response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post); $response->assertStatus(200); $this->assertEntitlements( $jane, [ 'activesync', 'groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage' ] ); // add 2 storage, remove 1 activesync $post = [ 'skus' => [ $mailbox->id => 1, $groupware->id => 1, $storage->id => 9, $activesync->id => 0 ] ]; $response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post); $response->assertStatus(200); $this->assertEntitlements( $jane, [ 'groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage' ] ); // add mailbox $post = [ 'skus' => [ $mailbox->id => 2, $groupware->id => 1, $storage->id => 9, $activesync->id => 0 ] ]; $response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post); $response->assertStatus(500); $this->assertEntitlements( $jane, [ 'groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage' ] ); // remove mailbox $post = [ 'skus' => [ $mailbox->id => 0, $groupware->id => 1, $storage->id => 9, $activesync->id => 0 ] ]; $response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post); $response->assertStatus(500); $this->assertEntitlements( $jane, [ 'groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage' ] ); // less than free storage $post = [ 'skus' => [ $mailbox->id => 1, $groupware->id => 1, $storage->id => 1, $activesync->id => 0 ] ]; $response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post); $response->assertStatus(200); $this->assertEntitlements( $jane, [ 'groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage' ] ); } /** * Test user data response used in show and info actions */ public function testUserResponse(): void { $provider = \config('services.payment_provider') ?: 'mollie'; $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); $wallet->setSettings(['mollie_id' => null, 'stripe_id' => null]); $result = $this->invokeMethod(new UsersController(), 'userResponse', [$user]); $this->assertEquals($user->id, $result['id']); $this->assertEquals($user->email, $result['email']); $this->assertEquals($user->status, $result['status']); $this->assertTrue(is_array($result['statusInfo'])); - $this->assertTrue(is_array($result['aliases'])); - $this->assertCount(1, $result['aliases']); - $this->assertSame('john.doe@kolab.org', $result['aliases'][0]); - $this->assertTrue(is_array($result['settings'])); $this->assertSame('US', $result['settings']['country']); $this->assertSame('USD', $result['settings']['currency']); $this->assertTrue(is_array($result['accounts'])); $this->assertTrue(is_array($result['wallets'])); $this->assertCount(0, $result['accounts']); $this->assertCount(1, $result['wallets']); $this->assertSame($wallet->id, $result['wallet']['id']); $this->assertArrayNotHasKey('discount', $result['wallet']); $this->assertTrue($result['statusInfo']['enableDomains']); $this->assertTrue($result['statusInfo']['enableWallets']); $this->assertTrue($result['statusInfo']['enableUsers']); $this->assertTrue($result['statusInfo']['enableSettings']); // Ned is John's wallet controller $ned = $this->getTestUser('ned@kolab.org'); $ned_wallet = $ned->wallets()->first(); $result = $this->invokeMethod(new UsersController(), 'userResponse', [$ned]); $this->assertEquals($ned->id, $result['id']); $this->assertEquals($ned->email, $result['email']); $this->assertTrue(is_array($result['accounts'])); $this->assertTrue(is_array($result['wallets'])); $this->assertCount(1, $result['accounts']); $this->assertCount(1, $result['wallets']); $this->assertSame($wallet->id, $result['wallet']['id']); $this->assertSame($wallet->id, $result['accounts'][0]['id']); $this->assertSame($ned_wallet->id, $result['wallets'][0]['id']); $this->assertSame($provider, $result['wallet']['provider']); $this->assertSame($provider, $result['wallets'][0]['provider']); $this->assertTrue($result['statusInfo']['enableDomains']); $this->assertTrue($result['statusInfo']['enableWallets']); $this->assertTrue($result['statusInfo']['enableUsers']); $this->assertTrue($result['statusInfo']['enableSettings']); // Test discount in a response $discount = Discount::where('code', 'TEST')->first(); $wallet->discount()->associate($discount); $wallet->save(); $mod_provider = $provider == 'mollie' ? 'stripe' : 'mollie'; $wallet->setSetting($mod_provider . '_id', 123); $user->refresh(); $result = $this->invokeMethod(new UsersController(), 'userResponse', [$user]); $this->assertEquals($user->id, $result['id']); $this->assertSame($discount->id, $result['wallet']['discount_id']); $this->assertSame($discount->discount, $result['wallet']['discount']); $this->assertSame($discount->description, $result['wallet']['discount_description']); $this->assertSame($mod_provider, $result['wallet']['provider']); $this->assertSame($discount->id, $result['wallets'][0]['discount_id']); $this->assertSame($discount->discount, $result['wallets'][0]['discount']); $this->assertSame($discount->description, $result['wallets'][0]['discount_description']); $this->assertSame($mod_provider, $result['wallets'][0]['provider']); // Jack is not a John's wallet controller $jack = $this->getTestUser('jack@kolab.org'); $result = $this->invokeMethod(new UsersController(), 'userResponse', [$jack]); $this->assertFalse($result['statusInfo']['enableDomains']); $this->assertFalse($result['statusInfo']['enableWallets']); $this->assertFalse($result['statusInfo']['enableUsers']); $this->assertFalse($result['statusInfo']['enableSettings']); } /** - * List of email address validation cases for testValidateEmail() + * User email address validation. * - * @return array Arguments for testValidateEmail() + * Note: Technically these include unit tests, but let's keep it here for now. + * FIXME: Shall we do a http request for each case? */ - public function dataValidateEmail(): array + public function testValidateEmail(): void { - $this->refreshApplication(); + Queue::fake(); + $public_domains = Domain::getPublicDomains(); $domain = reset($public_domains); $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); + $folder = $this->getTestSharedFolder('folder-event@kolab.org'); + $folder->setAliases(['folder-alias1@kolab.org']); + $folder_del = $this->getTestSharedFolder('folder-test@kolabnow.com'); + $folder_del->setAliases(['folder-alias2@kolabnow.com']); + $folder_del->delete(); + $pub_group = $this->getTestGroup('group-test@kolabnow.com'); + $pub_group->delete(); + $priv_group = $this->getTestGroup('group-test@kolab.org'); + $resource = $this->getTestResource('resource-test@kolabnow.com'); + $resource->delete(); + + $cases = [ + // valid (user domain) + ["admin@kolab.org", $john, null], + + // valid (public domain) + ["test.test@$domain", $john, null], - return [ // Invalid format ["$domain", $john, 'The specified email is invalid.'], [".@$domain", $john, 'The specified email is invalid.'], ["test123456@localhost", $john, 'The specified domain is invalid.'], ["test123456@unknown-domain.org", $john, 'The specified domain is invalid.'], ["$domain", $john, 'The specified email is invalid.'], [".@$domain", $john, 'The specified email is invalid.'], // forbidden local part on public domains ["admin@$domain", $john, 'The specified email is not available.'], ["administrator@$domain", $john, 'The specified email is not available.'], // forbidden (other user's domain) ["testtest@kolab.org", $user, 'The specified domain is not available.'], - // existing alias of other user, to be a user email + // existing alias of other user ["jack.daniels@kolab.org", $john, 'The specified email is not available.'], - // valid (user domain) - ["admin@kolab.org", $john, null], + // An existing shared folder or folder alias + ["folder-event@kolab.org", $john, 'The specified email is not available.'], + ["folder-alias1@kolab.org", $john, 'The specified email is not available.'], - // valid (public domain) - ["test.test@$domain", $john, null], + // A soft-deleted shared folder or folder alias + ["folder-test@kolabnow.com", $john, 'The specified email is not available.'], + ["folder-alias2@kolabnow.com", $john, 'The specified email is not available.'], + + // A group + ["group-test@kolab.org", $john, 'The specified email is not available.'], + + // A soft-deleted group + ["group-test@kolabnow.com", $john, 'The specified email is not available.'], + + // A resource + ["resource-test1@kolab.org", $john, 'The specified email is not available.'], + + // A soft-deleted resource + ["resource-test@kolabnow.com", $john, 'The specified email is not available.'], ]; - } - /** - * User email address validation. - * - * Note: Technically these include unit tests, but let's keep it here for now. - * FIXME: Shall we do a http request for each case? - * - * @dataProvider dataValidateEmail - */ - public function testValidateEmail($email, $user, $expected_result): void - { - $result = UsersController::validateEmail($email, $user); - $this->assertSame($expected_result, $result); + foreach ($cases as $idx => $case) { + list($email, $user, $expected) = $case; + + $deleted = null; + $result = UsersController::validateEmail($email, $user, $deleted); + + $this->assertSame($expected, $result, "Case {$email}"); + $this->assertNull($deleted, "Case {$email}"); + } } /** * User email validation - tests for $deleted argument * * Note: Technically these include unit tests, but let's keep it here for now. * FIXME: Shall we do a http request for each case? */ public function testValidateEmailDeleted(): void { Queue::fake(); $john = $this->getTestUser('john@kolab.org'); $deleted_priv = $this->getTestUser('deleted@kolab.org'); $deleted_priv->delete(); $deleted_pub = $this->getTestUser('deleted@kolabnow.com'); $deleted_pub->delete(); $result = UsersController::validateEmail('deleted@kolab.org', $john, $deleted); $this->assertSame(null, $result); $this->assertSame($deleted_priv->id, $deleted->id); $result = UsersController::validateEmail('deleted@kolabnow.com', $john, $deleted); $this->assertSame('The specified email is not available.', $result); $this->assertSame(null, $deleted); $result = UsersController::validateEmail('jack@kolab.org', $john, $deleted); $this->assertSame('The specified email is not available.', $result); $this->assertSame(null, $deleted); - } - - /** - * User email validation - tests for an address being a group email address - * - * Note: Technically these include unit tests, but let's keep it here for now. - * FIXME: Shall we do a http request for each case? - */ - public function testValidateEmailGroup(): void - { - Queue::fake(); - $john = $this->getTestUser('john@kolab.org'); $pub_group = $this->getTestGroup('group-test@kolabnow.com'); $priv_group = $this->getTestGroup('group-test@kolab.org'); // A group in a public domain, existing $result = UsersController::validateEmail($pub_group->email, $john, $deleted); $this->assertSame('The specified email is not available.', $result); $this->assertNull($deleted); $pub_group->delete(); // A group in a public domain, deleted $result = UsersController::validateEmail($pub_group->email, $john, $deleted); $this->assertSame('The specified email is not available.', $result); $this->assertNull($deleted); // A group in a private domain, existing $result = UsersController::validateEmail($priv_group->email, $john, $deleted); $this->assertSame('The specified email is not available.', $result); $this->assertNull($deleted); $priv_group->delete(); // A group in a private domain, deleted $result = UsersController::validateEmail($priv_group->email, $john, $deleted); $this->assertSame(null, $result); $this->assertSame($priv_group->id, $deleted->id); + + // TODO: Test the same with a resource and shared folder } /** - * List of alias validation cases for testValidateAlias() + * User email alias validation. * - * @return array Arguments for testValidateAlias() + * Note: Technically these include unit tests, but let's keep it here for now. + * FIXME: Shall we do a http request for each case? */ - public function dataValidateAlias(): array + public function testValidateAlias(): void { - $this->refreshApplication(); + Queue::fake(); + $public_domains = Domain::getPublicDomains(); $domain = reset($public_domains); $john = $this->getTestUser('john@kolab.org'); $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); + $deleted_priv = $this->getTestUser('deleted@kolab.org'); + $deleted_priv->setAliases(['deleted-alias@kolab.org']); + $deleted_priv->delete(); + $deleted_pub = $this->getTestUser('deleted@kolabnow.com'); + $deleted_pub->setAliases(['deleted-alias@kolabnow.com']); + $deleted_pub->delete(); + $folder = $this->getTestSharedFolder('folder-event@kolab.org'); + $folder->setAliases(['folder-alias1@kolab.org']); + $folder_del = $this->getTestSharedFolder('folder-test@kolabnow.com'); + $folder_del->setAliases(['folder-alias2@kolabnow.com']); + $folder_del->delete(); + $group_priv = $this->getTestGroup('group-test@kolab.org'); + $group = $this->getTestGroup('group-test@kolabnow.com'); + $group->delete(); + $resource = $this->getTestResource('resource-test@kolabnow.com'); + $resource->delete(); - return [ + $cases = [ // Invalid format ["$domain", $john, 'The specified alias is invalid.'], [".@$domain", $john, 'The specified alias is invalid.'], ["test123456@localhost", $john, 'The specified domain is invalid.'], ["test123456@unknown-domain.org", $john, 'The specified domain is invalid.'], ["$domain", $john, 'The specified alias is invalid.'], [".@$domain", $john, 'The specified alias is invalid.'], // forbidden local part on public domains ["admin@$domain", $john, 'The specified alias is not available.'], ["administrator@$domain", $john, 'The specified alias is not available.'], // forbidden (other user's domain) ["testtest@kolab.org", $user, 'The specified domain is not available.'], // existing alias of other user, to be an alias, user in the same group account ["jack.daniels@kolab.org", $john, null], // existing user ["jack@kolab.org", $john, 'The specified alias is not available.'], // valid (user domain) ["admin@kolab.org", $john, null], // valid (public domain) ["test.test@$domain", $john, null], - ]; - } - /** - * User email alias validation. - * - * Note: Technically these include unit tests, but let's keep it here for now. - * FIXME: Shall we do a http request for each case? - * - * @dataProvider dataValidateAlias - */ - public function testValidateAlias($alias, $user, $expected_result): void - { - $result = UsersController::validateAlias($alias, $user); - $this->assertSame($expected_result, $result); - } + // An alias that was a user email before is allowed, but only for custom domains + ["deleted@kolab.org", $john, null], + ["deleted-alias@kolab.org", $john, null], + ["deleted@kolabnow.com", $john, 'The specified alias is not available.'], + ["deleted-alias@kolabnow.com", $john, 'The specified alias is not available.'], - /** - * User alias validation - more cases. - * - * Note: Technically these include unit tests, but let's keep it here for now. - * FIXME: Shall we do a http request for each case? - */ - public function testValidateAlias2(): void - { - Queue::fake(); + // An existing shared folder or folder alias + ["folder-event@kolab.org", $john, 'The specified alias is not available.'], + ["folder-alias1@kolab.org", $john, null], - $john = $this->getTestUser('john@kolab.org'); - $jack = $this->getTestUser('jack@kolab.org'); - $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); - $deleted_priv = $this->getTestUser('deleted@kolab.org'); - $deleted_priv->setAliases(['deleted-alias@kolab.org']); - $deleted_priv->delete(); - $deleted_pub = $this->getTestUser('deleted@kolabnow.com'); - $deleted_pub->setAliases(['deleted-alias@kolabnow.com']); - $deleted_pub->delete(); - $group = $this->getTestGroup('group-test@kolabnow.com'); + // A soft-deleted shared folder or folder alias + ["folder-test@kolabnow.com", $john, 'The specified alias is not available.'], + ["folder-alias2@kolabnow.com", $john, 'The specified alias is not available.'], - // An alias that was a user email before is allowed, but only for custom domains - $result = UsersController::validateAlias('deleted@kolab.org', $john); - $this->assertSame(null, $result); + // A group with the same email address exists + ["group-test@kolab.org", $john, 'The specified alias is not available.'], - $result = UsersController::validateAlias('deleted-alias@kolab.org', $john); - $this->assertSame(null, $result); + // A soft-deleted group + ["group-test@kolabnow.com", $john, 'The specified alias is not available.'], - $result = UsersController::validateAlias('deleted@kolabnow.com', $john); - $this->assertSame('The specified alias is not available.', $result); + // A resource + ["resource-test1@kolab.org", $john, 'The specified alias is not available.'], - $result = UsersController::validateAlias('deleted-alias@kolabnow.com', $john); - $this->assertSame('The specified alias is not available.', $result); + // A soft-deleted resource + ["resource-test@kolabnow.com", $john, 'The specified alias is not available.'], + ]; - // A grpoup with the same email address exists - $result = UsersController::validateAlias($group->email, $john); - $this->assertSame('The specified alias is not available.', $result); + foreach ($cases as $idx => $case) { + list($alias, $user, $expected) = $case; + $result = UsersController::validateAlias($alias, $user); + $this->assertSame($expected, $result, "Case {$alias}"); + } } } diff --git a/src/tests/Feature/ResourceTest.php b/src/tests/Feature/ResourceTest.php index 319ce544..b5e71ec3 100644 --- a/src/tests/Feature/ResourceTest.php +++ b/src/tests/Feature/ResourceTest.php @@ -1,352 +1,352 @@ deleteTestUser('user-test@kolabnow.com'); Resource::withTrashed()->where('email', 'like', '%@kolabnow.com')->each(function ($resource) { $this->deleteTestResource($resource->email); }); } public function tearDown(): void { $this->deleteTestUser('user-test@kolabnow.com'); Resource::withTrashed()->where('email', 'like', '%@kolabnow.com')->each(function ($resource) { $this->deleteTestResource($resource->email); }); parent::tearDown(); } /** * Tests for Resource::assignToWallet() */ public function testAssignToWallet(): void { $user = $this->getTestUser('user-test@kolabnow.com'); $resource = $this->getTestResource('resource-test@kolabnow.com'); $result = $resource->assignToWallet($user->wallets->first()); $this->assertSame($resource, $result); $this->assertSame(1, $resource->entitlements()->count()); // Can't be done twice on the same resource $this->expectException(\Exception::class); $result->assignToWallet($user->wallets->first()); } /** * Test Resource::getConfig() and setConfig() methods */ public function testConfigTrait(): void { Queue::fake(); $resource = new Resource(); $resource->email = 'resource-test@kolabnow.com'; $resource->name = 'Test'; $resource->save(); $john = $this->getTestUser('john@kolab.org'); $resource->assignToWallet($john->wallets->first()); $this->assertSame(['invitation_policy' => 'accept'], $resource->getConfig()); $result = $resource->setConfig(['invitation_policy' => 'reject', 'unknown' => false]); $this->assertSame(['invitation_policy' => 'reject'], $resource->getConfig()); $this->assertSame('reject', $resource->getSetting('invitation_policy')); $this->assertSame(['unknown' => "The requested configuration parameter is not supported."], $result); $result = $resource->setConfig(['invitation_policy' => 'unknown']); $this->assertSame(['invitation_policy' => 'reject'], $resource->getConfig()); $this->assertSame('reject', $resource->getSetting('invitation_policy')); $this->assertSame(['invitation_policy' => "The specified invitation policy is invalid."], $result); // Test valid user for manual invitation policy $result = $resource->setConfig(['invitation_policy' => 'manual:john@kolab.org']); $this->assertSame(['invitation_policy' => 'manual:john@kolab.org'], $resource->getConfig()); $this->assertSame('manual:john@kolab.org', $resource->getSetting('invitation_policy')); $this->assertSame([], $result); // Test invalid user email for manual invitation policy $result = $resource->setConfig(['invitation_policy' => 'manual:john']); $this->assertSame(['invitation_policy' => 'manual:john@kolab.org'], $resource->getConfig()); $this->assertSame('manual:john@kolab.org', $resource->getSetting('invitation_policy')); $this->assertSame(['invitation_policy' => "The specified email address is invalid."], $result); // Test non-existing user for manual invitation policy $result = $resource->setConfig(['invitation_policy' => 'manual:unknown@kolab.org']); $this->assertSame(['invitation_policy' => "The specified email address does not exist."], $result); // Test existing user from a different wallet, for manual invitation policy $result = $resource->setConfig(['invitation_policy' => 'manual:user@sample-tenant.dev-local']); $this->assertSame(['invitation_policy' => "The specified email address does not exist."], $result); } /** * Test creating a resource */ public function testCreate(): void { Queue::fake(); $resource = new Resource(); $resource->name = 'Reśo'; - $resource->domain = 'kolabnow.com'; + $resource->domainName = 'kolabnow.com'; $resource->save(); $this->assertMatchesRegularExpression('/^[0-9]{1,20}$/', $resource->id); $this->assertMatchesRegularExpression('/^resource-[0-9]{1,20}@kolabnow\.com$/', $resource->email); $this->assertSame('Reśo', $resource->name); $this->assertTrue($resource->isNew()); $this->assertTrue($resource->isActive()); $this->assertFalse($resource->isDeleted()); $this->assertFalse($resource->isLdapReady()); $this->assertFalse($resource->isImapReady()); $settings = $resource->settings()->get(); $this->assertCount(1, $settings); $this->assertSame('folder', $settings[0]->key); $this->assertSame('shared/Resources/Reśo@kolabnow.com', $settings[0]->value); Queue::assertPushed( \App\Jobs\Resource\CreateJob::class, function ($job) use ($resource) { $resourceEmail = TestCase::getObjectProperty($job, 'resourceEmail'); $resourceId = TestCase::getObjectProperty($job, 'resourceId'); return $resourceEmail === $resource->email && $resourceId === $resource->id; } ); Queue::assertPushedWithChain( \App\Jobs\Resource\CreateJob::class, [ \App\Jobs\Resource\VerifyJob::class, ] ); } /** * Test resource deletion and force-deletion */ public function testDelete(): void { Queue::fake(); $user = $this->getTestUser('user-test@kolabnow.com'); $resource = $this->getTestResource('resource-test@kolabnow.com'); $resource->assignToWallet($user->wallets->first()); $entitlements = \App\Entitlement::where('entitleable_id', $resource->id); $this->assertSame(1, $entitlements->count()); $resource->delete(); $this->assertTrue($resource->fresh()->trashed()); $this->assertSame(0, $entitlements->count()); $this->assertSame(1, $entitlements->withTrashed()->count()); $resource->forceDelete(); $this->assertSame(0, $entitlements->withTrashed()->count()); $this->assertCount(0, Resource::withTrashed()->where('id', $resource->id)->get()); Queue::assertPushed(\App\Jobs\Resource\DeleteJob::class, 1); Queue::assertPushed( \App\Jobs\Resource\DeleteJob::class, function ($job) use ($resource) { $resourceEmail = TestCase::getObjectProperty($job, 'resourceEmail'); $resourceId = TestCase::getObjectProperty($job, 'resourceId'); return $resourceEmail === $resource->email && $resourceId === $resource->id; } ); } /** * Tests for Resource::emailExists() */ public function testEmailExists(): void { Queue::fake(); $resource = $this->getTestResource('resource-test@kolabnow.com'); $this->assertFalse(Resource::emailExists('unknown@domain.tld')); $this->assertTrue(Resource::emailExists($resource->email)); $result = Resource::emailExists($resource->email, true); $this->assertSame($result->id, $resource->id); $resource->delete(); $this->assertTrue(Resource::emailExists($resource->email)); $result = Resource::emailExists($resource->email, true); $this->assertSame($result->id, $resource->id); } /** * Tests for SettingsTrait functionality and ResourceSettingObserver */ public function testSettings(): void { Queue::fake(); Queue::assertNothingPushed(); $resource = $this->getTestResource('resource-test@kolabnow.com'); Queue::assertPushed(\App\Jobs\Resource\UpdateJob::class, 0); // Add a setting $resource->setSetting('unknown', 'test'); Queue::assertPushed(\App\Jobs\Resource\UpdateJob::class, 0); // Add a setting that is synced to LDAP $resource->setSetting('invitation_policy', 'accept'); Queue::assertPushed(\App\Jobs\Resource\UpdateJob::class, 1); // Note: We test both current resource as well as fresh resource object // to make sure cache works as expected $this->assertSame('test', $resource->getSetting('unknown')); $this->assertSame('accept', $resource->fresh()->getSetting('invitation_policy')); Queue::fake(); // Update a setting $resource->setSetting('unknown', 'test1'); Queue::assertPushed(\App\Jobs\Resource\UpdateJob::class, 0); // Update a setting that is synced to LDAP $resource->setSetting('invitation_policy', 'reject'); Queue::assertPushed(\App\Jobs\Resource\UpdateJob::class, 1); $this->assertSame('test1', $resource->getSetting('unknown')); $this->assertSame('reject', $resource->fresh()->getSetting('invitation_policy')); Queue::fake(); // Delete a setting (null) $resource->setSetting('unknown', null); Queue::assertPushed(\App\Jobs\Resource\UpdateJob::class, 0); // Delete a setting that is synced to LDAP $resource->setSetting('invitation_policy', null); Queue::assertPushed(\App\Jobs\Resource\UpdateJob::class, 1); $this->assertSame(null, $resource->getSetting('unknown')); $this->assertSame(null, $resource->fresh()->getSetting('invitation_policy')); } /** * Test resource status assignment and is*() methods */ public function testStatus(): void { $resource = new Resource(); $this->assertSame(false, $resource->isNew()); $this->assertSame(false, $resource->isActive()); $this->assertSame(false, $resource->isDeleted()); $this->assertSame(false, $resource->isLdapReady()); $this->assertSame(false, $resource->isImapReady()); $resource->status = Resource::STATUS_NEW; $this->assertSame(true, $resource->isNew()); $this->assertSame(false, $resource->isActive()); $this->assertSame(false, $resource->isDeleted()); $this->assertSame(false, $resource->isLdapReady()); $this->assertSame(false, $resource->isImapReady()); $resource->status |= Resource::STATUS_ACTIVE; $this->assertSame(true, $resource->isNew()); $this->assertSame(true, $resource->isActive()); $this->assertSame(false, $resource->isDeleted()); $this->assertSame(false, $resource->isLdapReady()); $this->assertSame(false, $resource->isImapReady()); $resource->status |= Resource::STATUS_LDAP_READY; $this->assertSame(true, $resource->isNew()); $this->assertSame(true, $resource->isActive()); $this->assertSame(false, $resource->isDeleted()); $this->assertSame(true, $resource->isLdapReady()); $this->assertSame(false, $resource->isImapReady()); $resource->status |= Resource::STATUS_DELETED; $this->assertSame(true, $resource->isNew()); $this->assertSame(true, $resource->isActive()); $this->assertSame(true, $resource->isDeleted()); $this->assertSame(true, $resource->isLdapReady()); $this->assertSame(false, $resource->isImapReady()); $resource->status |= Resource::STATUS_IMAP_READY; $this->assertSame(true, $resource->isNew()); $this->assertSame(true, $resource->isActive()); $this->assertSame(true, $resource->isDeleted()); $this->assertSame(true, $resource->isLdapReady()); $this->assertSame(true, $resource->isImapReady()); // Unknown status value $this->expectException(\Exception::class); $resource->status = 111; } /** * Test updating a resource */ public function testUpdate(): void { Queue::fake(); $resource = $this->getTestResource('resource-test@kolabnow.com'); $resource->name = 'New'; $resource->save(); // Assert the folder changes on a resource name change $settings = $resource->settings()->where('key', 'folder')->get(); $this->assertCount(1, $settings); $this->assertSame('shared/Resources/New@kolabnow.com', $settings[0]->value); Queue::assertPushed(\App\Jobs\Resource\UpdateJob::class, 1); Queue::assertPushed( \App\Jobs\Resource\UpdateJob::class, function ($job) use ($resource) { $resourceEmail = TestCase::getObjectProperty($job, 'resourceEmail'); $resourceId = TestCase::getObjectProperty($job, 'resourceId'); return $resourceEmail === $resource->email && $resourceId === $resource->id; } ); } } diff --git a/src/tests/Feature/SharedFolderTest.php b/src/tests/Feature/SharedFolderTest.php index bf486ee6..e520aff0 100644 --- a/src/tests/Feature/SharedFolderTest.php +++ b/src/tests/Feature/SharedFolderTest.php @@ -1,304 +1,358 @@ deleteTestUser('user-test@kolabnow.com'); SharedFolder::withTrashed()->where('email', 'like', '%@kolabnow.com')->each(function ($folder) { $this->deleteTestSharedFolder($folder->email); }); } public function tearDown(): void { $this->deleteTestUser('user-test@kolabnow.com'); SharedFolder::withTrashed()->where('email', 'like', '%@kolabnow.com')->each(function ($folder) { $this->deleteTestSharedFolder($folder->email); }); parent::tearDown(); } + /** + * Tests for AliasesTrait methods + */ + public function testAliases(): void + { + Queue::fake(); + Queue::assertNothingPushed(); + + $folder = $this->getTestSharedFolder('folder-test@kolabnow.com'); + + $this->assertCount(0, $folder->aliases->all()); + + // Add an alias + $folder->setAliases(['FolderAlias1@kolabnow.com']); + + Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 1); + + $aliases = $folder->aliases()->get(); + + $this->assertCount(1, $aliases); + $this->assertSame('folderalias1@kolabnow.com', $aliases[0]->alias); + $this->assertTrue(SharedFolder::aliasExists('folderalias1@kolabnow.com')); + + // Add another alias + $folder->setAliases(['FolderAlias1@kolabnow.com', 'FolderAlias2@kolabnow.com']); + + Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 2); + + $aliases = $folder->aliases()->orderBy('alias')->get(); + $this->assertCount(2, $aliases); + $this->assertSame('folderalias1@kolabnow.com', $aliases[0]->alias); + $this->assertSame('folderalias2@kolabnow.com', $aliases[1]->alias); + + // Remove an alias + $folder->setAliases(['FolderAlias1@kolabnow.com']); + + Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 3); + + $aliases = $folder->aliases()->get(); + + $this->assertCount(1, $aliases); + $this->assertSame('folderalias1@kolabnow.com', $aliases[0]->alias); + $this->assertFalse(SharedFolder::aliasExists('folderalias2@kolabnow.com')); + + // Remove all aliases + $folder->setAliases([]); + + Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 4); + + $this->assertCount(0, $folder->aliases()->get()); + $this->assertFalse(SharedFolder::aliasExists('folderalias1@kolabnow.com')); + $this->assertFalse(SharedFolder::aliasExists('folderalias2@kolabnow.com')); + } + /** * Tests for SharedFolder::assignToWallet() */ public function testAssignToWallet(): void { $user = $this->getTestUser('user-test@kolabnow.com'); $folder = $this->getTestSharedFolder('folder-test@kolabnow.com'); $result = $folder->assignToWallet($user->wallets->first()); $this->assertSame($folder, $result); $this->assertSame(1, $folder->entitlements()->count()); $this->assertSame('shared-folder', $folder->entitlements()->first()->sku->title); // Can't be done twice on the same folder $this->expectException(\Exception::class); $result->assignToWallet($user->wallets->first()); } /** * Test SharedFolder::getConfig() and setConfig() methods */ public function testConfigTrait(): void { Queue::fake(); $folder = new SharedFolder(); $folder->email = 'folder-test@kolabnow.com'; $folder->name = 'Test'; $folder->save(); $john = $this->getTestUser('john@kolab.org'); $folder->assignToWallet($john->wallets->first()); $this->assertSame(['acl' => []], $folder->getConfig()); $result = $folder->setConfig(['acl' => ['anyone, read-only'], 'unknown' => false]); $this->assertSame(['acl' => ['anyone, read-only']], $folder->getConfig()); $this->assertSame('["anyone, read-only"]', $folder->getSetting('acl')); $this->assertSame(['unknown' => "The requested configuration parameter is not supported."], $result); $result = $folder->setConfig(['acl' => ['anyone, unknown']]); $this->assertSame(['acl' => ['anyone, read-only']], $folder->getConfig()); $this->assertSame('["anyone, read-only"]', $folder->getSetting('acl')); $this->assertSame(['acl' => ["The entry format is invalid. Expected an email address."]], $result); // Test valid user for ACL $result = $folder->setConfig(['acl' => ['john@kolab.org, full']]); $this->assertSame(['acl' => ['john@kolab.org, full']], $folder->getConfig()); $this->assertSame('["john@kolab.org, full"]', $folder->getSetting('acl')); $this->assertSame([], $result); // Test invalid user for ACL $result = $folder->setConfig(['acl' => ['john, full']]); $this->assertSame(['acl' => ['john@kolab.org, full']], $folder->getConfig()); $this->assertSame('["john@kolab.org, full"]', $folder->getSetting('acl')); $this->assertSame(['acl' => ["The specified email address is invalid."]], $result); // Other invalid entries $acl = [ // Test non-existing user for ACL 'unknown@kolab.org, full', // Test existing user from a different wallet 'user@sample-tenant.dev-local, read-only', // Valid entry 'john@kolab.org, read-write', ]; $result = $folder->setConfig(['acl' => $acl]); $this->assertCount(2, $result['acl']); $this->assertSame("The specified email address does not exist.", $result['acl'][0]); $this->assertSame("The specified email address does not exist.", $result['acl'][1]); $this->assertSame(['acl' => ['john@kolab.org, full']], $folder->getConfig()); $this->assertSame('["john@kolab.org, full"]', $folder->getSetting('acl')); } /** * Test creating a shared folder */ public function testCreate(): void { Queue::fake(); $folder = new SharedFolder(); $folder->name = 'Reśo'; - $folder->domain = 'kolabnow.com'; + $folder->domainName = 'kolabnow.com'; $folder->save(); $this->assertMatchesRegularExpression('/^[0-9]{1,20}$/', $folder->id); $this->assertMatchesRegularExpression('/^mail-[0-9]{1,20}@kolabnow\.com$/', $folder->email); $this->assertSame('Reśo', $folder->name); $this->assertTrue($folder->isNew()); $this->assertTrue($folder->isActive()); $this->assertFalse($folder->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/Stories/GreylistTest.php b/src/tests/Feature/Stories/GreylistTest.php index 0b4d5fb2..4926d669 100644 --- a/src/tests/Feature/Stories/GreylistTest.php +++ b/src/tests/Feature/Stories/GreylistTest.php @@ -1,433 +1,433 @@ setUpTest(); $this->useServicesUrl(); $this->clientAddress = '212.103.80.148'; $this->net = \App\IP4Net::getNet($this->clientAddress); DB::delete("DELETE FROM greylist_connect WHERE sender_domain = 'sender.domain';"); DB::delete("DELETE FROM greylist_whitelist WHERE sender_domain = 'sender.domain';"); } public function tearDown(): void { DB::delete("DELETE FROM greylist_connect WHERE sender_domain = 'sender.domain';"); DB::delete("DELETE FROM greylist_whitelist WHERE sender_domain = 'sender.domain';"); parent::tearDown(); } public function testWithTimestamp() { $request = new Greylist\Request( [ 'sender' => 'someone@sender.domain', 'recipient' => $this->domainOwner->email, 'client_address' => $this->clientAddress, 'client_name' => 'some.mx', 'timestamp' => \Carbon\Carbon::now()->subDays(7)->toString() ] ); $timestamp = $this->getObjectProperty($request, 'timestamp'); $this->assertTrue( \Carbon\Carbon::parse($timestamp, 'UTC') < \Carbon\Carbon::now() ); } public function testNoNet() { $request = new Greylist\Request( [ 'sender' => 'someone@sender.domain', 'recipient' => $this->domainOwner->email, 'client_address' => '127.128.129.130', 'client_name' => 'some.mx' ] ); $this->assertTrue($request->shouldDefer()); } public function testIp6Net() { $request = new Greylist\Request( [ 'sender' => 'someone@sender.domain', 'recipient' => $this->domainOwner->email, 'client_address' => '2a00:1450:400a:803::2005', 'client_name' => 'some.mx' ] ); $this->assertTrue($request->shouldDefer()); } // public function testMultiRecipientThroughAlias() {} public function testWhitelistNew() { $whitelist = Greylist\Whitelist::where('sender_domain', 'sender.domain')->first(); $this->assertNull($whitelist); for ($i = 0; $i < 5; $i++) { $request = new Greylist\Request( [ 'sender' => "someone{$i}@sender.domain", 'recipient' => $this->domainOwner->email, 'client_address' => $this->clientAddress, 'client_name' => 'some.mx', 'timestamp' => \Carbon\Carbon::now()->subDays(1) ] ); $this->assertTrue($request->shouldDefer()); } $whitelist = Greylist\Whitelist::where('sender_domain', 'sender.domain')->first(); $this->assertNotNull($whitelist); $request = new Greylist\Request( [ 'sender' => "someone5@sender.domain", 'recipient' => $this->domainOwner->email, 'client_address' => $this->clientAddress, 'client_name' => 'some.mx', 'timestamp' => \Carbon\Carbon::now()->subDays(1) ] ); $this->assertFalse($request->shouldDefer()); } // public function testWhitelistedHit() {} public function testWhitelistStale() { $whitelist = Greylist\Whitelist::where('sender_domain', 'sender.domain')->first(); $this->assertNull($whitelist); for ($i = 0; $i < 5; $i++) { $request = new Greylist\Request( [ 'sender' => "someone{$i}@sender.domain", 'recipient' => $this->domainOwner->email, 'client_address' => $this->clientAddress, 'client_name' => 'some.mx', 'timestamp' => \Carbon\Carbon::now()->subDays(1) ] ); $this->assertTrue($request->shouldDefer()); } $whitelist = Greylist\Whitelist::where('sender_domain', 'sender.domain')->first(); $this->assertNotNull($whitelist); $request = new Greylist\Request( [ 'sender' => "someone5@sender.domain", 'recipient' => $this->domainOwner->email, 'client_address' => $this->clientAddress, 'client_name' => 'some.mx', 'timestamp' => \Carbon\Carbon::now()->subDays(1) ] ); $this->assertFalse($request->shouldDefer()); $whitelist->updated_at = \Carbon\Carbon::now()->subMonthsWithoutOverflow(2); $whitelist->save(['timestamps' => false]); $this->assertTrue($request->shouldDefer()); } // public function testWhitelistUpdate() {} public function testRetry() { $connect = Greylist\Connect::create( [ 'sender_local' => 'someone', 'sender_domain' => 'sender.domain', 'recipient_hash' => hash('sha256', $this->domainOwner->email), 'recipient_id' => $this->domainOwner->id, 'recipient_type' => \App\User::class, 'connect_count' => 1, 'net_id' => $this->net->id, 'net_type' => \App\IP4Net::class ] ); $connect->created_at = \Carbon\Carbon::now()->subMinutes(6); $connect->save(); $request = new Greylist\Request( [ 'sender' => 'someone@sender.domain', 'recipient' => $this->domainOwner->email, 'client_address' => $this->clientAddress ] ); $this->assertFalse($request->shouldDefer()); } public function testInvalidRecipient() { $connect = Greylist\Connect::create( [ 'sender_local' => 'someone', 'sender_domain' => 'sender.domain', 'recipient_hash' => hash('sha256', $this->domainOwner->email), 'recipient_id' => 1234, 'recipient_type' => \App\Domain::class, 'connect_count' => 1, 'net_id' => $this->net->id, 'net_type' => \App\IP4Net::class ] ); $request = new Greylist\Request( [ 'sender' => 'someone@sender.domain', 'recipient' => 'not.someone@that.exists', 'client_address' => $this->clientAddress ] ); $this->assertTrue($request->shouldDefer()); } public function testUserDisabled() { $connect = Greylist\Connect::create( [ 'sender_local' => 'someone', 'sender_domain' => 'sender.domain', 'recipient_hash' => hash('sha256', $this->domainOwner->email), 'recipient_id' => $this->domainOwner->id, 'recipient_type' => \App\User::class, 'connect_count' => 1, 'net_id' => $this->net->id, 'net_type' => \App\IP4Net::class ] ); $this->domainOwner->setSetting('greylist_enabled', 'false'); $request = new Greylist\Request( [ 'sender' => 'someone@sender.domain', 'recipient' => $this->domainOwner->email, 'client_address' => $this->clientAddress ] ); $this->assertFalse($request->shouldDefer()); // Ensure we also find the setting by alias - $aliases = $this->domainOwner->aliases()->orderBy('alias')->get(); + $aliases = $this->domainOwner->aliases()->orderBy('alias')->pluck('alias')->all(); $request = new Greylist\Request( [ 'sender' => 'someone@sender.domain', - 'recipient' => $aliases[0]->alias, + 'recipient' => $aliases[0], 'client_address' => $this->clientAddress ] ); $this->assertFalse($request->shouldDefer()); } public function testUserEnabled() { $connect = Greylist\Connect::create( [ 'sender_local' => 'someone', 'sender_domain' => 'sender.domain', 'recipient_hash' => hash('sha256', $this->domainOwner->email), 'recipient_id' => $this->domainOwner->id, 'recipient_type' => \App\User::class, 'connect_count' => 1, 'net_id' => $this->net->id, 'net_type' => \App\IP4Net::class ] ); $this->domainOwner->setSetting('greylist_enabled', 'true'); $request = new Greylist\Request( [ 'sender' => 'someone@sender.domain', 'recipient' => $this->domainOwner->email, 'client_address' => $this->clientAddress ] ); $this->assertTrue($request->shouldDefer()); $connect->created_at = \Carbon\Carbon::now()->subMinutes(6); $connect->save(); $this->assertFalse($request->shouldDefer()); } public function testMultipleUsersAllDisabled() { $request = new Greylist\Request( [ 'sender' => 'someone@sender.domain', 'recipient' => $this->domainOwner->email, 'client_address' => $this->clientAddress ] ); foreach ($this->domainUsers as $user) { Greylist\Connect::create( [ 'sender_local' => 'someone', 'sender_domain' => 'sender.domain', 'recipient_hash' => hash('sha256', $user->email), 'recipient_id' => $user->id, 'recipient_type' => \App\User::class, 'connect_count' => 1, 'net_id' => $this->net->id, 'net_type' => \App\IP4Net::class ] ); $user->setSetting('greylist_enabled', 'false'); if ($user->email == $this->domainOwner->email) { continue; } $request = new Greylist\Request( [ 'sender' => 'someone@sender.domain', 'recipient' => $user->email, 'client_address' => $this->clientAddress ] ); $this->assertFalse($request->shouldDefer()); } } public function testMultipleUsersAnyEnabled() { $request = new Greylist\Request( [ 'sender' => 'someone@sender.domain', 'recipient' => $this->domainOwner->email, 'client_address' => $this->clientAddress ] ); foreach ($this->domainUsers as $user) { Greylist\Connect::create( [ 'sender_local' => 'someone', 'sender_domain' => 'sender.domain', 'recipient_hash' => hash('sha256', $user->email), 'recipient_id' => $user->id, 'recipient_type' => \App\User::class, 'connect_count' => 1, 'net_id' => $this->net->id, 'net_type' => \App\IP4Net::class ] ); $user->setSetting('greylist_enabled', ($user->id == $this->jack->id) ? 'true' : 'false'); if ($user->email == $this->domainOwner->email) { continue; } $request = new Greylist\Request( [ 'sender' => 'someone@sender.domain', 'recipient' => $user->email, 'client_address' => $this->clientAddress ] ); if ($user->id == $this->jack->id) { $this->assertTrue($request->shouldDefer()); } else { $this->assertFalse($request->shouldDefer()); } } } public function testControllerNew() { $data = [ 'sender' => 'someone@sender.domain', 'recipient' => $this->domainOwner->email, 'client_address' => $this->clientAddress, 'client_name' => 'some.mx' ]; $response = $this->post('/api/webhooks/policy/greylist', $data); $response->assertStatus(403); } public function testControllerNotNew() { $connect = Greylist\Connect::create( [ 'sender_local' => 'someone', 'sender_domain' => 'sender.domain', 'recipient_hash' => hash('sha256', $this->domainOwner->email), 'recipient_id' => $this->domainOwner->id, 'recipient_type' => \App\User::class, 'connect_count' => 1, 'net_id' => $this->net->id, 'net_type' => \App\IP4Net::class ] ); $connect->created_at = \Carbon\Carbon::now()->subMinutes(6); $connect->save(); $data = [ 'sender' => 'someone@sender.domain', 'recipient' => $this->domainOwner->email, 'client_address' => $this->clientAddress, 'client_name' => 'some.mx' ]; $response = $this->post('/api/webhooks/policy/greylist', $data); $response->assertStatus(200); } } diff --git a/src/tests/Feature/UserTest.php b/src/tests/Feature/UserTest.php index 84804adb..8d5c16d3 100644 --- a/src/tests/Feature/UserTest.php +++ b/src/tests/Feature/UserTest.php @@ -1,1326 +1,1326 @@ 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 { $user = $this->getTestUser('user-test@' . \config('app.domain')); $wallet = $user->wallets()->first(); $package = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $user->assignPackage($package); $sku = \App\Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); $entitlement = \App\Entitlement::where('wallet_id', $wallet->id) ->where('sku_id', $sku->id)->first(); $this->assertNotNull($entitlement); $this->assertSame($sku->id, $entitlement->sku->id); $this->assertSame($wallet->id, $entitlement->wallet->id); $this->assertEquals($user->id, $entitlement->entitleable->id); $this->assertTrue($entitlement->entitleable instanceof \App\User); $this->assertCount(7, $user->entitlements()->get()); } /** * Tests for User::assignPlan() */ public function testAssignPlan(): void { $this->markTestIncomplete(); } /** * Tests for User::assignSku() */ public function testAssignSku(): void { $this->markTestIncomplete(); } /** * Verify a wallet assigned a controller is among the accounts of the assignee. */ public function testAccounts(): void { $userA = $this->getTestUser('UserAccountA@UserAccount.com'); $userB = $this->getTestUser('UserAccountB@UserAccount.com'); $this->assertTrue($userA->wallets()->count() == 1); $userA->wallets()->each( function ($wallet) use ($userB) { $wallet->addController($userB); } ); $this->assertTrue($userB->accounts()->get()[0]->id === $userA->wallets()->get()[0]->id); } /** * Test User::canDelete() method */ public function testCanDelete(): void { $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $domain = $this->getTestDomain('kolab.org'); // Admin $this->assertTrue($admin->canDelete($admin)); $this->assertFalse($admin->canDelete($john)); $this->assertFalse($admin->canDelete($jack)); $this->assertFalse($admin->canDelete($reseller1)); $this->assertFalse($admin->canDelete($domain)); $this->assertFalse($admin->canDelete($domain->wallet())); // Reseller - kolabnow $this->assertFalse($reseller1->canDelete($john)); $this->assertFalse($reseller1->canDelete($jack)); $this->assertTrue($reseller1->canDelete($reseller1)); $this->assertFalse($reseller1->canDelete($domain)); $this->assertFalse($reseller1->canDelete($domain->wallet())); $this->assertFalse($reseller1->canDelete($admin)); // Normal user - account owner $this->assertTrue($john->canDelete($john)); $this->assertTrue($john->canDelete($ned)); $this->assertTrue($john->canDelete($jack)); $this->assertTrue($john->canDelete($domain)); $this->assertFalse($john->canDelete($domain->wallet())); $this->assertFalse($john->canDelete($reseller1)); $this->assertFalse($john->canDelete($admin)); // Normal user - a non-owner and non-controller $this->assertFalse($jack->canDelete($jack)); $this->assertFalse($jack->canDelete($john)); $this->assertFalse($jack->canDelete($domain)); $this->assertFalse($jack->canDelete($domain->wallet())); $this->assertFalse($jack->canDelete($reseller1)); $this->assertFalse($jack->canDelete($admin)); // Normal user - John's wallet controller $this->assertTrue($ned->canDelete($ned)); $this->assertTrue($ned->canDelete($john)); $this->assertTrue($ned->canDelete($jack)); $this->assertTrue($ned->canDelete($domain)); $this->assertFalse($ned->canDelete($domain->wallet())); $this->assertFalse($ned->canDelete($reseller1)); $this->assertFalse($ned->canDelete($admin)); } /** * Test User::canRead() method */ public function testCanRead(): void { $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $domain = $this->getTestDomain('kolab.org'); // Admin $this->assertTrue($admin->canRead($admin)); $this->assertTrue($admin->canRead($john)); $this->assertTrue($admin->canRead($jack)); $this->assertTrue($admin->canRead($reseller1)); $this->assertTrue($admin->canRead($reseller2)); $this->assertTrue($admin->canRead($domain)); $this->assertTrue($admin->canRead($domain->wallet())); // Reseller - kolabnow $this->assertTrue($reseller1->canRead($john)); $this->assertTrue($reseller1->canRead($jack)); $this->assertTrue($reseller1->canRead($reseller1)); $this->assertTrue($reseller1->canRead($domain)); $this->assertTrue($reseller1->canRead($domain->wallet())); $this->assertFalse($reseller1->canRead($reseller2)); $this->assertFalse($reseller1->canRead($admin)); // Reseller - different tenant $this->assertTrue($reseller2->canRead($reseller2)); $this->assertFalse($reseller2->canRead($john)); $this->assertFalse($reseller2->canRead($jack)); $this->assertFalse($reseller2->canRead($reseller1)); $this->assertFalse($reseller2->canRead($domain)); $this->assertFalse($reseller2->canRead($domain->wallet())); $this->assertFalse($reseller2->canRead($admin)); // Normal user - account owner $this->assertTrue($john->canRead($john)); $this->assertTrue($john->canRead($ned)); $this->assertTrue($john->canRead($jack)); $this->assertTrue($john->canRead($domain)); $this->assertTrue($john->canRead($domain->wallet())); $this->assertFalse($john->canRead($reseller1)); $this->assertFalse($john->canRead($reseller2)); $this->assertFalse($john->canRead($admin)); // Normal user - a non-owner and non-controller $this->assertTrue($jack->canRead($jack)); $this->assertFalse($jack->canRead($john)); $this->assertFalse($jack->canRead($domain)); $this->assertFalse($jack->canRead($domain->wallet())); $this->assertFalse($jack->canRead($reseller1)); $this->assertFalse($jack->canRead($reseller2)); $this->assertFalse($jack->canRead($admin)); // Normal user - John's wallet controller $this->assertTrue($ned->canRead($ned)); $this->assertTrue($ned->canRead($john)); $this->assertTrue($ned->canRead($jack)); $this->assertTrue($ned->canRead($domain)); $this->assertTrue($ned->canRead($domain->wallet())); $this->assertFalse($ned->canRead($reseller1)); $this->assertFalse($ned->canRead($reseller2)); $this->assertFalse($ned->canRead($admin)); } /** * Test User::canUpdate() method */ public function testCanUpdate(): void { $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $domain = $this->getTestDomain('kolab.org'); // Admin $this->assertTrue($admin->canUpdate($admin)); $this->assertTrue($admin->canUpdate($john)); $this->assertTrue($admin->canUpdate($jack)); $this->assertTrue($admin->canUpdate($reseller1)); $this->assertTrue($admin->canUpdate($reseller2)); $this->assertTrue($admin->canUpdate($domain)); $this->assertTrue($admin->canUpdate($domain->wallet())); // Reseller - kolabnow $this->assertTrue($reseller1->canUpdate($john)); $this->assertTrue($reseller1->canUpdate($jack)); $this->assertTrue($reseller1->canUpdate($reseller1)); $this->assertTrue($reseller1->canUpdate($domain)); $this->assertTrue($reseller1->canUpdate($domain->wallet())); $this->assertFalse($reseller1->canUpdate($reseller2)); $this->assertFalse($reseller1->canUpdate($admin)); // Reseller - different tenant $this->assertTrue($reseller2->canUpdate($reseller2)); $this->assertFalse($reseller2->canUpdate($john)); $this->assertFalse($reseller2->canUpdate($jack)); $this->assertFalse($reseller2->canUpdate($reseller1)); $this->assertFalse($reseller2->canUpdate($domain)); $this->assertFalse($reseller2->canUpdate($domain->wallet())); $this->assertFalse($reseller2->canUpdate($admin)); // Normal user - account owner $this->assertTrue($john->canUpdate($john)); $this->assertTrue($john->canUpdate($ned)); $this->assertTrue($john->canUpdate($jack)); $this->assertTrue($john->canUpdate($domain)); $this->assertFalse($john->canUpdate($domain->wallet())); $this->assertFalse($john->canUpdate($reseller1)); $this->assertFalse($john->canUpdate($reseller2)); $this->assertFalse($john->canUpdate($admin)); // Normal user - a non-owner and non-controller $this->assertTrue($jack->canUpdate($jack)); $this->assertFalse($jack->canUpdate($john)); $this->assertFalse($jack->canUpdate($domain)); $this->assertFalse($jack->canUpdate($domain->wallet())); $this->assertFalse($jack->canUpdate($reseller1)); $this->assertFalse($jack->canUpdate($reseller2)); $this->assertFalse($jack->canUpdate($admin)); // Normal user - John's wallet controller $this->assertTrue($ned->canUpdate($ned)); $this->assertTrue($ned->canUpdate($john)); $this->assertTrue($ned->canUpdate($jack)); $this->assertTrue($ned->canUpdate($domain)); $this->assertFalse($ned->canUpdate($domain->wallet())); $this->assertFalse($ned->canUpdate($reseller1)); $this->assertFalse($ned->canUpdate($reseller2)); $this->assertFalse($ned->canUpdate($admin)); } /** * Test user created/creating/updated observers */ public function testCreateAndUpdate(): void { Queue::fake(); $domain = \config('app.domain'); $user = User::create([ 'email' => 'USER-test@' . \strtoupper($domain), 'password' => 'test', ]); $result = User::where('email', "user-test@$domain")->first(); $this->assertSame("user-test@$domain", $result->email); $this->assertSame($user->id, $result->id); $this->assertSame(User::STATUS_NEW | User::STATUS_ACTIVE, $result->status); $this->assertSame(0, $user->passwords()->count()); Queue::assertPushed(\App\Jobs\User\CreateJob::class, 1); Queue::assertPushed(\App\Jobs\PGP\KeyCreateJob::class, 0); Queue::assertPushed( \App\Jobs\User\CreateJob::class, function ($job) use ($user) { $userEmail = TestCase::getObjectProperty($job, 'userEmail'); $userId = TestCase::getObjectProperty($job, 'userId'); return $userEmail === $user->email && $userId === $user->id; } ); Queue::assertPushedWithChain( \App\Jobs\User\CreateJob::class, [ \App\Jobs\User\VerifyJob::class, ] ); /* FIXME: Looks like we can't really do detailed assertions on chained jobs Another thing to consider is if we maybe should run these jobs independently (not chained) and make sure there's no race-condition in status update Queue::assertPushed(\App\Jobs\User\VerifyJob::class, 1); Queue::assertPushed(\App\Jobs\User\VerifyJob::class, function ($job) use ($user) { $userEmail = TestCase::getObjectProperty($job, 'userEmail'); $userId = TestCase::getObjectProperty($job, 'userId'); return $userEmail === $user->email && $userId === $user->id; }); */ // Test invoking KeyCreateJob $this->deleteTestUser("user-test@$domain"); \App\Tenant::find(\config('app.tenant_id'))->setSetting('pgp.enable', 1); $user = User::create(['email' => "user-test@$domain", 'password' => 'test']); Queue::assertPushed(\App\Jobs\PGP\KeyCreateJob::class, 1); Queue::assertPushed( \App\Jobs\PGP\KeyCreateJob::class, function ($job) use ($user) { $userEmail = TestCase::getObjectProperty($job, 'userEmail'); $userId = TestCase::getObjectProperty($job, 'userId'); return $userEmail === $user->email && $userId === $user->id; } ); // Update the user, test the password change $oldPassword = $user->password; $user->password = 'test123'; $user->save(); $this->assertNotEquals($oldPassword, $user->password); $this->assertSame(0, $user->passwords()->count()); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1); Queue::assertPushed( \App\Jobs\User\UpdateJob::class, function ($job) use ($user) { $userEmail = TestCase::getObjectProperty($job, 'userEmail'); $userId = TestCase::getObjectProperty($job, 'userId'); return $userEmail === $user->email && $userId === $user->id; } ); // Update the user, test the password history $user->setSetting('password_policy', 'last:3'); $oldPassword = $user->password; $user->password = 'test1234'; $user->save(); $this->assertSame(1, $user->passwords()->count()); $this->assertSame($oldPassword, $user->passwords()->first()->password); $user->password = 'test12345'; $user->save(); $oldPassword = $user->password; $user->password = 'test123456'; $user->save(); $this->assertSame(2, $user->passwords()->count()); $this->assertSame($oldPassword, $user->passwords()->latest()->first()->password); } /** * Tests for User::domains() */ public function testDomains(): void { $user = $this->getTestUser('john@kolab.org'); $domain = $this->getTestDomain('useraccount.com', [ 'status' => Domain::STATUS_NEW | Domain::STATUS_ACTIVE, 'type' => Domain::TYPE_PUBLIC, ]); $domains = $user->domains()->pluck('namespace')->all(); $this->assertContains($domain->namespace, $domains); $this->assertContains('kolab.org', $domains); // Jack is not the wallet controller, so for him the list should not // include John's domains, kolab.org specifically $user = $this->getTestUser('jack@kolab.org'); $domains = $user->domains()->pluck('namespace')->all(); $this->assertContains($domain->namespace, $domains); $this->assertNotContains('kolab.org', $domains); // Public domains of other tenants should not be returned $tenant = \App\Tenant::where('id', '!=', \config('app.tenant_id'))->first(); $domain->tenant_id = $tenant->id; $domain->save(); $domains = $user->domains()->pluck('namespace')->all(); $this->assertNotContains($domain->namespace, $domains); } /** * Test User::getConfig() and setConfig() methods */ public function testConfigTrait(): void { $john = $this->getTestUser('john@kolab.org'); $john->setSetting('greylist_enabled', null); $john->setSetting('password_policy', null); // Greylist_enabled $this->assertSame(true, $john->getConfig()['greylist_enabled']); $result = $john->setConfig(['greylist_enabled' => false, 'unknown' => false]); $this->assertSame(['unknown' => "The requested configuration parameter is not supported."], $result); $this->assertSame(false, $john->getConfig()['greylist_enabled']); $this->assertSame('false', $john->getSetting('greylist_enabled')); $result = $john->setConfig(['greylist_enabled' => true]); $this->assertSame([], $result); $this->assertSame(true, $john->getConfig()['greylist_enabled']); $this->assertSame('true', $john->getSetting('greylist_enabled')); // Password_policy $result = $john->setConfig(['password_policy' => true]); $this->assertSame(['password_policy' => "Specified password policy is invalid."], $result); $this->assertSame(null, $john->getConfig()['password_policy']); $this->assertSame(null, $john->getSetting('password_policy')); $result = $john->setConfig(['password_policy' => 'min:-1']); $this->assertSame(['password_policy' => "Specified password policy is invalid."], $result); $result = $john->setConfig(['password_policy' => 'min:-1']); $this->assertSame(['password_policy' => "Specified password policy is invalid."], $result); $result = $john->setConfig(['password_policy' => 'min:10,unknown']); $this->assertSame(['password_policy' => "Specified password policy is invalid."], $result); \config(['app.password_policy' => 'min:5,max:100']); $result = $john->setConfig(['password_policy' => 'min:4,max:255']); $this->assertSame(['password_policy' => "Minimum password length cannot be less than 5."], $result); \config(['app.password_policy' => 'min:5,max:100']); $result = $john->setConfig(['password_policy' => 'min:10,max:255']); $this->assertSame(['password_policy' => "Maximum password length cannot be more than 100."], $result); \config(['app.password_policy' => 'min:5,max:255']); $result = $john->setConfig(['password_policy' => 'min:10,max:255']); $this->assertSame([], $result); $this->assertSame('min:10,max:255', $john->getConfig()['password_policy']); $this->assertSame('min:10,max:255', $john->getSetting('password_policy')); } /** * Test user account degradation and un-degradation */ public function testDegradeAndUndegrade(): void { Queue::fake(); // Test an account with users, domain $userA = $this->getTestUser('UserAccountA@UserAccount.com'); $userB = $this->getTestUser('UserAccountB@UserAccount.com'); $package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $domain = $this->getTestDomain('UserAccount.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_HOSTED, ]); $userA->assignPackage($package_kolab); $domain->assignPackage($package_domain, $userA); $userA->assignPackage($package_kolab, $userB); $entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id); $entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id); $entitlementsDomain = \App\Entitlement::where('entitleable_id', $domain->id); $yesterday = Carbon::now()->subDays(1); $this->backdateEntitlements($entitlementsA->get(), $yesterday, Carbon::now()->subMonthsWithoutOverflow(1)); $this->backdateEntitlements($entitlementsB->get(), $yesterday, Carbon::now()->subMonthsWithoutOverflow(1)); $wallet = $userA->wallets->first(); $this->assertSame(7, $entitlementsA->count()); $this->assertSame(7, $entitlementsB->count()); $this->assertSame(7, $entitlementsA->whereDate('updated_at', $yesterday->toDateString())->count()); $this->assertSame(7, $entitlementsB->whereDate('updated_at', $yesterday->toDateString())->count()); $this->assertSame(0, $wallet->balance); Queue::fake(); // reset queue state // Degrade the account/wallet owner $userA->degrade(); $entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id); $entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id); $this->assertTrue($userA->fresh()->isDegraded()); $this->assertTrue($userA->fresh()->isDegraded(true)); $this->assertFalse($userB->fresh()->isDegraded()); $this->assertTrue($userB->fresh()->isDegraded(true)); $balance = $wallet->fresh()->balance; $this->assertTrue($balance <= -64); $this->assertSame(7, $entitlementsA->whereDate('updated_at', Carbon::now()->toDateString())->count()); $this->assertSame(7, $entitlementsB->whereDate('updated_at', Carbon::now()->toDateString())->count()); // Expect one update job for every user // @phpstan-ignore-next-line $userIds = Queue::pushed(\App\Jobs\User\UpdateJob::class)->map(function ($job) { return TestCase::getObjectProperty($job, 'userId'); })->all(); $this->assertSame([$userA->id, $userB->id], $userIds); // Un-Degrade the account/wallet owner $entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id); $entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id); $yesterday = Carbon::now()->subDays(1); $this->backdateEntitlements($entitlementsA->get(), $yesterday, Carbon::now()->subMonthsWithoutOverflow(1)); $this->backdateEntitlements($entitlementsB->get(), $yesterday, Carbon::now()->subMonthsWithoutOverflow(1)); Queue::fake(); // reset queue state $userA->undegrade(); $this->assertFalse($userA->fresh()->isDegraded()); $this->assertFalse($userA->fresh()->isDegraded(true)); $this->assertFalse($userB->fresh()->isDegraded()); $this->assertFalse($userB->fresh()->isDegraded(true)); // Expect no balance change, degraded account entitlements are free $this->assertSame($balance, $wallet->fresh()->balance); $this->assertSame(7, $entitlementsA->whereDate('updated_at', Carbon::now()->toDateString())->count()); $this->assertSame(7, $entitlementsB->whereDate('updated_at', Carbon::now()->toDateString())->count()); // Expect one update job for every user // @phpstan-ignore-next-line $userIds = Queue::pushed(\App\Jobs\User\UpdateJob::class)->map(function ($job) { return TestCase::getObjectProperty($job, 'userId'); })->all(); $this->assertSame([$userA->id, $userB->id], $userIds); } /** * Test user deletion */ public function testDelete(): void { Queue::fake(); $user = $this->getTestUser('user-test@' . \config('app.domain')); $package = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $user->assignPackage($package); $id = $user->id; $this->assertCount(7, $user->entitlements()->get()); $user->delete(); $this->assertCount(0, $user->entitlements()->get()); $this->assertTrue($user->fresh()->trashed()); $this->assertFalse($user->fresh()->isDeleted()); // Delete the user for real $job = new \App\Jobs\User\DeleteJob($id); $job->handle(); $this->assertTrue(User::withTrashed()->where('id', $id)->first()->isDeleted()); $user->forceDelete(); $this->assertCount(0, User::withTrashed()->where('id', $id)->get()); // Test an account with users, domain, and group, and resource $userA = $this->getTestUser('UserAccountA@UserAccount.com'); $userB = $this->getTestUser('UserAccountB@UserAccount.com'); $userC = $this->getTestUser('UserAccountC@UserAccount.com'); $package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $domain = $this->getTestDomain('UserAccount.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_HOSTED, ]); $userA->assignPackage($package_kolab); $domain->assignPackage($package_domain, $userA); $userA->assignPackage($package_kolab, $userB); $userA->assignPackage($package_kolab, $userC); $group = $this->getTestGroup('test-group@UserAccount.com'); $group->assignToWallet($userA->wallets->first()); $resource = $this->getTestResource('test-resource@UserAccount.com', ['name' => 'test']); $resource->assignToWallet($userA->wallets->first()); $folder = $this->getTestSharedFolder('test-folder@UserAccount.com', ['name' => 'test']); $folder->assignToWallet($userA->wallets->first()); $entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id); $entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id); $entitlementsC = \App\Entitlement::where('entitleable_id', $userC->id); $entitlementsDomain = \App\Entitlement::where('entitleable_id', $domain->id); $entitlementsGroup = \App\Entitlement::where('entitleable_id', $group->id); $entitlementsResource = \App\Entitlement::where('entitleable_id', $resource->id); $entitlementsFolder = \App\Entitlement::where('entitleable_id', $folder->id); $this->assertSame(7, $entitlementsA->count()); $this->assertSame(7, $entitlementsB->count()); $this->assertSame(7, $entitlementsC->count()); $this->assertSame(1, $entitlementsDomain->count()); $this->assertSame(1, $entitlementsGroup->count()); $this->assertSame(1, $entitlementsResource->count()); $this->assertSame(1, $entitlementsFolder->count()); // Delete non-controller user $userC->delete(); $this->assertTrue($userC->fresh()->trashed()); $this->assertFalse($userC->fresh()->isDeleted()); $this->assertSame(0, $entitlementsC->count()); // Delete the controller (and expect "sub"-users to be deleted too) $userA->delete(); $this->assertSame(0, $entitlementsA->count()); $this->assertSame(0, $entitlementsB->count()); $this->assertSame(0, $entitlementsDomain->count()); $this->assertSame(0, $entitlementsGroup->count()); $this->assertSame(0, $entitlementsResource->count()); $this->assertSame(0, $entitlementsFolder->count()); $this->assertSame(7, $entitlementsA->withTrashed()->count()); $this->assertSame(7, $entitlementsB->withTrashed()->count()); $this->assertSame(7, $entitlementsC->withTrashed()->count()); $this->assertSame(1, $entitlementsDomain->withTrashed()->count()); $this->assertSame(1, $entitlementsGroup->withTrashed()->count()); $this->assertSame(1, $entitlementsResource->withTrashed()->count()); $this->assertSame(1, $entitlementsFolder->withTrashed()->count()); $this->assertTrue($userA->fresh()->trashed()); $this->assertTrue($userB->fresh()->trashed()); $this->assertTrue($domain->fresh()->trashed()); $this->assertTrue($group->fresh()->trashed()); $this->assertTrue($resource->fresh()->trashed()); $this->assertTrue($folder->fresh()->trashed()); $this->assertFalse($userA->isDeleted()); $this->assertFalse($userB->isDeleted()); $this->assertFalse($domain->isDeleted()); $this->assertFalse($group->isDeleted()); $this->assertFalse($resource->isDeleted()); $this->assertFalse($folder->isDeleted()); $userA->forceDelete(); $all_entitlements = \App\Entitlement::where('wallet_id', $userA->wallets->first()->id); $transactions = \App\Transaction::where('object_id', $userA->wallets->first()->id); $this->assertSame(0, $all_entitlements->withTrashed()->count()); $this->assertSame(0, $transactions->count()); $this->assertCount(0, User::withTrashed()->where('id', $userA->id)->get()); $this->assertCount(0, User::withTrashed()->where('id', $userB->id)->get()); $this->assertCount(0, User::withTrashed()->where('id', $userC->id)->get()); $this->assertCount(0, Domain::withTrashed()->where('id', $domain->id)->get()); $this->assertCount(0, Group::withTrashed()->where('id', $group->id)->get()); $this->assertCount(0, \App\Resource::withTrashed()->where('id', $resource->id)->get()); $this->assertCount(0, \App\SharedFolder::withTrashed()->where('id', $folder->id)->get()); } /** * Test user deletion vs. group membership */ public function testDeleteAndGroups(): void { Queue::fake(); $package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $userA = $this->getTestUser('UserAccountA@UserAccount.com'); $userB = $this->getTestUser('UserAccountB@UserAccount.com'); $userA->assignPackage($package_kolab, $userB); $group = $this->getTestGroup('test-group@UserAccount.com'); $group->members = ['test@gmail.com', $userB->email]; $group->assignToWallet($userA->wallets->first()); $group->save(); Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 1); $userGroups = $userA->groups()->get(); $this->assertSame(1, $userGroups->count()); $this->assertSame($group->id, $userGroups->first()->id); $userB->delete(); $this->assertSame(['test@gmail.com'], $group->fresh()->members); // Twice, one for save() and one for delete() above Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 2); } /** * Test handling negative balance on user deletion */ public function testDeleteWithNegativeBalance(): void { $user = $this->getTestUser('user-test@' . \config('app.domain')); $wallet = $user->wallets()->first(); $wallet->balance = -1000; $wallet->save(); $reseller_wallet = $user->tenant->wallet(); $reseller_wallet->balance = 0; $reseller_wallet->save(); \App\Transaction::where('object_id', $reseller_wallet->id)->where('object_type', \App\Wallet::class)->delete(); $user->delete(); $reseller_transactions = \App\Transaction::where('object_id', $reseller_wallet->id) ->where('object_type', \App\Wallet::class)->get(); $this->assertSame(-1000, $reseller_wallet->fresh()->balance); $this->assertCount(1, $reseller_transactions); $trans = $reseller_transactions[0]; $this->assertSame("Deleted user {$user->email}", $trans->description); $this->assertSame(-1000, $trans->amount); $this->assertSame(\App\Transaction::WALLET_DEBIT, $trans->type); } /** * Test handling positive balance on user deletion */ public function testDeleteWithPositiveBalance(): void { $user = $this->getTestUser('user-test@' . \config('app.domain')); $wallet = $user->wallets()->first(); $wallet->balance = 1000; $wallet->save(); $reseller_wallet = $user->tenant->wallet(); $reseller_wallet->balance = 0; $reseller_wallet->save(); $user->delete(); $this->assertSame(0, $reseller_wallet->fresh()->balance); } /** * Test user deletion with PGP/WOAT enabled */ public function testDeleteWithPGP(): void { Queue::fake(); // Test with PGP disabled $user = $this->getTestUser('user-test@' . \config('app.domain')); $user->tenant->setSetting('pgp.enable', 0); $user->delete(); Queue::assertPushed(\App\Jobs\PGP\KeyDeleteJob::class, 0); // Test with PGP enabled $this->deleteTestUser('user-test@' . \config('app.domain')); $user = $this->getTestUser('user-test@' . \config('app.domain')); $user->tenant->setSetting('pgp.enable', 1); $user->delete(); $user->tenant->setSetting('pgp.enable', 0); Queue::assertPushed(\App\Jobs\PGP\KeyDeleteJob::class, 1); Queue::assertPushed( \App\Jobs\PGP\KeyDeleteJob::class, function ($job) use ($user) { $userId = TestCase::getObjectProperty($job, 'userId'); $userEmail = TestCase::getObjectProperty($job, 'userEmail'); return $userId == $user->id && $userEmail === $user->email; } ); } /** * Tests for User::aliasExists() */ public function testAliasExists(): void { $this->assertTrue(User::aliasExists('jack.daniels@kolab.org')); $this->assertFalse(User::aliasExists('j.daniels@kolab.org')); $this->assertFalse(User::aliasExists('john@kolab.org')); } /** * Tests for User::emailExists() */ public function testEmailExists(): void { $this->assertFalse(User::emailExists('jack.daniels@kolab.org')); $this->assertFalse(User::emailExists('j.daniels@kolab.org')); $this->assertTrue(User::emailExists('john@kolab.org')); $user = User::emailExists('john@kolab.org', true); $this->assertSame('john@kolab.org', $user->email); } /** * Tests for User::findByEmail() */ public function testFindByEmail(): void { $user = $this->getTestUser('john@kolab.org'); $result = User::findByEmail('john'); $this->assertNull($result); $result = User::findByEmail('non-existing@email.com'); $this->assertNull($result); $result = User::findByEmail('john@kolab.org'); $this->assertInstanceOf(User::class, $result); $this->assertSame($user->id, $result->id); // Use an alias $result = User::findByEmail('john.doe@kolab.org'); $this->assertInstanceOf(User::class, $result); $this->assertSame($user->id, $result->id); Queue::fake(); // A case where two users have the same alias $ned = $this->getTestUser('ned@kolab.org'); $ned->setAliases(['joe.monster@kolab.org']); $result = User::findByEmail('joe.monster@kolab.org'); $this->assertNull($result); $ned->setAliases([]); // TODO: searching by external email (setting) $this->markTestIncomplete(); } /** * Test User::hasSku() method */ 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')); } /** * 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() + * Tests for AliasesTrait::setAliases() */ public function testSetAliases(): void { Queue::fake(); Queue::assertNothingPushed(); $user = $this->getTestUser('UserAccountA@UserAccount.com'); $domain = $this->getTestDomain('UserAccount.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_HOSTED, ]); $this->assertCount(0, $user->aliases->all()); $user->tenant->setSetting('pgp.enable', 1); // Add an alias $user->setAliases(['UserAlias1@UserAccount.com']); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1); Queue::assertPushed(\App\Jobs\PGP\KeyCreateJob::class, 1); $user->tenant->setSetting('pgp.enable', 0); $aliases = $user->aliases()->get(); $this->assertCount(1, $aliases); $this->assertSame('useralias1@useraccount.com', $aliases[0]['alias']); // Add another alias $user->setAliases(['UserAlias1@UserAccount.com', 'UserAlias2@UserAccount.com']); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 2); Queue::assertPushed(\App\Jobs\PGP\KeyCreateJob::class, 1); $aliases = $user->aliases()->orderBy('alias')->get(); $this->assertCount(2, $aliases); $this->assertSame('useralias1@useraccount.com', $aliases[0]->alias); $this->assertSame('useralias2@useraccount.com', $aliases[1]->alias); $user->tenant->setSetting('pgp.enable', 1); // Remove an alias $user->setAliases(['UserAlias1@UserAccount.com']); $user->tenant->setSetting('pgp.enable', 0); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 3); Queue::assertPushed(\App\Jobs\PGP\KeyDeleteJob::class, 1); Queue::assertPushed( \App\Jobs\PGP\KeyDeleteJob::class, function ($job) use ($user) { $userId = TestCase::getObjectProperty($job, 'userId'); $userEmail = TestCase::getObjectProperty($job, 'userEmail'); return $userId == $user->id && $userEmail === 'useralias2@useraccount.com'; } ); $aliases = $user->aliases()->get(); $this->assertCount(1, $aliases); $this->assertSame('useralias1@useraccount.com', $aliases[0]['alias']); // Remove all aliases $user->setAliases([]); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 4); $this->assertCount(0, $user->aliases()->get()); } /** * Tests for UserSettingsTrait::setSettings() and getSetting() and getSettings() */ public function testUserSettings(): void { Queue::fake(); Queue::assertNothingPushed(); $user = $this->getTestUser('UserAccountA@UserAccount.com'); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 0); // Test default settings // Note: Technicly this tests UserObserver::created() behavior $all_settings = $user->settings()->orderBy('key')->get(); $this->assertCount(2, $all_settings); $this->assertSame('country', $all_settings[0]->key); $this->assertSame('CH', $all_settings[0]->value); $this->assertSame('currency', $all_settings[1]->key); $this->assertSame('CHF', $all_settings[1]->value); // Add a setting $user->setSetting('first_name', 'Firstname'); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1); // Note: We test both current user as well as fresh user object // to make sure cache works as expected $this->assertSame('Firstname', $user->getSetting('first_name')); $this->assertSame('Firstname', $user->fresh()->getSetting('first_name')); // Update a setting $user->setSetting('first_name', 'Firstname1'); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 2); // Note: We test both current user as well as fresh user object // to make sure cache works as expected $this->assertSame('Firstname1', $user->getSetting('first_name')); $this->assertSame('Firstname1', $user->fresh()->getSetting('first_name')); // Delete a setting (null) $user->setSetting('first_name', null); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 3); // Note: We test both current user as well as fresh user object // to make sure cache works as expected $this->assertSame(null, $user->getSetting('first_name')); $this->assertSame(null, $user->fresh()->getSetting('first_name')); // Delete a setting (empty string) $user->setSetting('first_name', 'Firstname1'); $user->setSetting('first_name', ''); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 5); // Note: We test both current user as well as fresh user object // to make sure cache works as expected $this->assertSame(null, $user->getSetting('first_name')); $this->assertSame(null, $user->fresh()->getSetting('first_name')); // Set multiple settings at once $user->setSettings([ 'first_name' => 'Firstname2', 'last_name' => 'Lastname2', 'country' => null, ]); // TODO: This really should create a single UserUpdate job, not 3 Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 7); // Note: We test both current user as well as fresh user object // to make sure cache works as expected $this->assertSame('Firstname2', $user->getSetting('first_name')); $this->assertSame('Firstname2', $user->fresh()->getSetting('first_name')); $this->assertSame('Lastname2', $user->getSetting('last_name')); $this->assertSame('Lastname2', $user->fresh()->getSetting('last_name')); $this->assertSame(null, $user->getSetting('country')); $this->assertSame(null, $user->fresh()->getSetting('country')); $all_settings = $user->settings()->orderBy('key')->get(); $this->assertCount(3, $all_settings); // Test getSettings() method $this->assertSame( [ 'first_name' => 'Firstname2', 'last_name' => 'Lastname2', 'unknown' => null, ], $user->getSettings(['first_name', 'last_name', 'unknown']) ); } /** * Tests for User::users() */ public function testUsers(): void { $jack = $this->getTestUser('jack@kolab.org'); $joe = $this->getTestUser('joe@kolab.org'); $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $wallet = $john->wallets()->first(); $users = $john->users()->orderBy('email')->get(); $this->assertCount(4, $users); $this->assertEquals($jack->id, $users[0]->id); $this->assertEquals($joe->id, $users[1]->id); $this->assertEquals($john->id, $users[2]->id); $this->assertEquals($ned->id, $users[3]->id); $users = $jack->users()->orderBy('email')->get(); $this->assertCount(0, $users); $users = $ned->users()->orderBy('email')->get(); $this->assertCount(4, $users); } /** * Tests for User::walletOwner() (from EntitleableTrait) */ public function testWalletOwner(): void { $jack = $this->getTestUser('jack@kolab.org'); $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $this->assertSame($john->id, $john->walletOwner()->id); $this->assertSame($john->id, $jack->walletOwner()->id); $this->assertSame($john->id, $ned->walletOwner()->id); // User with no entitlements $user = $this->getTestUser('UserAccountA@UserAccount.com'); $this->assertSame($user->id, $user->walletOwner()->id); } /** * Tests for User::wallets() */ public function testWallets(): void { $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $this->assertSame(1, $john->wallets()->count()); $this->assertCount(1, $john->wallets); $this->assertInstanceOf(\App\Wallet::class, $john->wallets->first()); $this->assertSame(1, $ned->wallets()->count()); $this->assertCount(1, $ned->wallets); $this->assertInstanceOf(\App\Wallet::class, $ned->wallets->first()); } } diff --git a/src/tests/TestCaseTrait.php b/src/tests/TestCaseTrait.php index 70cda30f..563ef002 100644 --- a/src/tests/TestCaseTrait.php +++ b/src/tests/TestCaseTrait.php @@ -1,606 +1,606 @@ '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, $titles = []): void { // Add beta + $title entitlements $beta_sku = Sku::withEnvTenantContext()->where('title', 'beta')->first(); $user->assignSku($beta_sku); if (!empty($titles)) { Sku::withEnvTenantContext()->whereIn('title', (array) $titles)->get() ->each(function ($sku) use ($user) { $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, $targetCreatedDate = null) { $wallets = []; $ids = []; foreach ($entitlements as $entitlement) { $ids[] = $entitlement->id; $wallets[] = $entitlement->wallet_id; } \App\Entitlement::whereIn('id', $ids)->update([ 'created_at' => $targetCreatedDate ?: $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' => $targetCreatedDate ?: $targetDate ]); } } /** * Removes all beta entitlements from the database */ protected function clearBetaEntitlements(): void { $beta_handlers = [ 'App\Handlers\Beta', 'App\Handlers\Beta\Distlists', 'App\Handlers\Beta\Resources', 'App\Handlers\Beta\SharedFolders', ]; $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; } 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; } 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; } 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 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; + $resource->domainName = $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; + $folder->domainName = $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/data/kolab3.ldif b/src/tests/data/kolab3.ldif index 22a80fcc..15b68bca 100644 --- a/src/tests/data/kolab3.ldif +++ b/src/tests/data/kolab3.ldif @@ -1,119 +1,121 @@ dn: associateddomain=kolab3.com,ou=Domains,dc=hosted,dc=com objectClass: top objectClass: domainrelatedobject objectClass: inetdomain inetDomainBaseDN: ou=kolab3.com,dc=hosted,dc=com associatedDomain: kolab3.com associatedDomain: kolab3-alias.com dn: uid=owner@kolab3.com,ou=People,ou=kolab3.com,dc=hosted,dc=com cn: Aleksander Machniak displayName: Machniak, Aleksander givenName: Aleksander sn: Machniak o: Organization AG l:: U2llbWlhbm93aWNlIMWabMSFc2tpZQ== mobile: 123456789 c: PL objectClass: top objectClass: inetorgperson objectClass: kolabinetorgperson objectClass: organizationalperson objectClass: mailrecipient objectClass: country objectClass: person mail: owner@kolab3.com alias: alias@kolab3.com alias: alias@kolab3-alias.com mailAlternateAddress: external@gmail.com mailHost: imap.hosted.com mailQuota: 8388608 uid: owner@kolab3.com userPassword:: e1NTSEE1MTJ9Zzc0K1NFQ1RMc00xeDBhWWtTclRHOXNPRnpFcDh3akNmbGhzaHIyRGpFN21pMUczaU5iNENsSDNsam9yUFJsVGdaMTA1UHNRR0VwTnROcitYUmppZ2c9PQ== nsUniqueID: 229dc10c-1b6a11f7-b7c1edc1-0e0f46c4 createtimestamp: 20170407081419Z modifytimestamp: 20200915082359Z dn: uid=user@kolab3.com,ou=People,ou=kolab3.com,dc=hosted,dc=com cn: Jane Doe displayName: Doe, Jane givenName: Jane sn: Doe o: Org AG telephoneNumber: 1234567890 objectClass: top objectClass: inetorgperson objectClass: kolabinetorgperson objectClass: organizationalperson objectClass: mailrecipient objectClass: country objectClass: person mail: user@kolab3.com alias: alias2@kolab3.com mailAlternateAddress: ext@gmail.com mailHost: imap.hosted.com mailQuota: 2097152 uid: user@kolab3.com userPassword:: e1NTSEE1MTJ9Zzc0K1NFQ1RMc00xeDBhWWtTclRHOXNPRnpFcDh3akNmbGhzaHIyRGpFN21pMUczaU5iNENsSDNsam9yUFJsVGdaMTA1UHNRR0VwTnROcitYUmppZ2c9PQ== nsUniqueID: 229dc20c-1b6a11f7-b7c1edc1-0e0f46c4 dn: cn=Group,ou=Groups,ou=kolab3.com,dc=hosted,dc=com cn: Group mail: group@kolab3.com objectClass: top objectClass: groupofuniquenames objectClass: kolabgroupofuniquenames uniqueMember: uid=user@kolab3.com,ou=People,ou=kolab3.com,dc=hosted,dc=com uniqueMember: uid=owner@kolab3.com,ou=People,ou=kolab3.com,dc=hosted,dc=com kolabAllowSMTPRecipient: recipient@kolab.org kolabAllowSMTPSender: sender@gmail.com dn: cn=Error,ou=Groups,ou=kolab3.com,dc=hosted,dc=com cn: Error uniqueMember: uid=user@kolab3.com,ou=People,ou=kolab3.com,dc=hosted,dc=com dn: cn=UnknownDomain,ou=Groups,ou=kolab3.org,dc=hosted,dc=com cn: UnknownDomain mail: unknowndomain@kolab3.org objectClass: top objectClass: groupofuniquenames objectClass: kolabgroupofuniquenames uniqueMember: uid=user@kolab3.com,ou=People,ou=kolab3.com,dc=hosted,dc=com uniqueMember: uid=owner@kolab3.com,ou=People,ou=kolab3.com,dc=hosted,dc=com dn: cn=Resource,ou=Resources,ou=kolab3.com,dc=hosted,dc=com cn: Resource mail: resource-car-resource@kolab3.com objectClass: top objectClass: kolabsharedfolder objectClass: kolabresource objectClass: mailrecipient owner: uid=user@kolab3.com,ou=People,ou=kolab3.com,dc=hosted,dc=com kolabAllowSMTPRecipient: recipient@kolab.org kolabAllowSMTPSender: sender@gmail.com kolabInvitationPolicy: ACT_MANUAL kolabTargetFolder: shared/Resource@kolab3.com dn: cn=Error,ou=Resources,ou=kolab3.com,dc=hosted,dc=com cn: Error dn: cn=Folder1,ou=Shared Folders,ou=kolab3.com,dc=hosted,dc=com cn: Folder1 objectClass: kolabsharedfolder objectClass: mailrecipient objectClass: top kolabFolderType: mail kolabTargetFolder: shared/Folder1@kolab3.com mail: folder1@kolab3.com +alias: folder-alias1@kolab3.com +alias: folder-alias2@kolab3.com acl: anyone, read-write acl: owner@kolab3.com, full dn: cn=Folder2,ou=Shared Folders,ou=kolab3.com,dc=hosted,dc=com cn: Folder2 objectClass: kolabsharedfolder objectClass: mailrecipient objectClass: top kolabFolderType: event kolabTargetFolder: shared/Folder2@kolab3.com mail: folder2@kolab3.com acl: anyone, read-only