From 8c14e9c1d4872d7657d7d0b03016d572c58d9cea Mon Sep 17 00:00:00 2001 From: Piotr Mrowczynski Date: Wed, 18 Oct 2017 13:32:55 +0200 Subject: [PATCH] Add membership manager with unit tests --- lib/private/MembershipManager.php | 746 ++++++++++++++++++++++++++++ tests/lib/MembershipManagerTest.php | 663 ++++++++++++++++++++++++ 2 files changed, 1409 insertions(+) create mode 100644 lib/private/MembershipManager.php create mode 100644 tests/lib/MembershipManagerTest.php diff --git a/lib/private/MembershipManager.php b/lib/private/MembershipManager.php new file mode 100644 index 000000000000..537d8b2d6f36 --- /dev/null +++ b/lib/private/MembershipManager.php @@ -0,0 +1,746 @@ + + * + * @copyright Copyright (c) 2017, ownCloud GmbH + * @license AGPL-3.0 + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License, version 3, + * along with this program. If not, see + * + */ + +namespace OC; + +use OC\Group\BackendGroup; +use OC\User\Account; +use OCP\AppFramework\Db\Entity; +use OCP\IConfig; +use OCP\IDBConnection; +use Doctrine\DBAL\Exception\UniqueConstraintViolationException; +use OCP\DB\QueryBuilder\IQueryBuilder; + +class MembershipManager { + + /** + * types of memberships in the group + */ + const MEMBERSHIP_TYPE_GROUP_USER = 0; + const MEMBERSHIP_TYPE_GROUP_ADMIN = 1; + + /** @var IConfig */ + protected $config; + + /** @var IDBConnection */ + private $db; + + /** + * MembershipManager class is responsible for mapping and handling + * user/group memberships + * + * @param IDBConnection $db + * @param IConfig $config + */ + public function __construct(IDBConnection $db, IConfig $config) { + $this->db = $db; + $this->config = $config; + } + + /** + * Return backend group entities for given account (identified by user's uid) + * + * @param string $userId + * + * @return BackendGroup[] + */ + public function getUserBackendGroups($userId) { + return $this->getBackendGroupsSqlQuery($userId, false, self::MEMBERSHIP_TYPE_GROUP_USER); + } + + /** + * Return backend group entities for given account (identified by user's internal account id) + * + * NOTE: Search by internal id is used to optimize access when + * group backend/account has already been instantiated and internal id is explicitly available + * + * @param int $accountId + * + * @return BackendGroup[] + */ + public function getUserBackendGroupsById($accountId) { + return $this->getBackendGroupsSqlQuery($accountId, true, self::MEMBERSHIP_TYPE_GROUP_USER); + } + + /** + * Return backend group entities for given account (identified by user's uid) of which + * the user is admin. + * + * @param string $userId + * + * @return BackendGroup[] + */ + public function getAdminBackendGroups($userId) { + return $this->getBackendGroupsSqlQuery($userId, false, self::MEMBERSHIP_TYPE_GROUP_ADMIN); + } + + /** + * Return user account entities for given group (identified with gid). If group predicate not specified, + * it will return all users which are group users + * + * @param string|null $gid + * + * @return Account[] + */ + public function getGroupUserAccounts($gid = null) { + return $this->getAccountsSqlQuery($gid, false, self::MEMBERSHIP_TYPE_GROUP_USER); + } + + /** + * Return user account entities for given group (identified with group's internal backend group id) + * + * @param int $backendGroupId + * + * @return Account[] + */ + public function getGroupUserAccountsById($backendGroupId) { + return $this->getAccountsSqlQuery($backendGroupId, true, self::MEMBERSHIP_TYPE_GROUP_USER); + } + + /** + * Return admin account entities for given group (identified with gid). If group predicate not specified, + * it will return all users which are group admins + * + * @param string|null $gid + * + * @return Account[] + */ + public function getGroupAdminAccounts($gid = null) { + return $this->getAccountsSqlQuery($gid, false, self::MEMBERSHIP_TYPE_GROUP_ADMIN); + + } + + /** + * Check whether given user (identified by user's uid) is user of + * the group (identified with group's gid). If group predicate not specified, + * it will check if user is group user of any group + * + * @param string $userId + * @param string $gid + * + * @return boolean + */ + public function isGroupUser($userId, $gid = null) { + return $this->isGroupMemberSqlQuery($userId, $gid, self::MEMBERSHIP_TYPE_GROUP_USER, false); + } + + /** + * Check whether given account (identified by user's internal account id) is user of + * the group (identified with group's internal backend group id) + * + * NOTE: Search by internal id is used to optimize access when + * group backend/account has already been instantiated and internal id is explicitly available + * + * @param int $accountId + * @param int $backendGroupId + * + * @return boolean + */ + public function isGroupUserById($accountId, $backendGroupId) { + return $this->isGroupMemberSqlQuery($accountId, $backendGroupId, self::MEMBERSHIP_TYPE_GROUP_USER, true); + } + + /** + * Check whether given account (identified by user's uid) is admin of + * the group (identified with gid). If group predicate not specified, + * it will check if user is group admin of any group + * + * @param string $userId + * @param string $gid + * + * @return boolean + */ + public function isGroupAdmin($userId, $gid = null) { + return $this->isGroupMemberSqlQuery($userId, $gid, self::MEMBERSHIP_TYPE_GROUP_ADMIN, false); + + } + + /** + * Search for members which match the pattern and + * are users in the group (identified with gid) + * + * @param string $gid + * @param string $pattern + * @param integer $limit + * @param integer $offset + * @return Entity[] + */ + public function find($gid, $pattern, $limit = null, $offset = null) { + return $this->searchAccountsSqlQuery($gid, false, $pattern, $limit, $offset); + } + + /** + * Search for members which match the pattern and + * are users in the backend group (identified with internal backend group id) + * + * NOTE: Search by internal id instead of gid is used to optimize access when + * group backend has already been instantiated and $backendGroupId is explicitly available + * + * @param int $backendGroupId + * @param string $pattern + * @param integer $limit + * @param integer $offset + * @return Entity[] + */ + public function findById($backendGroupId, $pattern, $limit = null, $offset = null) { + return $this->searchAccountsSqlQuery($backendGroupId, true, $pattern, $limit, $offset); + } + + /** + * Count members which match the pattern and + * are users in the group (identified with gid) + * + * @param string $gid + * @param string $pattern + * + * @return int + */ + public function count($gid, $pattern) { + return $this->countSqlQuery($gid, false, $pattern); + } + + /** + * Add a group account (identified by user's internal account id) + * to group (identified by group's internal backend group id). + * + * @param int $accountId - internal id of an account + * @param int $backendGroupId - internal id of backend group + * + * @throws UniqueConstraintViolationException + * @return bool + */ + public function addGroupUser($accountId, $backendGroupId) { + return $this->addGroupMemberSqlQuery($accountId, $backendGroupId, self::MEMBERSHIP_TYPE_GROUP_USER); + } + + /** + * Add a group admin account (identified by user's internal account id) + * to group (identified by group's internal backend group id). + * + * @param int $accountId - internal id of an account + * @param int $backendGroupId - internal id of backend group + * + * @throws UniqueConstraintViolationException + * @return bool + */ + public function addGroupAdmin($accountId, $backendGroupId) { + return $this->addGroupMemberSqlQuery($accountId, $backendGroupId, self::MEMBERSHIP_TYPE_GROUP_ADMIN); + } + + /** + * Remove group user membership for user (identified by user's internal account id) + * from group (identified by group's internal backend group id). + * + * @param int $accountId - internal id of an account + * @param int $backendGroupId - internal id of backend group + * @return bool + */ + public function removeGroupUser($accountId, $backendGroupId) { + return $this->removeGroupMembershipsSqlQuery($backendGroupId, $accountId, [self::MEMBERSHIP_TYPE_GROUP_USER]); + } + + /** + * Remove group admin membership for user (identified by user's internal account id) + * from group (identified by group's internal backend group id). + * + * @param int $accountId - internal id of an account + * @param int $backendGroupId - internal id of backend group + * @return bool + */ + public function removeGroupAdmin($accountId, $backendGroupId) { + return $this->removeGroupMembershipsSqlQuery($backendGroupId, $accountId, [self::MEMBERSHIP_TYPE_GROUP_ADMIN]); + } + + /** + * Remove group memberships from group (identified by group's internal backend group id), + * regardless of the role in the group. + * + * @param int $backendGroupId - internal id of backend group + * @return bool + */ + public function removeGroupMembers($backendGroupId) { + return $this->removeGroupMembershipsSqlQuery($backendGroupId, null, [self::MEMBERSHIP_TYPE_GROUP_USER, self::MEMBERSHIP_TYPE_GROUP_ADMIN]); + } + + /** + * Remove group memberships for user (identified by user's internal account id), + * regardless of the role in the group. + * + * @param int $accountId - internal id of an account + * @return bool + */ + public function removeMemberships($accountId) { + return $this->removeGroupMembershipsSqlQuery(null, $accountId, [self::MEMBERSHIP_TYPE_GROUP_USER, self::MEMBERSHIP_TYPE_GROUP_ADMIN]); + } + + /** + * Check if the given user is member of the group with specific membership type + * + * @param string|int $userId + * @param string|int|null $groupId + * @param string $membershipType + * @param bool $useInternalIds + * + * @return boolean + */ + private function isGroupMemberSqlQuery($userId, $groupId, $membershipType, $useInternalIds) { + $qb = $this->db->getQueryBuilder(); + $qb->selectAlias($qb->createFunction('1'), 'exists') + ->from('memberships', 'm'); + + if (!is_null($groupId)) { + if ($useInternalIds) { + $qb = $this->applyInternalPredicates($qb, $groupId, $userId, true); + } else { + $qb = $this->applyPredicates($qb, $groupId, $userId); + } + } + + // Place predicate on membership_type + $qb->andWhere($qb->expr()->eq('m.membership_type', $qb->createNamedParameter($membershipType))); + + // Limit to 1, to prevent fetching unnecessary rows + $qb->setMaxResults(1); + + return $this->getExistsQuery($qb); + } + + + /** + * Add user to the group with specific membership type $membershipType. + * + * @param int $accountId - internal id of an account + * @param int $backendGroupId - internal id of backend group + * @param string $membershipType + * + * Return will indicate if row has been inserted + * + * @throws UniqueConstraintViolationException + * @return boolean + */ + private function addGroupMemberSqlQuery($accountId, $backendGroupId, $membershipType) { + $qb = $this->db->getQueryBuilder(); + + $qb->insert('memberships') + ->values([ + 'backend_group_id' => $qb->createNamedParameter($backendGroupId), + 'account_id' => $qb->createNamedParameter($accountId), + 'membership_type' => $qb->createNamedParameter($membershipType), + ]); + + return $this->getAffectedQuery($qb); + } + + /* + * Removes users from the groups. If the predicate on a user or group is null, then it will apply + * removal to all the entries of that type. + * + * NOTE: This function requires to use internal IDs, since we cannot + * use JOIN with DELETE (some databases don't support it). We also cannot use aliases + * since MySQL has specific syntax for them in DELETE + * + * Return will indicate if row has been removed + * + * @param int|null $accountId - internal id of an account + * @param int|null $backendGroupId - internal id of backend group + * @param int[] $membershipTypeArray + * + * @return boolean + */ + private function removeGroupMembershipsSqlQuery($backendGroupId, $accountId, $membershipTypeArray) { + $qb = $this->db->getQueryBuilder(); + $qb->delete('memberships'); + + if (!is_null($backendGroupId) && !is_null($accountId)) { + // Both backend_group_id and account_id predicates are specified + $qb = $this->applyInternalPredicates($qb, $backendGroupId, $accountId, false); + } else if (!is_null($backendGroupId)) { + // Group predicate backend_group_id specified + $qb = $this->applyBackendGroupIdPredicate($qb, $backendGroupId, false); + } else if (!is_null($accountId)) { + // User predicate account_id specified + $qb = $this->applyAccountIdPredicate($qb, $accountId, false); + } else { + return false; + } + + $qb->andWhere($qb->expr()->in('membership_type', + $qb->createNamedParameter($membershipTypeArray, IQueryBuilder::PARAM_INT_ARRAY))); + + return $this->getAffectedQuery($qb); + } + + /* + * Return backend group entities for given user + * (identified by account id if $isAccountId is true, or uid if $isAccountId is false) + * of which the user has specific membership type + * + * @param string|int $userId + * @param bool $isAccountId + * @param int $membershipType + * + * @return BackendGroup[] + */ + private function getBackendGroupsSqlQuery($userId, $isAccountId, $membershipType) { + $qb = $this->db->getQueryBuilder(); + $qb->select(['g.id', 'g.group_id', 'g.display_name', 'g.backend']) + ->from('memberships', 'm') + ->innerJoin('m', 'backend_groups', 'g', $qb->expr()->eq('g.id', 'm.backend_group_id')); + + // Adjust the query depending on availability of accountId + // to have optimized access + if ($isAccountId) { + $qb = $this->applyAccountIdPredicate($qb, $userId, true); + } else { + $qb = $this->applyUserIdPredicate($qb, $userId); + } + + // Place predicate on membership_type + $qb->andWhere($qb->expr()->eq('m.membership_type', $qb->createNamedParameter($membershipType))); + + return $this->getBackendGroupsQuery($qb); + } + + /** + * Return account entities for given group + * (identified by backend group id if $isBackendGroupId is true, or gid if $isBackendGroupId is false) + * of which the accounts have specific membership type. If group id is not specified, it will + * return result for all groups. + * + * @param string|int|null $groupId + * @param bool $isBackendGroupId + * @param int $membershipType + * @return Account[] + */ + private function getAccountsSqlQuery($groupId, $isBackendGroupId, $membershipType) { + $qb = $this->db->getQueryBuilder(); + $qb->select(['a.id', 'a.user_id', 'a.lower_user_id', 'a.display_name', 'a.email', 'a.last_login', 'a.backend', 'a.state', 'a.quota', 'a.home']) + ->from('memberships', 'm') + ->innerJoin('m', 'accounts', 'a', $qb->expr()->eq('a.id', 'm.account_id')); + + if (!is_null($groupId)) { + // Adjust the query depending on availability of group id + // to have optimized access + if ($isBackendGroupId) { + $qb = $this->applyBackendGroupIdPredicate($qb, $groupId, true); + } else { + $qb = $this->applyGidPredicate($qb, $groupId); + } + } + + // Place predicate on membership_type + $qb->andWhere($qb->expr()->eq('m.membership_type', $qb->createNamedParameter($membershipType))); + + return $this->getAccountsQuery($qb); + } + + /** + * Search for members which match the pattern and + * are users in the group (identified by backend group id + * if $isBackendGroupId is true, or gid if $isBackendGroupId is false) + * + * @param string|int $groupId + * @param bool $isBackendGroupId + * @param string $pattern + * @param integer $limit + * @param integer $offset + * + * @return Account[] + */ + private function searchAccountsSqlQuery($groupId, $isBackendGroupId, $pattern, $limit = null, $offset = null) { + $qb = $this->db->getQueryBuilder(); + $qb->selectAlias('DISTINCT a.id', 'id') + ->addSelect(['a.user_id', 'a.lower_user_id', 'a.display_name', 'a.email', 'a.last_login', 'a.backend', 'a.state', 'a.quota', 'a.home']); + + $qb = $this->searchMembersSqlQuery($qb, $groupId, $isBackendGroupId, $pattern); + + // Order by display_name so we can use limit and offset + $qb->orderBy('a.display_name'); + + if (!is_null($offset)) { + $qb->setFirstResult($offset); + } + + if (!is_null($limit)) { + $qb->setMaxResults($limit); + } + + return $this->getAccountsQuery($qb); + } + + /** + * Count members which match the pattern and + * are users in the group (identified by backend group id + * if $isBackendGroupId is true, or gid if $isBackendGroupId is false) + * + * @param string|int $groupId + * @param bool $isBackendGroupId + * @param string $pattern + * @return int + */ + private function countSqlQuery($groupId, $isBackendGroupId, $pattern) { + $qb = $this->db->getQueryBuilder(); + + // We need to use distinct since otherwise we will get duplicated rows for each search term + // Due to the fact that we use createFunction(), predicate on column has to be surrounded with `` e.g. a.`id` + $qb->selectAlias($qb->createFunction('COUNT(DISTINCT a.`id`)'), 'count'); + + $qb = $this->searchMembersSqlQuery($qb, $groupId, $isBackendGroupId, $pattern); + + return $this->getCountQuery($qb); + } + + /** + * Search for members which match the pattern and + * are users in the group (identified by backend group id + * if $isBackendGroupId is true, or gid if $isBackendGroupId is false) + * + * @param IQueryBuilder $qb + * @param string|int $groupId + * @param bool $isBackendGroupId + * @param string $pattern + * + * @return IQueryBuilder + */ + private function searchMembersSqlQuery(IQueryBuilder $qb, $groupId, $isBackendGroupId, $pattern) { + // Optimize query if pattern is an empty string, and we can retrieve information with faster query + $emptyPattern = empty($pattern) ? true : false; + + $qb->from('accounts', 'a') + ->innerJoin('a', 'memberships', 'm', $qb->expr()->eq('a.id', 'm.account_id')); + + if (!$emptyPattern) { + $qb->leftJoin('a', 'account_terms', 't', $qb->expr()->eq('a.id', 't.account_id')); + } + + // Adjust the query depending on availability of group id + // to have optimized access + if ($isBackendGroupId) { + $qb = $this->applyBackendGroupIdPredicate($qb, $groupId, true); + } else { + $qb = $this->applyGidPredicate($qb, $groupId); + } + + if (!$emptyPattern) { + // Non empty pattern means that we need to set predicates on parameters + // and just fetch all users + $allowMedialSearches = $this->config->getSystemValue('accounts.enable_medial_search', true); + if ($allowMedialSearches) { + $parameter = '%' . $this->db->escapeLikeParameter($pattern) . '%'; + $loweredParameter = '%' . $this->db->escapeLikeParameter(strtolower($pattern)) . '%'; + } else { + $parameter = $this->db->escapeLikeParameter($pattern) . '%'; + $loweredParameter = $this->db->escapeLikeParameter(strtolower($pattern)) . '%'; + } + + $qb->andWhere( + $qb->expr()->orX( + $qb->expr()->like('a.lower_user_id', $qb->createNamedParameter($loweredParameter)), + $qb->expr()->iLike('a.display_name', $qb->createNamedParameter($parameter)), + $qb->expr()->iLike('a.email', $qb->createNamedParameter($parameter)), + $qb->expr()->like('t.term', $qb->createNamedParameter($loweredParameter)) + ) + ); + } + + // Place predicate on membership_type + $qb->andWhere($qb->expr()->eq('m.membership_type', $qb->createNamedParameter(self::MEMBERSHIP_TYPE_GROUP_USER))); + + return $qb; + } + + /** + * @param IQueryBuilder $qb + * @return int + */ + private function getAffectedQuery(IQueryBuilder $qb) { + // If affected is equal or more then 1, it means operation was successful + $affected = $qb->execute(); + return $affected > 0; + } + + /** + * @param IQueryBuilder $qb + * @return bool + */ + private function getExistsQuery(IQueryBuilder $qb) { + // First fetch contains exists + $stmt = $qb->execute(); + $data = $stmt->fetch(); + $stmt->closeCursor(); + return isset($data['exists']); + } + + /** + * @param IQueryBuilder $qb + * @return int + */ + private function getCountQuery(IQueryBuilder $qb) { + // First fetch contains count + $stmt = $qb->execute(); + $data = $stmt->fetch(); + $stmt->closeCursor(); + return intval($data['count']); + } + + /** + * @param IQueryBuilder $qb + * @return Account[] + */ + private function getAccountsQuery(IQueryBuilder $qb) { + $stmt = $qb->execute(); + $accounts = []; + while($attributes = $stmt->fetch()){ + // Map attributes in array to Account + // Attributes are explicitly specified by SELECT statement + $account = new Account(); + $account->setId($attributes['id']); + $account->setUserId($attributes['user_id']); + $account->setDisplayName($attributes['display_name']); + $account->setBackend($attributes['backend']); + $account->setEmail($attributes['email']); + $account->setQuota($attributes['quota']); + $account->setHome($attributes['home']); + $account->setState($attributes['state']); + $account->setLastLogin($attributes['last_login']); + $accounts[] = $account; + } + + $stmt->closeCursor(); + return $accounts; + } + + /** + * @param IQueryBuilder $qb + * @return BackendGroup[] + */ + private function getBackendGroupsQuery(IQueryBuilder $qb) { + $stmt = $qb->execute(); + $groups = []; + while($attributes = $stmt->fetch()){ + // Map attributes in array to BackendGroup + // Attributes are explicitly specified by SELECT statement + $group = new BackendGroup(); + $group->setId($attributes['id']); + $group->setGroupId($attributes['group_id']); + $group->setDisplayName($attributes['display_name']); + $group->setBackend($attributes['backend']); + $groups[] = $group; + } + + $stmt->closeCursor(); + return $groups; + } + + /** + * @param IQueryBuilder $qb + * @param string $gid + * @param string $userId + * @return IQueryBuilder + */ + private function applyPredicates(IQueryBuilder $qb, $gid, $userId) { + // We need to join with accounts table, since we miss information on accountId + // We need to join with backend group table, since we miss information on backendGroupId + $qb->innerJoin('m', 'accounts', + 'a', $qb->expr()->eq('a.id', 'm.account_id')); + $qb->innerJoin('m', 'backend_groups', + 'g', $qb->expr()->eq('g.id', 'm.backend_group_id')); + + // Apply predicate on user_id in accounts table + $qb->where($qb->expr()->eq('a.user_id', $qb->createNamedParameter($userId))); + + // Apply predicate on group_id in backend groups table + $qb->andWhere($qb->expr()->eq('g.group_id', $qb->createNamedParameter($gid))); + return $qb; + } + + /** + * @param IQueryBuilder $qb + * @param int $backendGroupId + * @param int $accountId + * @param bool $useAlias + * @return IQueryBuilder + */ + private function applyInternalPredicates(IQueryBuilder $qb, $backendGroupId, $accountId, $useAlias) { + $backendGroupColumn = $useAlias ? 'm.backend_group_id' : 'backend_group_id'; + $accountColumn = $useAlias ? 'm.account_id' : 'account_id'; + // No need to JOIN any tables, we already have all information required + // Apply predicate on backend_group_id and account_id in memberships table + $qb->where($qb->expr()->eq($backendGroupColumn, $qb->createNamedParameter($backendGroupId))); + $qb->andWhere($qb->expr()->eq($accountColumn, $qb->createNamedParameter($accountId))); + return $qb; + } + + /** + * @param IQueryBuilder $qb + * @param string $userId + * @return IQueryBuilder + */ + private function applyUserIdPredicate(IQueryBuilder $qb, $userId) { + // We need to join with accounts table, since we miss information on accountId + $qb->innerJoin('m', 'accounts', 'a', $qb->expr()->eq('a.id', 'm.account_id')); + + // Apply predicate on user_id in accounts table + $qb->where($qb->expr()->eq('a.user_id', $qb->createNamedParameter($userId))); + + return $qb; + } + + /** + * @param IQueryBuilder $qb + * @param int $accountId + * @param bool $useAlias + * @return IQueryBuilder + */ + private function applyAccountIdPredicate(IQueryBuilder $qb, $accountId, $useAlias) { + $accountColumn = $useAlias ? 'm.account_id' : 'account_id'; + // Apply predicate on account_id in memberships table + $qb->where($qb->expr()->eq($accountColumn, $qb->createNamedParameter($accountId))); + return $qb; + } + + /** + * @param IQueryBuilder $qb + * @param string $gid + * @return IQueryBuilder + */ + private function applyGidPredicate(IQueryBuilder $qb, $gid) { + // We need to join with backend group table, since we miss information on backendGroupId + $qb->innerJoin('m', 'backend_groups', 'g', $qb->expr()->eq('g.id', 'm.backend_group_id')); + + // Apply predicate on group_id in backend groups table + $qb->where($qb->expr()->eq('g.group_id', $qb->createNamedParameter($gid))); + return $qb; + } + + /** + * @param IQueryBuilder $qb + * @param int $backendGroupId + * @param bool $useAlias + * @return IQueryBuilder + */ + private function applyBackendGroupIdPredicate(IQueryBuilder $qb, $backendGroupId, $useAlias) { + $backendGroupColumn = $useAlias ? 'm.backend_group_id' : 'backend_group_id'; + // Apply predicate on backend_group_id in memberships table + $qb->where($qb->expr()->eq($backendGroupColumn, $qb->createNamedParameter($backendGroupId))); + return $qb; + } +} \ No newline at end of file diff --git a/tests/lib/MembershipManagerTest.php b/tests/lib/MembershipManagerTest.php new file mode 100644 index 000000000000..f9e3875fbad4 --- /dev/null +++ b/tests/lib/MembershipManagerTest.php @@ -0,0 +1,663 @@ + + * + * @copyright Copyright (c) 2017, ownCloud GmbH + * @license AGPL-3.0 + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License, version 3, + * along with this program. If not, see + * + */ + +namespace Test; + +use OC\Group\BackendGroup; +use OC\Group\GroupMapper; +use OC\User\Account; +use OC\User\AccountMapper; +use OC\MembershipManager; +use OC\User\AccountTermMapper; +use OCP\IConfig; +use OCP\IDBConnection; +use Doctrine\DBAL\Platforms\SqlitePlatform; +use Doctrine\DBAL\Exception\ForeignKeyConstraintViolationException; +use Doctrine\DBAL\Exception\UniqueConstraintViolationException; + +/** + * Class MembershipManagerTest + * + * @group DB + * + * @package Test + */ +class MembershipManagerTest extends TestCase { + + /** @var IConfig | \PHPUnit_Framework_MockObject_MockObject */ + protected $config; + + /** @var IDBConnection */ + protected $connection; + + /** @var MembershipManager */ + protected $manager; + + public static function setUpBeforeClass() { + parent::setUpBeforeClass(); + self::clearDB(); + + // create test groups and accounts + for ($i = 1; $i <= 4; $i++) { + self::createBackendGroup($i); + self::createAccount($i); + } + } + + private static function createBackendGroup($i) { + $groupMapper = new GroupMapper(\OC::$server->getDatabaseConnection()); + + $backendGroup = new BackendGroup(); + $backendGroup->setGroupId("testgroup$i"); + $backendGroup->setDisplayName("TestGroup$i"); + $backendGroup->setBackend(self::class); + + $groupMapper->insert($backendGroup); + } + + private static function getAccount($i) { + $termMapper = new AccountTermMapper(\OC::$server->getDatabaseConnection()); + $mapper = new AccountMapper(\OC::$server->getConfig(), \OC::$server->getDatabaseConnection(), $termMapper); + return $mapper->getByUid("testaccount$i"); + } + + private static function getBackendGroup($i) { + $groupMapper = new GroupMapper(\OC::$server->getDatabaseConnection()); + return $groupMapper->getGroup("testgroup$i"); + } + + private static function createAccount($i) { + $termMapper = new AccountTermMapper(\OC::$server->getDatabaseConnection()); + $mapper = new AccountMapper(\OC::$server->getConfig(), \OC::$server->getDatabaseConnection(), $termMapper); + + $account = new Account(); + $account->setUserId("testaccount$i"); + $account->setDisplayName("Test User $i"); + $account->setEmail("test$i@user.com"); + $account->setBackend(self::class); + $account->setHome("/foo/testaccount$i"); + $account->setQuota($i); + $account->setState(Account::STATE_ENABLED); + $account->setLastLogin($i); + + $mapper->insert($account); + + $mapper->setTermsForAccount($account->getId(), ["Term $i A","Term $i B","Term $i C"]); + } + + public function setUp() { + parent::setUp(); + + $this->config = $this->createMock(IConfig::class); + + $this->connection = \OC::$server->getDatabaseConnection(); + + $this->manager = new MembershipManager( + $this->connection, + $this->config + ); + } + + private static function clearDB() { + $manager = new MembershipManager( + \OC::$server->getDatabaseConnection(), + \OC::$server->getConfig() + ); + $groupMapper = new GroupMapper( + \OC::$server->getDatabaseConnection() + ); + $accountMapper = new AccountMapper( + \OC::$server->getConfig(), + \OC::$server->getDatabaseConnection(), + new AccountTermMapper(\OC::$server->getDatabaseConnection()) + ); + // Remove groups + for ($i = 0; $i <= 4; $i++) { + $group = self::getBackendGroup($i); + if (!is_null($group)) { + try { + $manager->removeGroupMembers($group->getId()); + } catch (\Exception $e) { + } + $groupMapper->delete($group); + } + } + // Remove accounts + for ($i = 0; $i <= 4; $i++) { + try { + $account = self::getAccount($i); + } catch (\Exception $e) { + continue; + } + + try { + $manager->removeMemberships($account->getId()); + } catch (\Exception $e) { + } + $accountMapper->delete($account); + } + } + + public static function tearDownAfterClass () { + self::clearDB(); + parent::tearDownAfterClass(); + } + + public function tearDown () { + // Cleanup groups + $this->cleanupGroups(); + parent::tearDown(); + } + + /** + * Test will prepare 4 accounts and 2 groups. 2 of them will be added as user and admin to the group, + * 1 as admin and 1 as user. Verify integrity and test all retrieval functions + * + * getAdminAccounts, getGroupUserAccounts, getGroupAdminAccounts, getGroupUserAccounts, + * getGroupUserAccountsById, getAdminBackendGroups, getUserBackendGroups, etUserBackendGroupsById, + * isGroupAdmin, isGroupUser, isGroupUserById + */ + public function testRegularAccountOperations() { + $result = $this->manager->getGroupAdminAccounts(); + $this->assertEmpty($result); + + $group1 = self::getBackendGroup(1); + $this->assertInstanceOf(BackendGroup::class, $group1); + $result = $this->manager->getGroupUserAccounts($group1->getGroupId()); + $this->assertEmpty($result); + $group2 = self::getBackendGroup(2); + $this->assertInstanceOf(BackendGroup::class, $group2); + $result = $this->manager->getGroupUserAccounts($group2->getGroupId()); + $this->assertEmpty($result); + + // Check adding as both ADMIN and USER + for ($i = 1; $i <= 2; $i++) { + $account = self::getAccount($i); + $this->assertInstanceOf(Account::class, $account); + + $this->addUserAndAdmin($account, $group1); + + $result = $this->manager->getGroupAdminAccounts(); + $this->assertCount($i, $result); + } + + // Check adding as only ADMIN + $account = self::getAccount(3); + $this->assertInstanceOf(Account::class, $account); + $this->addAdmin($account, $group2); + + // Check adding as only USER + $account = self::getAccount(4); + $this->assertInstanceOf(Account::class, $account); + $this->addUser($account, $group2); + + // Check that we have 3 total ADMIN accounts + $result = $this->manager->getGroupAdminAccounts(); + $this->assertCount(3, $result); + + // Check that we have 2 ADMIN accounts in group 1 + $result = $this->manager->getGroupAdminAccounts($group1->getGroupId()); + $this->assertCount(2, $result); + + // Check that we have 2 USER accounts in group 1 + $result = $this->manager->getGroupUserAccounts($group1->getGroupId()); + $this->assertCount(2, $result); + $result = $this->manager->getGroupUserAccountsById($group1->getId()); + $this->assertCount(2, $result); + + // Check that we have 1 ADMIN account in group 2 + $result = $this->manager->getGroupAdminAccounts($group2->getGroupId()); + $this->assertCount(1, $result); + $this->assertEquals("testaccount3", $result[0]->getUserId()); + $this->assertEquals("Test User 3", $result[0]->getDisplayName()); + + // Check that we have 1 USER account in group 2 + $result = $this->manager->getGroupUserAccounts($group2->getGroupId()); + $this->assertCount(1, $result); + $result = $this->manager->getGroupUserAccountsById($group2->getId()); + $this->assertCount(1, $result); + $this->assertEquals("testaccount4", $result[0]->getUserId()); + $this->assertEquals("Test User 4", $result[0]->getDisplayName()); + } + + /** + * Test that get functions return consistent results + */ + public function testGetConsistency() { + $accountId = 1; + $groupId = 1; + $account = self::getAccount($accountId); + $group = self::getBackendGroup($groupId); + + // Add as group user + $check = $this->manager->addGroupUser($account->getId(), $group->getId()); + $this->assertEquals(true, $check); + $check = $this->manager->addGroupAdmin($account->getId(), $group->getId()); + $this->assertEquals(true, $check); + + $backendGroups = $this->manager->getUserBackendGroups($account->getUserId()); + $this->checkConsistencyGroup($backendGroups[0], $groupId); + + $backendGroups = $this->manager->getAdminBackendGroups($account->getUserId()); + $this->checkConsistencyGroup($backendGroups[0], $groupId); + + $accounts = $this->manager->getGroupUserAccounts($group->getGroupId()); + $this->checkConsistencyAccount($accounts[0], $accountId); + + $accounts = $this->manager->getGroupAdminAccounts($group->getGroupId()); + $this->checkConsistencyAccount($accounts[0], $accountId); + } + + /** + * Test find, findById and count method, also with offsets. + */ + public function testFind() { + $result = $this->manager->getGroupAdminAccounts(); + $this->assertEmpty($result); + + $group1 = self::getBackendGroup(1); + $group2 = self::getBackendGroup(2); + + // Check adding as both ADMIN and USER + for ($i = 1; $i <= 2; $i++) { + $account = self::getAccount($i); + $this->addUserAndAdmin($account, $group1); + } + + $account = self::getAccount(3); + $this->addAdmin($account, $group2); + + $account = self::getAccount(4); + $this->addUser($account, $group2); + + // Test simple find in group 1 by userId + $result = $this->manager->find($group1->getGroupId(), "testaccount"); + $this->assertEquals(2, count($result)); + $this->assertEquals("testaccount1", array_shift($result)->getUserId()); + + // Test simple find in group 1 by account id + $result = $this->manager->findById($group1->getId(), "testaccount"); + $this->assertEquals(2, count($result)); + $this->assertEquals("testaccount1", array_shift($result)->getUserId()); + + // Test simple find in group 1 with offsets by userId + $result = $this->manager->find($group1->getGroupId(), "testaccount",1, 0); + $this->assertEquals(1, count($result)); + $this->assertEquals("testaccount1", array_shift($result)->getUserId()); + $result = $this->manager->find($group1->getGroupId(), "testaccount",1, 1); + $this->assertEquals(1, count($result)); + $this->assertEquals("testaccount2", array_shift($result)->getUserId()); + + // Test simple find in group 2 by userId - here we expect only 1 user, since the other is admin + $result = $this->manager->find($group2->getGroupId(), "testaccount"); + $this->assertEquals(1, count($result)); + $this->assertEquals("testaccount4", array_shift($result)->getUserId()); + + // Test finding by display name in group 2 + $result = $this->manager->find($group2->getGroupId(), "Test User 4"); + $this->assertEquals(1, count($result)); + $this->assertEquals("testaccount4", array_shift($result)->getUserId()); + + // Test finding by email name in group 2 + $result = $this->manager->find($group2->getGroupId(), "test4@user.com"); + $this->assertEquals(1, count($result)); + $this->assertEquals("testaccount4", array_shift($result)->getUserId()); + + // Test finding by email name in group 1, with medial search + $this->config->expects($this->any()) + ->method('getSystemValue') + ->willReturn(true); + $result = $this->manager->find($group1->getGroupId(), "@user.com"); + $this->assertEquals(2, count($result)); + + // Test finding by term in group 2 + $result = $this->manager->find($group2->getGroupId(), "Term 4 A"); + $this->assertEquals(1, count($result)); + $this->assertEquals("testaccount4", array_shift($result)->getUserId()); + + // Test finding by term in group 1 + $result = $this->manager->find($group1->getGroupId(), "Term 1 A"); + $this->assertEquals(1, count($result)); + $this->assertEquals("testaccount1", array_shift($result)->getUserId()); + + // Test count with empty string in group 1 - will match all users in the group + $result = $this->manager->count($group1->getGroupId(), ''); + $this->assertEquals(2, $result); + + // Test count by term in both group 1 and 2 + $result = $this->manager->count($group1->getGroupId(), "Term "); + $this->assertEquals(2, $result); + $result = $this->manager->count($group2->getGroupId(), "Term "); + $this->assertEquals(1, $result); + } + + /** + * Test will prepare 4 accounts which will be added as user and admin to the group + * + * 1. Remove single admin account + * 2. Remove single user account + * 3. Remove all memberships from account + */ + public function testRemoveAccountOperations() { + $result = $this->manager->getGroupAdminAccounts(); + $this->assertEmpty($result); + $group = self::getBackendGroup(1); + $result = $this->manager->getGroupUserAccounts($group->getGroupId()); + $this->assertEmpty($result); + + // Check adding as both ADMIN and USER + for ($i = 1; $i <= 4; $i++) { + $account = self::getAccount($i); + $this->addUserAndAdmin($account, $group); + + $result = $this->manager->getGroupAdminAccounts(); + $this->assertCount($i, $result); + $result = $this->manager->getGroupUserAccounts($group->getGroupId()); + $this->assertCount($i, $result); + } + + // Remove 1 ADMIN account + $account = self::getAccount(1); + $this->assertTrue($this->manager->isGroupAdmin($account->getUserId(), $group->getGroupId())); + $this->assertTrue($this->manager->isGroupUser($account->getUserId(), $group->getGroupId())); + $result = $this->manager->removeGroupAdmin($account->getId(), $group->getId()); + $this->assertTrue($result); + $this->assertCount(3, $this->manager->getGroupAdminAccounts()); + $this->assertCount(4, $this->manager->getGroupUserAccounts($group->getGroupId())); + $this->assertFalse($this->manager->isGroupAdmin($account->getUserId(), $group->getGroupId())); + $this->assertTrue($this->manager->isGroupUser($account->getUserId(), $group->getGroupId())); + + // Remove 1 USER account + $account = self::getAccount(2); + $this->assertTrue($this->manager->isGroupAdmin($account->getUserId(), $group->getGroupId())); + $this->assertTrue($this->manager->isGroupUser($account->getUserId(), $group->getGroupId())); + $result = $this->manager->removeGroupUser($account->getId(), $group->getId()); + $this->assertTrue($result); + $this->assertCount(3, $this->manager->getGroupAdminAccounts()); + $this->assertCount(3, $this->manager->getGroupUserAccounts($group->getGroupId())); + $this->assertTrue($this->manager->isGroupAdmin($account->getUserId(), $group->getGroupId())); + $this->assertFalse($this->manager->isGroupUser($account->getUserId(), $group->getGroupId())); + + // Remove account memberships + $account = self::getAccount(3); + $this->assertTrue($this->manager->isGroupAdmin($account->getUserId(), $group->getGroupId())); + $this->assertTrue($this->manager->isGroupUser($account->getUserId(), $group->getGroupId())); + $result = $this->manager->removeMemberships($account->getId()); + $this->assertTrue($result); + $this->assertCount(2, $this->manager->getGroupAdminAccounts()); + $this->assertCount(2, $this->manager->getGroupUserAccounts($group->getGroupId())); + $this->assertFalse($this->manager->isGroupAdmin($account->getUserId(), $group->getGroupId())); + $this->assertFalse($this->manager->isGroupUser($account->getUserId(), $group->getGroupId())); + + // After removal, remove memberships should return false since no memberships were removed + $result = $this->manager->removeMemberships($account->getId()); + $this->assertFalse($result); + } + + /** + * Test that deleting group should result in deleting all users, and violating that + * should rise exception + */ + public function testRemoveIncorrectIds() { + $result = $this->manager->getGroupAdminAccounts(); + $this->assertEmpty($result); + $group = self::getBackendGroup(1); + $result = $this->manager->getGroupUserAccounts($group->getGroupId()); + $this->assertEmpty($result); + + // Check adding as both ADMIN and USER + for ($i = 1; $i <= 4; $i++) { + $account = self::getAccount($i); + $this->addUserAndAdmin($account, $group); + } + + $this->assertCount(4, $this->manager->getGroupAdminAccounts()); + $this->assertCount(4, $this->manager->getGroupUserAccounts($group->getGroupId())); + + // Test that removal with null instead of integer will fail + $result = $this->manager->removeMemberships(null); + $this->assertFalse($result); + $this->assertCount(4, $this->manager->getGroupAdminAccounts()); + $this->assertCount(4, $this->manager->getGroupUserAccounts($group->getGroupId())); + + $result = $this->manager->removeGroupMembers(null); + $this->assertFalse($result); + $this->assertCount(4, $this->manager->getGroupAdminAccounts()); + $this->assertCount(4, $this->manager->getGroupUserAccounts($group->getGroupId())); + + $result = $this->manager->removeGroupUser(null, null); + $this->assertFalse($result); + $this->assertCount(4, $this->manager->getGroupAdminAccounts()); + $this->assertCount(4, $this->manager->getGroupUserAccounts($group->getGroupId())); + + $result = $this->manager->removeGroupAdmin(null, null); + $this->assertFalse($result); + $this->assertCount(4, $this->manager->getGroupAdminAccounts()); + $this->assertCount(4, $this->manager->getGroupUserAccounts($group->getGroupId())); + } + + /** + * Test that deleting group should result in deleting all users, and violating that + * should rise exception + */ + public function testRemoveFailed() { + // Test foreign keys only on databases which fully support it + if ($this->connection->getDatabasePlatform() instanceof SqlitePlatform) { + $this->markTestSkipped("Foreign key are not fully supported on SQLite, skip test"); + return; + } + $account = self::getAccount(1); + $this->assertInstanceOf(Account::class, $account); + $group = self::getBackendGroup(1); + $this->assertInstanceOf(BackendGroup::class, $group); + + // Add as group user + $check = $this->manager->addGroupUser($account->getId(), $group->getId()); + $this->assertEquals(true, $check); + + $termMapper = new AccountTermMapper(\OC::$server->getDatabaseConnection()); + $accountMapper = new AccountMapper(\OC::$server->getConfig(), \OC::$server->getDatabaseConnection(), $termMapper); + $groupMapper = new GroupMapper(\OC::$server->getDatabaseConnection()); + + $thrown = false; + try { + $accountMapper->delete($account); + } catch (\Exception $e) { + $thrown = true; + $this->assertInstanceOf(ForeignKeyConstraintViolationException::class, $e); + } + $this->assertTrue($thrown); + + try { + $groupMapper->delete($group); + } catch (\Exception $e) { + $this->assertInstanceOf(ForeignKeyConstraintViolationException::class, $e); + } + $this->assertTrue($thrown); + } + + /** + * Doubled insert of the user as a member should fail + */ + public function testInsertFailed() { + $account = self::getAccount(1); + $this->assertInstanceOf(Account::class, $account); + $group1 = self::getBackendGroup(1); + $this->assertInstanceOf(BackendGroup::class, $group1); + + // Add as group user + $check = $this->manager->addGroupUser($account->getId(), $group1->getId()); + $this->assertEquals(true, $check); + + // This should fail now + $thrown = false; + try { + $this->manager->addGroupUser($account->getId(), $group1->getId()); + } catch (\Exception $e) { + $thrown = true; + $this->assertInstanceOf(UniqueConstraintViolationException::class, $e); + } + $this->assertTrue($thrown); + + // Add as group admin + $check = $this->manager->addGroupAdmin($account->getId(), $group1->getId()); + $this->assertEquals(true, $check); + + // This should fail now + $thrown = false; + try { + $this->manager->addGroupAdmin($account->getId(), $group1->getId()); + } catch (\Exception $e) { + $thrown = true; + $this->assertInstanceOf(UniqueConstraintViolationException::class, $e); + } + $this->assertTrue($thrown); + } + + /** + * @param BackendGroup $group + * @param int $i + */ + private function checkConsistencyGroup($group, $i) { + $this->assertEquals("testgroup$i", $group->getGroupId()); + $this->assertEquals("TestGroup$i", $group->getDisplayName()); + $this->assertEquals(self::class, $group->getBackend()); + } + + /** + * @param Account $account + * @param int $i + */ + private function checkConsistencyAccount($account, $i) { + $this->assertEquals("testaccount$i", $account->getUserId()); + $this->assertEquals("Test User $i", $account->getDisplayName()); + $this->assertEquals("test$i@user.com", $account->getEmail()); + $this->assertEquals(self::class, $account->getBackend()); + $this->assertEquals("/foo/testaccount$i", $account->getHome()); + $this->assertEquals($i, intval($account->getQuota())); + $this->assertEquals(Account::STATE_ENABLED, $account->getState()); + $this->assertEquals($i, intval($account->getLastLogin())); + $account->setUserId("testaccount$i"); + } + + /** + * @param Account $account + * @param BackendGroup $group + */ + private function addUserAndAdmin($account, $group) { + // Add as both group user and group admin + $check = $this->manager->addGroupUser($account->getId(), $group->getId()); + $this->assertEquals(true, $check); + $check = $this->manager->addGroupAdmin($account->getId(), $group->getId()); + $this->assertEquals(true, $check); + + $result = $this->manager->getAdminBackendGroups($account->getUserId()); + $this->assertCount(1, $result); + $result = $this->manager->getUserBackendGroups($account->getUserId()); + $this->assertCount(1, $result); + $result = $this->manager->getUserBackendGroupsById($account->getId()); + $this->assertCount(1, $result); + + $result = $this->manager->isGroupAdmin($account->getUserId(), $group->getGroupId()); + $this->assertEquals(true, $result); + $result = $this->manager->isGroupUser($account->getUserId(), $group->getGroupId()); + $this->assertEquals(true, $result); + $result = $this->manager->isGroupUserById($account->getId(), $group->getId()); + $this->assertEquals(true, $result); + } + + /** + * @param Account $account + * @param BackendGroup $group + */ + private function addAdmin($account, $group) { + // Add only as group admin in another group + $check = $this->manager->addGroupAdmin($account->getId(), $group->getId()); + $this->assertEquals(true, $check); + + // After adding + $result = $this->manager->getAdminBackendGroups($account->getUserId()); + $this->assertCount(1, $result); + $result = $this->manager->getUserBackendGroups($account->getUserId()); + $this->assertCount(0, $result); + $result = $this->manager->getUserBackendGroupsById($account->getId()); + $this->assertCount(0, $result); + $result = $this->manager->isGroupAdmin($account->getUserId(), $group->getGroupId()); + $this->assertEquals(true, $result); + $result = $this->manager->isGroupUser($account->getUserId(), $group->getGroupId()); + $this->assertEquals(false, $result); + $result = $this->manager->isGroupUserById($account->getId(), $group->getId()); + $this->assertEquals(false, $result); + } + + /** + * @param Account $account + * @param BackendGroup $group + */ + private function addUser($account, $group) { + // Add only as group user in another group + $check = $this->manager->addGroupUser($account->getId(), $group->getId()); + $this->assertEquals(true, $check); + + // After adding + $result = $this->manager->getAdminBackendGroups($account->getUserId()); + $this->assertCount(0, $result); + $result = $this->manager->getUserBackendGroups($account->getUserId()); + $this->assertCount(1, $result); + $result = $this->manager->getUserBackendGroupsById($account->getId()); + $this->assertCount(1, $result); + $result = $this->manager->isGroupAdmin($account->getUserId(), $group->getGroupId()); + $this->assertEquals(false, $result); + $result = $this->manager->isGroupUser($account->getUserId(), $group->getGroupId()); + $this->assertEquals(true, $result); + $result = $this->manager->isGroupUserById($account->getId(), $group->getId()); + $this->assertEquals(true, $result); + } + + private function cleanupGroups() { + // Remove using group operations + for ($i = 1; $i <= 4; $i++) { + $group = self::getBackendGroup($i); + $this->manager->removeGroupMembers($group->getId()); + $result = $this->manager->getGroupUserAccounts($group->getGroupId()); + $this->assertCount(0, $result); + $result = $this->manager->getGroupUserAccountsById($group->getId()); + $this->assertCount(0, $result); + $result = $this->manager->getGroupAdminAccounts($group->getGroupId()); + $this->assertCount(0, $result); + } + + // Verify empty + $result = $this->manager->getGroupAdminAccounts(); + $this->assertCount(0, $result); + + // Verify empty + for ($i = 1; $i <= 4; $i++) { + $account = self::getAccount($i); + $result = $this->manager->getAdminBackendGroups($account->getUserId()); + $this->assertCount(0, $result); + $result = $this->manager->getUserBackendGroups($account->getUserId()); + $this->assertCount(0, $result); + $result = $this->manager->getUserBackendGroupsById($account->getId()); + $this->assertCount(0, $result); + } + } +} \ No newline at end of file