nextcloud/apps/files_sharing/tests/Command/FixOwncloudGroupSubshareStatusTest.php
Anna Larch 6220ae9175 fix(sharing): restore STATUS_ACCEPTED for OC-migrated group share subshares
When an ownCloud-migrated group share (which has no per-user USERGROUP
subshare) is renamed for the first time, DefaultShareProvider::move()
inserted a new USERGROUP row without setting `accepted`. The column
defaulted to 0 (STATUS_PENDING), causing MountProvider to skip the
share on the next login — the shared file disappeared for the recipient.

Fix: set accepted = STATUS_ACCEPTED explicitly on the INSERT in
DefaultShareProvider::move() for the TYPE_GROUP branch.

Secondary fix: SharedMount::moveMount() silently returned true when
updateFileTarget() threw (e.g. group no longer exists on an OC-migrated
instance). Set $result = false in the catch block so View::rename()
propagates the failure instead of silently corrupting VFS state.

An opt-in occ command (sharing:fix-owncloud-group-shares) with --dry-run
support is included to repair existing broken instances. It targets only
TYPE_USERGROUP subshares with accepted=STATUS_PENDING and permissions!=0
(shares that were accepted but broken by the missing column default),
leaving explicitly declined shares (permissions=0) untouched.

AI-Assisted-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Anna Larch <anna@nextcloud.com>
2026-05-26 17:28:39 +02:00

131 lines
4.8 KiB
PHP

<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Files_Sharing\Tests\Command;
use OCA\Files_Sharing\Command\FixOwncloudGroupSubshareStatus;
use OCA\Files_Sharing\Tests\TestCase;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
use OCP\Server;
use OCP\Share\IShare;
use Symfony\Component\Console\Tester\CommandTester;
#[\PHPUnit\Framework\Attributes\Group(name: 'DB')]
class FixOwncloudGroupSubshareStatusTest extends TestCase {
private IDBConnection $connection;
private CommandTester $commandTester;
protected function setUp(): void {
parent::setUp();
$this->connection = Server::get(IDBConnection::class);
$this->commandTester = new CommandTester(new FixOwncloudGroupSubshareStatus($this->connection));
$this->cleanDB();
}
protected function tearDown(): void {
$this->cleanDB();
parent::tearDown();
}
private function cleanDB(): void {
$this->connection->getQueryBuilder()->delete('share')->executeStatement();
}
private function insertShare(int $shareType, int $accepted, int $permissions): int {
$qb = $this->connection->getQueryBuilder();
$qb->insert('share')
->values([
'share_type' => $qb->createNamedParameter($shareType, IQueryBuilder::PARAM_INT),
'share_with' => $qb->createNamedParameter('user1'),
'uid_owner' => $qb->createNamedParameter('owner'),
'uid_initiator' => $qb->createNamedParameter('owner'),
'parent' => $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT),
'item_type' => $qb->createNamedParameter('file'),
'item_source' => $qb->createNamedParameter('42'),
'item_target' => $qb->createNamedParameter('/file'),
'file_source' => $qb->createNamedParameter(42, IQueryBuilder::PARAM_INT),
'file_target' => $qb->createNamedParameter('/file'),
'permissions' => $qb->createNamedParameter($permissions, IQueryBuilder::PARAM_INT),
'stime' => $qb->createNamedParameter(time(), IQueryBuilder::PARAM_INT),
'accepted' => $qb->createNamedParameter($accepted, IQueryBuilder::PARAM_INT),
])
->executeStatement();
return (int)$this->connection->lastInsertId('*PREFIX*share');
}
private function getAccepted(int $id): int {
$qb = $this->connection->getQueryBuilder();
return (int)$qb->select('accepted')
->from('share')
->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT)))
->executeQuery()
->fetchOne();
}
public function testFixesPendingSubshareWithPermissions(): void {
$id = $this->insertShare(IShare::TYPE_USERGROUP, IShare::STATUS_PENDING, 31);
$this->commandTester->execute([]);
$this->assertSame(IShare::STATUS_ACCEPTED, $this->getAccepted($id));
$this->assertStringContainsString('Fixed', $this->commandTester->getDisplay());
}
public function testDryRunShowsCountWithoutChanging(): void {
$id = $this->insertShare(IShare::TYPE_USERGROUP, IShare::STATUS_PENDING, 31);
$this->commandTester->execute(['--dry-run' => true]);
$this->assertSame(IShare::STATUS_PENDING, $this->getAccepted($id));
$this->assertStringContainsString('dry-run', $this->commandTester->getDisplay());
}
public function testDoesNotTouchDeclinedSubshare(): void {
// permissions = 0 means the user explicitly declined the share
$id = $this->insertShare(IShare::TYPE_USERGROUP, IShare::STATUS_PENDING, 0);
$this->commandTester->execute([]);
$this->assertSame(IShare::STATUS_PENDING, $this->getAccepted($id));
$this->assertStringContainsString('No affected', $this->commandTester->getDisplay());
}
public function testDoesNotTouchAlreadyAcceptedSubshare(): void {
$id = $this->insertShare(IShare::TYPE_USERGROUP, IShare::STATUS_ACCEPTED, 31);
$this->commandTester->execute([]);
$this->assertSame(IShare::STATUS_ACCEPTED, $this->getAccepted($id));
$this->assertStringContainsString('No affected', $this->commandTester->getDisplay());
}
public function testDoesNotTouchNonUsergroupShares(): void {
$id = $this->insertShare(IShare::TYPE_GROUP, IShare::STATUS_PENDING, 31);
$this->commandTester->execute([]);
$this->assertSame(IShare::STATUS_PENDING, $this->getAccepted($id));
$this->assertStringContainsString('No affected', $this->commandTester->getDisplay());
}
public function testFixesMultipleAffectedRows(): void {
$id1 = $this->insertShare(IShare::TYPE_USERGROUP, IShare::STATUS_PENDING, 31);
$id2 = $this->insertShare(IShare::TYPE_USERGROUP, IShare::STATUS_PENDING, 17);
$idDeclined = $this->insertShare(IShare::TYPE_USERGROUP, IShare::STATUS_PENDING, 0);
$this->commandTester->execute([]);
$this->assertSame(IShare::STATUS_ACCEPTED, $this->getAccepted($id1));
$this->assertSame(IShare::STATUS_ACCEPTED, $this->getAccepted($id2));
$this->assertSame(IShare::STATUS_PENDING, $this->getAccepted($idDeclined));
$this->assertStringContainsString('2', $this->commandTester->getDisplay());
}
}