]> source.dussan.org Git - nextcloud-server.git/commitdiff
Add repair step for unmerged shares (WIP)
authorVincent Petry <pvince81@owncloud.com>
Tue, 12 Jul 2016 11:38:49 +0000 (13:38 +0200)
committerRoeland Jago Douma <roeland@famdouma.nl>
Wed, 3 Aug 2016 08:16:28 +0000 (10:16 +0200)
lib/private/Repair.php
lib/private/Repair/RepairUnmergedShares.php [new file with mode: 0644]
tests/lib/Repair/RepairUnmergedSharesTest.php [new file with mode: 0644]

index 6c1eef5b9f66fcd59caf6349ebf61f02ce8e477f..cd23f5cb806edd7136f93a83e737e49dd5fb9492 100644 (file)
@@ -49,6 +49,7 @@ use OC\Repair\RepairMimeTypes;
 use OC\Repair\SearchLuceneTables;
 use OC\Repair\UpdateOutdatedOcsIds;
 use OC\Repair\RepairInvalidShares;
+use OC\Repair\RepairUnmergedShares;
 use OCP\AppFramework\QueryException;
 use OCP\Migration\IOutput;
 use OCP\Migration\IRepairStep;
@@ -140,6 +141,12 @@ class Repair implements IOutput{
                        new RemoveOldShares(\OC::$server->getDatabaseConnection()),
                        new AvatarPermissions(\OC::$server->getDatabaseConnection()),
                        new RemoveRootShares(\OC::$server->getDatabaseConnection(), \OC::$server->getUserManager(), \OC::$server->getLazyRootFolder()),
+                       new RepairUnmergedShares(
+                               \OC::$server->getConfig(),
+                               \OC::$server->getDatabaseConnection(),
+                               \OC::$server->getUserManager(),
+                               \OC::$server->getGroupManager()
+                       ),
                ];
        }
 
diff --git a/lib/private/Repair/RepairUnmergedShares.php b/lib/private/Repair/RepairUnmergedShares.php
new file mode 100644 (file)
index 0000000..3e28bcf
--- /dev/null
@@ -0,0 +1,323 @@
+<?php
+/**
+ * @author Vincent Petry <pvince81@owncloud.com>
+ *
+ * @copyright Copyright (c) 2016, ownCloud, Inc.
+ * @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 <http://www.gnu.org/licenses/>
+ *
+ */
+
+namespace OC\Repair;
+
+use OCP\Migration\IOutput;
+use OCP\Migration\IRepairStep;
+use OC\Share\Constants;
+use OCP\DB\QueryBuilder\IQueryBuilder;
+use OCP\IConfig;
+use OCP\IDBConnection;
+use OCP\IUserManager;
+use OCP\IUser;
+use OCP\IGroupManager;
+use OC\Share20\DefaultShareProvider;
+
+/**
+ * Repairs shares for which the received folder was not properly deduplicated.
+ *
+ * An unmerged share can for example happen when sharing a folder with the same
+ * user through multiple ways, like several groups and also directly, additionally
+ * to group shares. Since 9.0.0 these would create duplicate entries "folder (2)",
+ * one for every share. This repair step rearranges them so they only appear as a single
+ * folder.
+ */
+class RepairUnmergedShares implements IRepairStep {
+
+       /** @var \OCP\IConfig */
+       protected $config;
+
+       /** @var \OCP\IDBConnection */
+       protected $connection;
+
+       /** @var IUserManager */
+       protected $userManager;
+
+       /** @var IGroupManager */
+       protected $groupManager;
+
+       /** @var IQueryBuilder */
+       private $queryGetSharesWithUsers;
+
+       /** @var IQueryBuilder */
+       private $queryUpdateSharePermissionsAndTarget;
+
+       /** @var IQueryBuilder */
+       private $queryUpdateShareInBatch;
+
+       /**
+        * @param \OCP\IConfig $config
+        * @param \OCP\IDBConnection $connection
+        */
+       public function __construct(
+               IConfig $config,
+               IDBConnection $connection,
+               IUserManager $userManager,
+               IGroupManager $groupManager
+       ) {
+               $this->connection = $connection;
+               $this->config = $config;
+               $this->userManager = $userManager;
+               $this->groupManager = $groupManager;
+       }
+
+       public function getName() {
+               return 'Repair unmerged shares';
+       }
+
+       /**
+        * Builds prepared queries for reuse
+        */
+       private function buildPreparedQueries() {
+               /**
+                * Retrieve shares for a given user/group and share type
+                */
+               $query = $this->connection->getQueryBuilder();
+               $query
+                       ->select('item_source', 'id', 'file_target', 'permissions', 'parent', 'share_type')
+                       ->from('share')
+                       ->where($query->expr()->eq('share_type', $query->createParameter('shareType')))
+                       ->andWhere($query->expr()->in('share_with', $query->createParameter('shareWiths')))
+                       ->andWhere($query->expr()->in('item_type', $query->createParameter('itemTypes')))
+                       ->orderBy('item_source', 'ASC')
+                       ->addOrderBy('stime', 'ASC');
+
+               $this->queryGetSharesWithUsers = $query;
+
+               /**
+                * Updates the file_target to the given value for all given share ids.
+                *
+                * This updates several shares in bulk which is faster than individually.
+                */
+               $query = $this->connection->getQueryBuilder();
+               $query->update('share')
+                       ->set('file_target', $query->createParameter('file_target'))
+                       ->where($query->expr()->in('id', $query->createParameter('ids')));
+
+               $this->queryUpdateShareInBatch = $query;
+
+               /**
+                * Updates the share permissions and target path of a single share.
+                */
+               $query = $this->connection->getQueryBuilder();
+               $query->update('share')
+                       ->set('permissions', $query->createParameter('permissions'))
+                       ->set('file_target', $query->createParameter('file_target'))
+                       ->where($query->expr()->eq('id', $query->createParameter('shareid')));
+
+               $this->queryUpdateSharePermissionsAndTarget = $query;
+
+       }
+
+       private function getSharesWithUser($shareType, $shareWiths) {
+               $groupedShares = [];
+
+               $query = $this->queryGetSharesWithUsers;
+               $query->setParameter('shareWiths', $shareWiths, IQueryBuilder::PARAM_STR_ARRAY);
+               $query->setParameter('shareType', $shareType);
+               $query->setParameter('itemTypes', ['file', 'folder'], IQueryBuilder::PARAM_STR_ARRAY);
+
+               $shares = $query->execute()->fetchAll();
+
+               // group by item_source
+               foreach ($shares as $share) {
+                       if (!isset($groupedShares[$share['item_source']])) {
+                               $groupedShares[$share['item_source']] = [];
+                       }
+                       $groupedShares[$share['item_source']][] = $share;
+               }
+               return $groupedShares;
+       }
+
+       /**
+        * Fix the given received share represented by the set of group shares
+        * and matching sub shares
+        *
+        * @param array $groupShares group share entries
+        * @param array $subShares sub share entries
+        *
+        * @return boolean false if the share was not repaired, true if it was
+        */
+       private function fixThisShare($groupShares, $subShares) {
+               $groupSharesById = [];
+               foreach ($groupShares as $groupShare) {
+                       $groupSharesById[$groupShare['id']] = $groupShare;
+               }
+
+               if ($this->isThisShareValid($groupSharesById, $subShares)) {
+                       return false;
+               }
+
+               $targetPath = $groupShares[0]['file_target'];
+
+               // check whether the user opted out completely of all subshares
+               $optedOut = true;
+               foreach ($subShares as $subShare) {
+                       if ((int)$subShare['permissions'] !== 0) {
+                               $optedOut = false;
+                               break;
+                       }
+               }
+
+               $shareIds = [];
+               foreach ($subShares as $subShare) {
+                       // only if the user deleted some subshares but not all, adjust the permissions of that subshare
+                       if (!$optedOut && (int)$subShare['permissions'] === 0 && (int)$subShare['share_type'] === DefaultShareProvider::SHARE_TYPE_USERGROUP) {
+                               // set permissions from parent group share
+                               $permissions = $groupSharesById[$subShare['parent']]['permissions'];
+
+                               // fix permissions and target directly
+                               $query = $this->queryUpdateSharePermissionsAndTarget;
+                               $query->setParameter('shareid', $subShare['id']);
+                               $query->setParameter('file_target', $targetPath);
+                               $query->setParameter('permissions', $permissions);
+                               $query->execute();
+                       } else {
+                               // gather share ids for bulk target update
+                               if ($subShare['file_target'] !== $targetPath) {
+                                       $shareIds[] = (int)$subShare['id'];
+                               }
+                       }
+               }
+
+               if (!empty($shareIds)) {
+                       $query = $this->queryUpdateShareInBatch;
+                       $query->setParameter('ids', $shareIds, IQueryBuilder::PARAM_INT_ARRAY);
+                       $query->setParameter('file_target', $targetPath);
+                       $query->execute();
+               }
+
+               return true;
+       }
+
+       /**
+        * Checks whether the number of group shares is balanced with the child subshares.
+        * If all group shares have exactly one subshare, and the target of every subshare
+        * is the same, then the share is valid.
+        * If however there is a group share entry that has no matching subshare, it means
+        * we're in the bogus situation and the whole share must be repaired
+        *
+        * @param array $groupSharesById
+        * @param array $subShares
+        *
+        * @return true if the share is valid, false if it needs repair
+        */
+       private function isThisShareValid($groupSharesById, $subShares) {
+               $foundTargets = [];
+
+               // every group share needs to have exactly one matching subshare
+               foreach ($subShares as $subShare) {
+                       $foundTargets[$subShare['file_target']] = true;
+                       if (count($foundTargets) > 1) {
+                               // not all the same target path value => invalid
+                               return false;
+                       }
+                       if (isset($groupSharesById[$subShare['parent']])) {
+                               // remove it from the list as we found it
+                               unset($groupSharesById[$subShare['parent']]);
+                       }
+               }
+
+               // if we found one subshare per group entry, the set will be empty.
+               // If not empty, it means that one of the group shares did not have
+               // a matching subshare entry.
+               return empty($groupSharesById);
+       }
+
+       /**
+        * Detect unmerged received shares and merge them properly
+        */
+       private function fixUnmergedShares(IOutput $out, IUser $user) {
+               $groups = $this->groupManager->getUserGroupIds($user);
+               if (empty($groups)) {
+                       // user is in no groups, so can't have received group shares
+                       return;
+               }
+
+               $subSharesByItemSource = $this->getSharesWithUser(DefaultShareProvider::SHARE_TYPE_USERGROUP, [$user->getUID()]);
+               if (empty($subSharesByItemSource)) {
+                       // nothing to repair for this user
+                       return;
+               }
+
+               $groupSharesByItemSource = $this->getSharesWithUser(Constants::SHARE_TYPE_GROUP, $groups);
+               if (empty($groupSharesByItemSource)) {
+                       // shouldn't happen, those invalid shares must be cleant already by RepairInvalidShares
+                       return;
+               }
+
+               // because sometimes one wants to give the user more permissions than the group share
+               $userSharesByItemSource = $this->getSharesWithUser(Constants::SHARE_TYPE_USER, [$user->getUID()]);
+
+               foreach ($groupSharesByItemSource as $itemSource => $groupShares) {
+                       if (!isset($subSharesByItemSource[$itemSource])) {
+                               // no subshares for this item source, skip it
+                               continue;
+                       }
+                       $subShares = $subSharesByItemSource[$itemSource];
+
+                       if (isset($userSharesByItemSource[$itemSource])) {
+                               // add it to the subshares to get a similar treatment
+                               $subShares = array_merge($subShares, $userSharesByItemSource[$itemSource]);
+                       }
+
+                       $this->fixThisShare($groupShares, $subShares);
+               }
+       }
+
+       /**
+        * Count all the users
+        *
+        * @return int
+        */
+       private function countUsers() {
+               $allCount = $this->userManager->countUsers();
+
+               $totalCount = 0;
+               foreach ($allCount as $backend => $count) {
+                       $totalCount += $count;
+               }
+
+               return $totalCount;
+       }
+
+       public function run(IOutput $output) {
+               $ocVersionFromBeforeUpdate = $this->config->getSystemValue('version', '0.0.0');
+               if (version_compare($ocVersionFromBeforeUpdate, '9.0.4.0', '<')) {
+                       // this situation was only possible between 9.0.0 and 9.0.3 included
+
+                       $function = function(IUser $user) use ($output) {
+                               $this->fixUnmergedShares($output, $user);
+                               $output->advance();
+                       };
+
+                       $this->buildPreparedQueries();
+
+                       $userCount = $this->countUsers();
+                       $output->startProgress($userCount);
+
+                       $this->userManager->callForAllUsers($function);
+
+                       $output->finishProgress();
+               }
+       }
+}
diff --git a/tests/lib/Repair/RepairUnmergedSharesTest.php b/tests/lib/Repair/RepairUnmergedSharesTest.php
new file mode 100644 (file)
index 0000000..20dabac
--- /dev/null
@@ -0,0 +1,409 @@
+<?php
+/**
+ * @author Vincent Petry <pvince81@owncloud.com>
+ *
+ * @copyright Copyright (c) 2016, ownCloud, Inc.
+ * @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 <http://www.gnu.org/licenses/>
+ *
+ */
+
+namespace Test\Repair;
+
+
+use OC\Repair\RepairUnmergedShares;
+use OC\Share\Constants;
+use OCP\Migration\IOutput;
+use OCP\Migration\IRepairStep;
+use Test\TestCase;
+use OC\Share20\DefaultShareProvider;
+
+/**
+ * Tests for repairing invalid shares
+ *
+ * @group DB
+ *
+ * @see \OC\Repair\RepairUnmergedShares
+ */
+class RepairUnmergedSharesTest extends TestCase {
+
+       /** @var IRepairStep */
+       private $repair;
+
+       /** @var \OCP\IDBConnection */
+       private $connection;
+
+       protected function setUp() {
+               parent::setUp();
+
+               $config = $this->getMockBuilder('OCP\IConfig')
+                       ->disableOriginalConstructor()
+                       ->getMock();
+               $config->expects($this->any())
+                       ->method('getSystemValue')
+                       ->with('version')
+                       ->will($this->returnValue('9.0.3.0'));
+
+               $this->connection = \OC::$server->getDatabaseConnection();
+               $this->deleteAllShares();
+
+               $user1 = $this->getMock('\OCP\IUser');
+               $user1->expects($this->any())
+                       ->method('getUID')
+                       ->will($this->returnValue('user1'));
+
+               $user2 = $this->getMock('\OCP\IUser');
+               $user2->expects($this->any())
+                       ->method('getUID')
+                       ->will($this->returnValue('user2'));
+
+               $users = [$user1, $user2];
+
+               $groupManager = $this->getMock('\OCP\IGroupManager');
+               $groupManager->expects($this->any())
+                       ->method('getUserGroupIds')
+                       ->will($this->returnValueMap([
+                               // owner
+                               [$user1, ['samegroup1', 'samegroup2']],
+                               // recipient
+                               [$user2, ['recipientgroup1', 'recipientgroup2']],
+                       ]));
+
+               $userManager = $this->getMock('\OCP\IUserManager');
+               $userManager->expects($this->once())
+                       ->method('countUsers')
+                       ->will($this->returnValue([2]));
+               $userManager->expects($this->once())
+                       ->method('callForAllUsers')
+                       ->will($this->returnCallback(function(\Closure $closure) use ($users) {
+                               foreach ($users as $user) {
+                                       $closure($user);
+                               }
+                       }));
+
+               /** @var \OCP\IConfig $config */
+               $this->repair = new RepairUnmergedShares($config, $this->connection, $userManager, $groupManager);
+       }
+
+       protected function tearDown() {
+               $this->deleteAllShares();
+
+               parent::tearDown();
+       }
+
+       protected function deleteAllShares() {
+               $qb = $this->connection->getQueryBuilder();
+               $qb->delete('share')->execute();
+       }
+
+       private function createShare($type, $sourceId, $recipient, $targetName, $permissions, $parentId = null) {
+               $qb = $this->connection->getQueryBuilder();
+               $values = [
+                       'share_type' => $qb->expr()->literal($type),
+                       'share_with' => $qb->expr()->literal($recipient),
+                       'uid_owner' => $qb->expr()->literal('user1'),
+                       'item_type' => $qb->expr()->literal('folder'),
+                       'item_source' => $qb->expr()->literal($sourceId),
+                       'item_target' => $qb->expr()->literal('/' . $sourceId),
+                       'file_source' => $qb->expr()->literal($sourceId),
+                       'file_target' => $qb->expr()->literal($targetName),
+                       'permissions' => $qb->expr()->literal($permissions),
+                       'stime' => $qb->expr()->literal(time()),
+               ];
+               if ($parentId !== null) {
+                       $values['parent'] = $qb->expr()->literal($parentId);
+               }
+               $qb->insert('share')
+                       ->values($values)
+                       ->execute();
+
+               return $this->connection->lastInsertId('*PREFIX*share');
+       }
+
+       private function getShareById($id) {
+               $query = $this->connection->getQueryBuilder();
+               $results = $query
+                       ->select('*')
+                       ->from('share')
+                       ->where($query->expr()->eq('id', $query->expr()->literal($id)))
+                       ->execute()
+                       ->fetchAll();
+
+               if (!empty($results)) {
+                       return $results[0];
+               }
+               return null;
+       }
+
+       public function sharesDataProvider() {
+               /**
+                * For all these test cases we have the following situation:
+                *
+                * - "user1" is the share owner
+                * - "user2" is the recipient, and member of "recipientgroup1" and "recipientgroup2"
+                * - "user1" is member of "samegroup1", "samegroup2" for same group tests
+                */
+               return [
+                       [
+                               // #0 legitimate share:
+                               // - outsider shares with group1, group2
+                               // - recipient renamed, resulting in subshares
+                               // - one subshare for each group share
+                               // - targets of subshare all match
+                               [
+                                       [Constants::SHARE_TYPE_GROUP, 123, 'recipientgroup1', '/test', 31],
+                                       [Constants::SHARE_TYPE_GROUP, 123, 'recipientgroup2', '/test', 31],
+                                       // child of the previous ones
+                                       [DefaultShareProvider::SHARE_TYPE_USERGROUP, 123, 'user2', '/test renamed', 31, 0],
+                                       // child of the previous ones
+                                       [DefaultShareProvider::SHARE_TYPE_USERGROUP, 123, 'user2', '/test renamed', 31, 1],
+                                       // different unrelated share
+                                       [Constants::SHARE_TYPE_GROUP, 456, 'recipientgroup1', '/test (4)', 31],
+                               ],
+                               [
+                                       ['/test', 31],
+                                       ['/test', 31],
+                                       // leave them alone
+                                       ['/test renamed', 31],
+                                       ['/test renamed', 31],
+                                       // leave unrelated alone
+                                       ['/test (4)', 31],
+                               ]
+                       ],
+                       [
+                               // #1 broken share:
+                               // - outsider shares with group1, group2
+                               // - only one subshare for two group shares
+                               [
+                                       [Constants::SHARE_TYPE_GROUP, 123, 'recipientgroup1', '/test', 31],
+                                       [Constants::SHARE_TYPE_GROUP, 123, 'recipientgroup2', '/test', 31],
+                                       // child of the previous one
+                                       [DefaultShareProvider::SHARE_TYPE_USERGROUP, 123, 'user2', '/test (2)', 31, 1],
+                                       // different unrelated share
+                                       [Constants::SHARE_TYPE_GROUP, 456, 'recipientgroup1', '/test (4)', 31],
+                               ],
+                               [
+                                       ['/test', 31],
+                                       ['/test', 31],
+                                       ['/test', 31],
+                                       // leave unrelated alone
+                                       ['/test (4)', 31],
+                               ]
+                       ],
+                       [
+                               // #2 bogus share
+                               // - outsider shares with group1, group2
+                               // - one subshare for each group share
+                               // - but the targets do not match when grouped
+                               [
+                                       [Constants::SHARE_TYPE_GROUP, 123, 'recipientgroup1', '/test', 31],
+                                       [Constants::SHARE_TYPE_GROUP, 123, 'recipientgroup2', '/test', 31],
+                                       // child of the previous ones
+                                       [DefaultShareProvider::SHARE_TYPE_USERGROUP, 123, 'user2', '/test (2)', 31, 0],
+                                       [DefaultShareProvider::SHARE_TYPE_USERGROUP, 123, 'user2', '/test (3)', 31, 1],
+                                       // different unrelated share
+                                       [Constants::SHARE_TYPE_GROUP, 456, 'recipientgroup1', '/test (4)', 31],
+                               ],
+                               [
+                                       ['/test', 31],
+                                       ['/test', 31],
+                                       // reset to original name
+                                       ['/test', 31],
+                                       ['/test', 31],
+                                       // leave unrelated alone
+                                       ['/test (4)', 31],
+                               ]
+                       ],
+                       [
+                               // #3 bogus share
+                               // - outsider shares with group1, group2
+                               // - one subshare for each group share
+                               // - first subshare not renamed (as in real world scenario)
+                               // - but the targets do not match when grouped
+                               [
+                                       [Constants::SHARE_TYPE_GROUP, 123, 'recipientgroup1', '/test', 31],
+                                       [Constants::SHARE_TYPE_GROUP, 123, 'recipientgroup2', '/test', 31],
+                                       // child of the previous ones
+                                       [DefaultShareProvider::SHARE_TYPE_USERGROUP, 123, 'user2', '/test', 31, 0],
+                                       [DefaultShareProvider::SHARE_TYPE_USERGROUP, 123, 'user2', '/test (2)', 31, 1],
+                                       // different unrelated share
+                                       [Constants::SHARE_TYPE_GROUP, 456, 'recipientgroup1', '/test (4)', 31],
+                               ],
+                               [
+                                       ['/test', 31],
+                                       ['/test', 31],
+                                       // reset to original name
+                                       ['/test', 31],
+                                       ['/test', 31],
+                                       // leave unrelated alone
+                                       ['/test (4)', 31],
+                               ]
+                       ],
+                       [
+                               // #4 bogus share:
+                               // - outsider shares with group1, group2
+                               // - one subshare for each group share
+                               // - non-matching targets
+                               // - recipient deletes one duplicate (unshare from self, permissions 0)
+                               [
+                                       [Constants::SHARE_TYPE_GROUP, 123, 'recipientgroup1', '/test', 31],
+                                       [Constants::SHARE_TYPE_GROUP, 123, 'recipientgroup2', '/test', 15],
+                                       // child of the previous ones
+                                       [DefaultShareProvider::SHARE_TYPE_USERGROUP, 123, 'user2', '/test (2)', 0, 0],
+                                       [DefaultShareProvider::SHARE_TYPE_USERGROUP, 123, 'user2', '/test (3)', 15, 1],
+                                       // different unrelated share
+                                       [Constants::SHARE_TYPE_GROUP, 456, 'recipientgroup1', '/test (4)', 31],
+                               ],
+                               [
+                                       ['/test', 31],
+                                       ['/test', 15],
+                                       // subshares repaired and permissions restored to the max allowed
+                                       ['/test', 31],
+                                       ['/test', 15],
+                                       // leave unrelated alone
+                                       ['/test (4)', 31],
+                               ]
+                       ],
+                       [
+                               // #5 bogus share:
+                               // - outsider shares with group1, group2
+                               // - one subshare for each group share
+                               // - non-matching targets
+                               // - recipient deletes ALL duplicates (unshare from self, permissions 0)
+                               [
+                                       [Constants::SHARE_TYPE_GROUP, 123, 'recipientgroup1', '/test', 31],
+                                       [Constants::SHARE_TYPE_GROUP, 123, 'recipientgroup2', '/test', 15],
+                                       // child of the previous ones
+                                       [DefaultShareProvider::SHARE_TYPE_USERGROUP, 123, 'user2', '/test (2)', 0, 0],
+                                       [DefaultShareProvider::SHARE_TYPE_USERGROUP, 123, 'user2', '/test (3)', 0, 1],
+                                       // different unrelated share
+                                       [Constants::SHARE_TYPE_GROUP, 456, 'recipientgroup1', '/test (4)', 31],
+                               ],
+                               [
+                                       ['/test', 31],
+                                       ['/test', 15],
+                                       // subshares target repaired but left "deleted" as it was the user's choice
+                                       ['/test', 0],
+                                       ['/test', 0],
+                                       // leave unrelated alone
+                                       ['/test (4)', 31],
+                               ]
+                       ],
+                       [
+                               // #6 bogus share:
+                               // - outsider shares with group1, group2 and also user2
+                               // - one subshare for each group share
+                               // - one extra share entry for direct share to user2
+                               // - non-matching targets
+                               // - user share has more permissions
+                               [
+                                       [Constants::SHARE_TYPE_GROUP, 123, 'recipientgroup1', '/test', 1],
+                                       [Constants::SHARE_TYPE_GROUP, 123, 'recipientgroup2', '/test', 15],
+                                       // child of the previous ones
+                                       [DefaultShareProvider::SHARE_TYPE_USERGROUP, 123, 'user2', '/test (2)', 1, 0],
+                                       [DefaultShareProvider::SHARE_TYPE_USERGROUP, 123, 'user2', '/test (3)', 15, 1],
+                                       [Constants::SHARE_TYPE_USER, 123, 'user2', '/test (4)', 31],
+                                       // different unrelated share
+                                       [Constants::SHARE_TYPE_GROUP, 456, 'recipientgroup1', '/test (5)', 31],
+                               ],
+                               [
+                                       ['/test', 1],
+                                       ['/test', 15],
+                                       // subshares repaired
+                                       ['/test', 1],
+                                       ['/test', 15],
+                                       ['/test', 31],
+                                       // leave unrelated alone
+                                       ['/test (5)', 31],
+                               ]
+                       ],
+                       [
+                               // #7 legitimate share with own group:
+                               // - insider shares with both groups the user is already in
+                               // - no subshares in this case
+                               [
+                                       [Constants::SHARE_TYPE_GROUP, 123, 'samegroup1', '/test', 31],
+                                       [Constants::SHARE_TYPE_GROUP, 123, 'samegroup2', '/test', 31],
+                                       // different unrelated share
+                                       [Constants::SHARE_TYPE_GROUP, 456, 'recipientgroup1', '/test (4)', 31],
+                               ],
+                               [
+                                       // leave all alone
+                                       ['/test', 31],
+                                       ['/test', 31],
+                                       // leave unrelated alone
+                                       ['/test (4)', 31],
+                               ]
+                       ],
+                       [
+                               // #7 legitimate shares:
+                               // - group share with same group
+                               // - group share with other group
+                               // - user share where recipient renamed
+                               // - user share where recipient did not rename
+                               [
+                                       [Constants::SHARE_TYPE_GROUP, 123, 'samegroup1', '/test', 31],
+                                       [Constants::SHARE_TYPE_GROUP, 123, 'recipientgroup1', '/test', 31],
+                                       [Constants::SHARE_TYPE_USER, 123, 'user3', '/test legit rename', 31],
+                                       [Constants::SHARE_TYPE_USER, 123, 'user4', '/test', 31],
+                                       // different unrelated share
+                                       [Constants::SHARE_TYPE_GROUP, 456, 'recipientgroup1', '/test (4)', 31],
+                               ],
+                               [
+                                       // leave all alone
+                                       ['/test', 31],
+                                       ['/test', 31],
+                                       ['/test legit rename', 31],
+                                       ['/test', 31],
+                                       // leave unrelated alone
+                                       ['/test (4)', 31],
+                               ]
+                       ],
+               ];
+       }
+
+       /**
+        * Test merge shares from group shares
+        *
+        * @dataProvider sharesDataProvider
+        */
+       public function testMergeGroupShares($shares, $expectedShares) {
+               $shareIds = [];
+
+               foreach ($shares as $share) {
+                       // if parent
+                       if (isset($share[5])) {
+                               // adjust to real id
+                               $share[5] = $shareIds[$share[5]];
+                       } else {
+                               $share[5] = null;
+                       }
+                       $shareIds[] = $this->createShare($share[0], $share[1], $share[2], $share[3], $share[4], $share[5]);
+               }
+
+               /** @var IOutput | \PHPUnit_Framework_MockObject_MockObject $outputMock */
+               $outputMock = $this->getMockBuilder('\OCP\Migration\IOutput')
+                       ->disableOriginalConstructor()
+                       ->getMock();
+
+               $this->repair->run($outputMock);
+
+               foreach ($expectedShares as $index => $expectedShare) {
+                       $share = $this->getShareById($shareIds[$index]);
+                       $this->assertEquals($expectedShare[0], $share['file_target']);
+                       $this->assertEquals($expectedShare[1], $share['permissions']);
+               }
+       }
+}
+