diff --git a/lib/Controller/RemoteActivityController.php b/lib/Controller/RemoteActivityController.php
index f0d00a0dd..c3ee92514 100644
--- a/lib/Controller/RemoteActivityController.php
+++ b/lib/Controller/RemoteActivityController.php
@@ -7,12 +7,18 @@
namespace OCA\Activity\Controller;
+use DateTimeInterface;
+use OC\Files\Storage\Wrapper\Wrapper;
use OCA\Activity\Extension\Files;
+use OCA\Files_Sharing\External\Storage as ExternalStorage;
use OCP\Activity\IManager as IActivityManager;
use OCP\App\IAppManager;
use OCP\AppFramework\Http;
+use OCP\AppFramework\Http\Attribute\BruteForceProtection;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCSController;
+use OCP\AppFramework\Utility\ITimeFactory;
+use OCP\Federation\ICloudIdManager;
use OCP\Files\InvalidPathException;
use OCP\Files\IRootFolder;
use OCP\Files\NotFoundException;
@@ -30,6 +36,8 @@ public function __construct(
protected IAppManager $appManager,
protected IRootFolder $rootFolder,
protected IActivityManager $activityManager,
+ protected ICloudIdManager $cloudIdManager,
+ protected ITimeFactory $timeFactory,
) {
parent::__construct($appName, $request);
}
@@ -48,12 +56,16 @@ public function __construct(
* @param string[] $origin
* @return DataResponse
*/
- public function receiveActivity($token, array $to, array $actor, $type, $updated, array $object = [], array $target = [], array $origin = []) {
- $date = \DateTime::createFromFormat(\DateTime::W3C, $updated);
- if ($date === false) {
+ #[BruteForceProtection(action: 'receiveActivity')]
+ public function receiveActivity($token, array $to, array $actor, $type, $updated, array $object = [], array $target = [], array $origin = []): DataResponse {
+ if (!$this->appManager->isInstalled('federatedfilesharing')) {
+ return new DataResponse([], Http::STATUS_NOT_FOUND);
+ }
+
+ $date = \DateTime::createFromFormat(DateTimeInterface::W3C, $updated);
+ if ($date === false || abs($date->getTimestamp() - $this->timeFactory->getTime()) > 600) {
return new DataResponse([], Http::STATUS_BAD_REQUEST);
}
- $time = $date->getTimestamp();
if (!isset($to['type'], $to['name']) || $to['type'] !== 'Person') {
return new DataResponse([], Http::STATUS_BAD_REQUEST);
@@ -61,7 +73,9 @@ public function receiveActivity($token, array $to, array $actor, $type, $updated
$user = $this->userManager->get($to['name']);
if (!$user instanceof IUser) {
- return new DataResponse([], Http::STATUS_NOT_FOUND);
+ $response = new DataResponse([], Http::STATUS_NOT_FOUND);
+ $response->throttle();
+ return $response;
}
if (!isset($actor['type'], $actor['name']) || $actor['type'] !== 'Person') {
@@ -72,27 +86,44 @@ public function receiveActivity($token, array $to, array $actor, $type, $updated
return new DataResponse([], Http::STATUS_BAD_REQUEST);
}
- if (!$this->appManager->isInstalled('federatedfilesharing')) {
- return new DataResponse([], Http::STATUS_NOT_FOUND);
+ if (isset($object['name']) && preg_match('/(^|\/)\.\.(\/|$)/', $object['name'])) {
+ return new DataResponse([], Http::STATUS_BAD_REQUEST);
+ }
+
+ try {
+ $resolved = $this->cloudIdManager->resolveCloudId($actor['name']);
+ $actorServer = $resolved->getRemote();
+ $actorUser = $resolved->getUser();
+ } catch (\InvalidArgumentException) {
+ return new DataResponse([], Http::STATUS_BAD_REQUEST);
+ }
+
+ $internalType = $this->translateType($type);
+ if ($internalType === '') {
+ return new DataResponse([], Http::STATUS_BAD_REQUEST);
}
$query = $this->db->getQueryBuilder();
$query->select('*')
->from('share_external')
->where($query->expr()->eq('share_token', $query->createNamedParameter($token)))
- ->andWhere($query->expr()->eq('user', $query->createNamedParameter($user->getUID())));
+ ->andWhere($query->expr()->eq('user', $query->createNamedParameter($user->getUID())))
+ ->andWhere($query->expr()->eq('owner', $query->createNamedParameter($actorUser)));
$result = $query->executeQuery();
$share = $result->fetch();
$result->closeCursor();
- if (!is_array($share) || strpos($share['mountpoint'], '{{TemporaryMountPointName#') === 0) {
- return new DataResponse([], Http::STATUS_NOT_FOUND);
+ if (!is_array($share) || str_starts_with($share['mountpoint'], '{{TemporaryMountPointName#')) {
+ $response = new DataResponse([], Http::STATUS_NOT_FOUND);
+ $response->throttle();
+ return $response;
}
- $internalType = $this->translateType($type);
- if ($internalType === '') {
- return new DataResponse([], Http::STATUS_BAD_REQUEST);
+ $normalizedActorServer = rtrim(strtolower(preg_replace('/^https?:\/\//', '', $actorServer)), '/');
+ $normalizedShareRemote = rtrim(strtolower(preg_replace('/^https?:\/\//', '', $share['remote'])), '/');
+ if ($normalizedActorServer !== $normalizedShareRemote) {
+ return new DataResponse([], Http::STATUS_FORBIDDEN);
}
$path2 = null;
@@ -111,7 +142,6 @@ public function receiveActivity($token, array $to, array $actor, $type, $updated
if (!isset($object['type'], $object['name']) || $object['type'] !== 'Document') {
return new DataResponse([], Http::STATUS_BAD_REQUEST);
}
-
$path = $share['mountpoint'] . $object['name'];
}
@@ -124,10 +154,25 @@ public function receiveActivity($token, array $to, array $actor, $type, $updated
try {
$node = $userFolder->get($path);
$fileId = $node->getId();
- } catch (NotFoundException $e) {
- return new DataResponse([], Http::STATUS_NOT_FOUND);
- } catch (InvalidPathException $e) {
- return new DataResponse([], Http::STATUS_NOT_FOUND);
+
+ $storage = $node->getStorage();
+ if (!$storage->instanceOfStorage(ExternalStorage::class)) {
+ $response = new DataResponse([], Http::STATUS_FORBIDDEN);
+ $response->throttle();
+ return $response;
+ }
+ while ($storage instanceof Wrapper) {
+ $storage = $storage->getWrapperStorage();
+ }
+ if (!($storage instanceof ExternalStorage) || $storage->getToken() !== $token) {
+ $response = new DataResponse([], Http::STATUS_FORBIDDEN);
+ $response->throttle();
+ return $response;
+ }
+ } catch (NotFoundException|InvalidPathException) {
+ $response = new DataResponse([], Http::STATUS_NOT_FOUND);
+ $response->throttle();
+ return $response;
}
if ($path2 !== null) {
@@ -136,8 +181,7 @@ public function receiveActivity($token, array $to, array $actor, $type, $updated
try {
$parent = $node->getParent();
$secondPath = [$parent->getId() => dirname($path2)];
- } catch (NotFoundException $e) {
- } catch (InvalidPathException $e) {
+ } catch (NotFoundException|InvalidPathException) {
}
}
$subjectParams = [$secondPath, $actor['name'], [$fileId => $path]];
@@ -153,11 +197,11 @@ public function receiveActivity($token, array $to, array $actor, $type, $updated
->setAuthor($actor['name'])
->setObject('files', $fileId, $path)
->setSubject($subject, $subjectParams)
- ->setTimestamp($time);
+ ->setTimestamp($date->getTimestamp());
$this->activityManager->publish($event);
- } catch (\InvalidArgumentException $e) {
+ } catch (\InvalidArgumentException) {
return new DataResponse(['activity'], Http::STATUS_BAD_REQUEST);
- } catch (\BadMethodCallException $e) {
+ } catch (\BadMethodCallException) {
return new DataResponse(['sending'], Http::STATUS_BAD_REQUEST);
}
diff --git a/tests/Controller/RemoteActivityControllerTest.php b/tests/Controller/RemoteActivityControllerTest.php
new file mode 100644
index 000000000..e8a499360
--- /dev/null
+++ b/tests/Controller/RemoteActivityControllerTest.php
@@ -0,0 +1,820 @@
+request = $this->createMock(IRequest::class);
+ $this->db = $this->createMock(IDBConnection::class);
+ $this->userManager = $this->createMock(IUserManager::class);
+ $this->appManager = $this->createMock(IAppManager::class);
+ $this->rootFolder = $this->createMock(IRootFolder::class);
+ $this->activityManager = $this->createMock(IActivityManager::class);
+ $this->cloudIdManager = $this->createMock(ICloudIdManager::class);
+ $this->timeFactory = $this->createMock(ITimeFactory::class);
+ $this->timeFactory->method('getTime')->willReturn(time());
+
+ $this->controller = new RemoteActivityController(
+ 'activity',
+ $this->request,
+ $this->db,
+ $this->userManager,
+ $this->appManager,
+ $this->rootFolder,
+ $this->activityManager,
+ $this->cloudIdManager,
+ $this->timeFactory,
+ );
+ }
+
+ private function validTimestamp(): string {
+ return date(\DateTime::W3C, time());
+ }
+
+ private function setupDbQueryMock(array|false $shareResult): void {
+ $expr = $this->createMock(IExpressionBuilder::class);
+ $expr->method('eq')->willReturn('');
+
+ $queryResult = $this->createMock(IResult::class);
+ $queryResult->method('fetch')->willReturn($shareResult);
+ $queryResult->method('closeCursor')->willReturn(true);
+
+ $qb = $this->createMock(IQueryBuilder::class);
+ $qb->method('select')->willReturnSelf();
+ $qb->method('from')->willReturnSelf();
+ $qb->method('where')->willReturnSelf();
+ $qb->method('andWhere')->willReturnSelf();
+ $qb->method('expr')->willReturn($expr);
+ $qb->method('createNamedParameter')->willReturnArgument(0);
+ $qb->method('executeQuery')->willReturn($queryResult);
+
+ $this->db->method('getQueryBuilder')->willReturn($qb);
+ }
+
+ private function createUserMock(string $uid = 'alice', string $cloudId = 'alice@nextcloud1.local'): IUser&MockObject {
+ $user = $this->createMock(IUser::class);
+ $user->method('getUID')->willReturn($uid);
+ $user->method('getCloudId')->willReturn($cloudId);
+ return $user;
+ }
+
+ private function createCloudIdMock(string $remote = 'https://nextcloud2.local/', string $user = 'admin'): ICloudId&MockObject {
+ $cloudId = $this->createMock(ICloudId::class);
+ $cloudId->method('getRemote')->willReturn($remote);
+ $cloudId->method('getUser')->willReturn($user);
+ return $cloudId;
+ }
+
+ private function validShare(string $remote = 'https://nextcloud2.local/'): array {
+ return [
+ 'mountpoint' => '/SharedFolder',
+ 'remote' => $remote,
+ ];
+ }
+
+ private function setupSuccessfulNode(string $path = '/SharedFolder/file.txt', int $fileId = 42): Node&MockObject {
+ $storage = $this->createMock(ExternalStorage::class);
+ $storage->method('instanceOfStorage')->willReturn(true);
+ $storage->method('getToken')->willReturn('token');
+
+ $node = $this->createMock(Node::class);
+ $node->method('getId')->willReturn($fileId);
+ $node->method('getStorage')->willReturn($storage);
+
+ $userFolder = $this->createMock(Folder::class);
+ $userFolder->method('get')->with($path)->willReturn($node);
+ $this->rootFolder->method('getUserFolder')->willReturn($userFolder);
+
+ return $node;
+ }
+
+ private function setupSuccessfulEvent(): IEvent&MockObject {
+ $event = $this->createMock(IEvent::class);
+ $event->method('setAffectedUser')->willReturnSelf();
+ $event->method('setApp')->willReturnSelf();
+ $event->method('setType')->willReturnSelf();
+ $event->method('setAuthor')->willReturnSelf();
+ $event->method('setObject')->willReturnSelf();
+ $event->method('setSubject')->willReturnSelf();
+ $event->method('setTimestamp')->willReturnSelf();
+
+ $this->activityManager->method('generateEvent')->willReturn($event);
+ return $event;
+ }
+
+ public function testFederationAppNotEnabled(): void {
+ $this->appManager->method('isInstalled')
+ ->with('federatedfilesharing')
+ ->willReturn(false);
+ $this->db->expects($this->never())->method('getQueryBuilder');
+
+ $response = $this->controller->receiveActivity(
+ 'token',
+ ['type' => 'Person', 'name' => 'alice'],
+ ['type' => 'Person', 'name' => 'admin@nextcloud2.local'],
+ 'Update', $this->validTimestamp(),
+ ['type' => 'Document', 'name' => '/file.txt'],
+ );
+
+ $this->assertSame(Http::STATUS_NOT_FOUND, $response->getStatus());
+ }
+
+ public static function dataInvalidTimestamps(): array {
+ return [
+ 'not a date string' => ['not-a-date'],
+ 'empty string' => [''],
+ 'plain unix integer' => ['1234567890'],
+ 'too old – 11 minutes ago' => [date(\DateTime::W3C, time() - 660)],
+ 'too far in future – 11 min' => [date(\DateTime::W3C, time() + 660)],
+ ];
+ }
+
+ /**
+ * @dataProvider dataInvalidTimestamps
+ */
+ public function testInvalidTimestamp(string $updated): void {
+ $this->appManager->method('isInstalled')->willReturn(true);
+ $this->db->expects($this->never())->method('getQueryBuilder');
+
+ $response = $this->controller->receiveActivity(
+ 'token',
+ ['type' => 'Person', 'name' => 'alice'],
+ ['type' => 'Person', 'name' => 'admin@nextcloud2.local'],
+ 'Update', $updated,
+ ['type' => 'Document', 'name' => '/file.txt'],
+ );
+
+ $this->assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus());
+ }
+
+ public function testTimestampJustInsideWindow(): void {
+ $this->appManager->method('isInstalled')->willReturn(true);
+ $user = $this->createUserMock();
+ $this->userManager->method('get')->willReturn($user);
+ $this->cloudIdManager->method('resolveCloudId')
+ ->willThrowException(new \InvalidArgumentException('invalid'));
+
+ $response = $this->controller->receiveActivity(
+ 'token',
+ ['type' => 'Person', 'name' => 'alice'],
+ ['type' => 'Person', 'name' => 'admin@nextcloud2.local'],
+ 'Update', date(\DateTime::W3C, time() - 299),
+ ['type' => 'Document', 'name' => '/file.txt'],
+ );
+
+ $this->assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus());
+ }
+
+ public static function dataInvalidTo(): array {
+ return [
+ 'missing type' => [['name' => 'alice']],
+ 'missing name' => [['type' => 'Person']],
+ 'wrong type' => [['type' => 'Organization', 'name' => 'alice']],
+ 'empty array' => [[]],
+ ];
+ }
+
+ /**
+ * @dataProvider dataInvalidTo
+ */
+ public function testInvalidTo(array $to): void {
+ $this->appManager->method('isInstalled')->willReturn(true);
+ $this->db->expects($this->never())->method('getQueryBuilder');
+
+ $response = $this->controller->receiveActivity(
+ 'token', $to,
+ ['type' => 'Person', 'name' => 'admin@nextcloud2.local'],
+ 'Update', $this->validTimestamp(),
+ ['type' => 'Document', 'name' => '/file.txt'],
+ );
+
+ $this->assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus());
+ }
+
+ public function testUserNotFound(): void {
+ $this->appManager->method('isInstalled')->willReturn(true);
+ $this->userManager->method('get')->with('alice')->willReturn(null);
+ $this->db->expects($this->never())->method('getQueryBuilder');
+
+ $response = $this->controller->receiveActivity(
+ 'token',
+ ['type' => 'Person', 'name' => 'alice'],
+ ['type' => 'Person', 'name' => 'admin@nextcloud2.local'],
+ 'Update', $this->validTimestamp(),
+ ['type' => 'Document', 'name' => '/file.txt'],
+ );
+
+ $this->assertSame(Http::STATUS_NOT_FOUND, $response->getStatus());
+ }
+
+ public static function dataInvalidActor(): array {
+ return [
+ 'missing type' => [['name' => 'admin@nextcloud2.local']],
+ 'missing name' => [['type' => 'Person']],
+ 'wrong type' => [['type' => 'Organization', 'name' => 'admin@nextcloud2.local']],
+ 'empty array' => [[]],
+ ];
+ }
+
+ /**
+ * @dataProvider dataInvalidActor
+ */
+ public function testInvalidActor(array $actor): void {
+ $this->appManager->method('isInstalled')->willReturn(true);
+ $this->userManager->method('get')->willReturn($this->createUserMock());
+ $this->db->expects($this->never())->method('getQueryBuilder');
+
+ $response = $this->controller->receiveActivity(
+ 'token',
+ ['type' => 'Person', 'name' => 'alice'],
+ $actor,
+ 'Update', $this->validTimestamp(),
+ ['type' => 'Document', 'name' => '/file.txt'],
+ );
+
+ $this->assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus());
+ }
+
+ public function testActorIsSameAsUser(): void {
+ $this->appManager->method('isInstalled')->willReturn(true);
+ $this->userManager->method('get')
+ ->willReturn($this->createUserMock('alice', 'alice@nextcloud1.local'));
+ $this->db->expects($this->never())->method('getQueryBuilder');
+
+ $response = $this->controller->receiveActivity(
+ 'token',
+ ['type' => 'Person', 'name' => 'alice'],
+ ['type' => 'Person', 'name' => 'alice@nextcloud1.local'],
+ 'Update', $this->validTimestamp(),
+ ['type' => 'Document', 'name' => '/file.txt'],
+ );
+
+ $this->assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus());
+ }
+
+ public static function dataPathTraversal(): array {
+ return [
+ 'double-dot at start' => ['../secret.txt'],
+ 'double-dot in the middle' => ['foo/../bar'],
+ 'double-dot at end' => ['foo/..'],
+ 'bare double-dot' => ['..'],
+ 'multiple traversal segments' => ['../../etc/passwd'],
+ ];
+ }
+
+ /**
+ * @dataProvider dataPathTraversal
+ */
+ public function testPathTraversal(string $objectName): void {
+ $this->appManager->method('isInstalled')->willReturn(true);
+ $this->userManager->method('get')->willReturn($this->createUserMock());
+ $this->db->expects($this->never())->method('getQueryBuilder');
+
+ $response = $this->controller->receiveActivity(
+ 'token',
+ ['type' => 'Person', 'name' => 'alice'],
+ ['type' => 'Person', 'name' => 'admin@nextcloud2.local'],
+ 'Update', $this->validTimestamp(),
+ ['type' => 'Document', 'name' => $objectName],
+ );
+
+ $this->assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus());
+ }
+
+ public function testPathWithThreeDotsIsAllowed(): void {
+ $this->appManager->method('isInstalled')->willReturn(true);
+ $this->userManager->method('get')->willReturn($this->createUserMock());
+ $this->cloudIdManager->method('resolveCloudId')
+ ->willThrowException(new \InvalidArgumentException());
+
+ $response = $this->controller->receiveActivity(
+ 'token',
+ ['type' => 'Person', 'name' => 'alice'],
+ ['type' => 'Person', 'name' => 'admin@nextcloud2.local'],
+ 'Update', $this->validTimestamp(),
+ ['type' => 'Document', 'name' => '.../file.txt'],
+ );
+
+ $this->assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus());
+ }
+
+ public function testInvalidCloudId(): void {
+ $this->appManager->method('isInstalled')->willReturn(true);
+ $this->userManager->method('get')->willReturn($this->createUserMock());
+ $this->cloudIdManager->method('resolveCloudId')
+ ->willThrowException(new \InvalidArgumentException('not a cloud id'));
+ $this->db->expects($this->never())->method('getQueryBuilder');
+
+ $response = $this->controller->receiveActivity(
+ 'token',
+ ['type' => 'Person', 'name' => 'alice'],
+ ['type' => 'Person', 'name' => 'not-valid'],
+ 'Update', $this->validTimestamp(),
+ ['type' => 'Document', 'name' => '/file.txt'],
+ );
+
+ $this->assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus());
+ }
+
+ public function testUnknownActivityType(): void {
+ $this->appManager->method('isInstalled')->willReturn(true);
+ $this->userManager->method('get')->willReturn($this->createUserMock());
+ $this->cloudIdManager->method('resolveCloudId')->willReturn($this->createCloudIdMock());
+ $this->db->expects($this->never())->method('getQueryBuilder');
+
+ $response = $this->controller->receiveActivity(
+ 'token',
+ ['type' => 'Person', 'name' => 'alice'],
+ ['type' => 'Person', 'name' => 'admin@nextcloud2.local'],
+ 'UnknownType', $this->validTimestamp(),
+ ['type' => 'Document', 'name' => '/file.txt'],
+ );
+
+ $this->assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus());
+ }
+
+ public function testShareNotFound(): void {
+ $this->appManager->method('isInstalled')->willReturn(true);
+ $this->userManager->method('get')->willReturn($this->createUserMock());
+ $this->cloudIdManager->method('resolveCloudId')->willReturn($this->createCloudIdMock());
+ $this->setupDbQueryMock(false);
+
+ $response = $this->controller->receiveActivity(
+ 'token',
+ ['type' => 'Person', 'name' => 'alice'],
+ ['type' => 'Person', 'name' => 'admin@nextcloud2.local'],
+ 'Update', $this->validTimestamp(),
+ ['type' => 'Document', 'name' => '/file.txt'],
+ );
+
+ $this->assertSame(Http::STATUS_NOT_FOUND, $response->getStatus());
+ }
+
+ public function testShareWithTemporaryMountpoint(): void {
+ $this->appManager->method('isInstalled')->willReturn(true);
+ $this->userManager->method('get')->willReturn($this->createUserMock());
+ $this->cloudIdManager->method('resolveCloudId')->willReturn($this->createCloudIdMock());
+ $this->setupDbQueryMock([
+ 'mountpoint' => '{{TemporaryMountPointName#/image.png}}',
+ 'remote' => 'https://nextcloud2.local/',
+ ]);
+
+ $response = $this->controller->receiveActivity(
+ 'token',
+ ['type' => 'Person', 'name' => 'alice'],
+ ['type' => 'Person', 'name' => 'admin@nextcloud2.local'],
+ 'Update', $this->validTimestamp(),
+ ['type' => 'Document', 'name' => '/file.txt'],
+ );
+
+ $this->assertSame(Http::STATUS_NOT_FOUND, $response->getStatus());
+ }
+
+ public function testActorServerMismatch(): void {
+ $this->appManager->method('isInstalled')->willReturn(true);
+ $this->userManager->method('get')->willReturn($this->createUserMock());
+ $this->cloudIdManager->method('resolveCloudId')
+ ->willReturn($this->createCloudIdMock('https://nextcloud2.local/', 'admin'));
+ $this->setupDbQueryMock([
+ 'mountpoint' => '/SharedFolder',
+ 'remote' => 'https://nextcloud3.local/',
+ ]);
+
+ $response = $this->controller->receiveActivity(
+ 'token',
+ ['type' => 'Person', 'name' => 'alice'],
+ ['type' => 'Person', 'name' => 'admin@nextcloud2.local'],
+ 'Update', $this->validTimestamp(),
+ ['type' => 'Document', 'name' => '/file.txt'],
+ );
+
+ $this->assertSame(Http::STATUS_FORBIDDEN, $response->getStatus());
+ }
+
+ public static function dataActorServerNormalization(): array {
+ return [
+ 'exact match' => ['https://nextcloud2.local/', 'https://nextcloud2.local/', true],
+ 'actor missing trailing slash' => ['https://nextcloud2.local', 'https://nextcloud2.local/', true],
+ 'db remote missing trailing slash' => ['https://nextcloud2.local/', 'https://nextcloud2.local', true],
+ 'actor uses http, db uses https' => ['http://nextcloud2.local/', 'https://nextcloud2.local/', true],
+ 'completely different server' => ['https://evil.com/', 'https://nextcloud2.local/', false],
+ 'subdomain mismatch' => ['https://sub.nextcloud2.local/', 'https://nextcloud2.local/', false],
+ ];
+ }
+
+ /**
+ * @dataProvider dataActorServerNormalization
+ */
+ public function testActorServerNormalization(string $actorServer, string $shareRemote, bool $shouldMatch): void {
+ $this->appManager->method('isInstalled')->willReturn(true);
+ $this->userManager->method('get')->willReturn($this->createUserMock());
+ $this->cloudIdManager->method('resolveCloudId')
+ ->willReturn($this->createCloudIdMock($actorServer, 'admin'));
+ $this->setupDbQueryMock([
+ 'mountpoint' => '/SharedFolder',
+ 'remote' => $shareRemote,
+ ]);
+
+ if ($shouldMatch) {
+ $userFolder = $this->createMock(Folder::class);
+ $userFolder->method('get')->willThrowException(new NotFoundException());
+ $this->rootFolder->method('getUserFolder')->willReturn($userFolder);
+ }
+
+ $response = $this->controller->receiveActivity(
+ 'token',
+ ['type' => 'Person', 'name' => 'alice'],
+ ['type' => 'Person', 'name' => 'admin@nextcloud2.local'],
+ 'Update', $this->validTimestamp(),
+ ['type' => 'Document', 'name' => '/file.txt'],
+ );
+
+ $expected = $shouldMatch ? Http::STATUS_NOT_FOUND : Http::STATUS_FORBIDDEN;
+ $this->assertSame($expected, $response->getStatus());
+ }
+
+ public static function dataInvalidMoveParams(): array {
+ return [
+ 'missing target' => [[], ['type' => 'Document', 'name' => '/old.png']],
+ 'target missing type' => [['name' => '/new.png'], ['type' => 'Document', 'name' => '/old.png']],
+ 'target wrong type' => [['type' => 'Wrong', 'name' => '/new.png'], ['type' => 'Document', 'name' => '/old.png']],
+ 'missing origin' => [['type' => 'Document', 'name' => '/new.png'], []],
+ 'origin missing type' => [['type' => 'Document', 'name' => '/new.png'], ['name' => '/old.png']],
+ 'origin wrong type' => [['type' => 'Document', 'name' => '/new.png'], ['type' => 'Wrong', 'name' => '/old.png']],
+ ];
+ }
+
+ /**
+ * @dataProvider dataInvalidMoveParams
+ */
+ public function testInvalidMoveParams(array $target, array $origin): void {
+ $this->appManager->method('isInstalled')->willReturn(true);
+ $this->userManager->method('get')->willReturn($this->createUserMock());
+ $this->cloudIdManager->method('resolveCloudId')->willReturn($this->createCloudIdMock());
+ $this->setupDbQueryMock($this->validShare());
+
+ $response = $this->controller->receiveActivity(
+ 'token',
+ ['type' => 'Person', 'name' => 'alice'],
+ ['type' => 'Person', 'name' => 'admin@nextcloud2.local'],
+ 'Move', $this->validTimestamp(),
+ ['type' => 'Document', 'name' => ''],
+ $target, $origin,
+ );
+
+ $this->assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus());
+ }
+
+ public static function dataInvalidObjectParams(): array {
+ return [
+ 'missing type' => [['name' => '/file.txt']],
+ 'missing name' => [['type' => 'Document']],
+ 'wrong type' => [['type' => 'Wrong', 'name' => '/file.txt']],
+ ];
+ }
+
+ /**
+ * @dataProvider dataInvalidObjectParams
+ */
+ public function testInvalidObjectParams(array $object): void {
+ $this->appManager->method('isInstalled')->willReturn(true);
+ $this->userManager->method('get')->willReturn($this->createUserMock());
+ $this->cloudIdManager->method('resolveCloudId')->willReturn($this->createCloudIdMock());
+ $this->setupDbQueryMock($this->validShare());
+
+ $response = $this->controller->receiveActivity(
+ 'token',
+ ['type' => 'Person', 'name' => 'alice'],
+ ['type' => 'Person', 'name' => 'admin@nextcloud2.local'],
+ 'Update', $this->validTimestamp(),
+ $object,
+ );
+
+ $this->assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus());
+ }
+
+ public function testNodeNotFoundException(): void {
+ $this->appManager->method('isInstalled')->willReturn(true);
+ $this->userManager->method('get')->willReturn($this->createUserMock());
+ $this->cloudIdManager->method('resolveCloudId')->willReturn($this->createCloudIdMock());
+ $this->setupDbQueryMock($this->validShare());
+
+ $userFolder = $this->createMock(Folder::class);
+ $userFolder->method('get')->willThrowException(new NotFoundException());
+ $this->rootFolder->method('getUserFolder')->willReturn($userFolder);
+
+ $response = $this->controller->receiveActivity(
+ 'token',
+ ['type' => 'Person', 'name' => 'alice'],
+ ['type' => 'Person', 'name' => 'admin@nextcloud2.local'],
+ 'Update', $this->validTimestamp(),
+ ['type' => 'Document', 'name' => '/file.txt'],
+ );
+
+ $this->assertSame(Http::STATUS_NOT_FOUND, $response->getStatus());
+ }
+
+ public function testNodeInvalidPathException(): void {
+ $this->appManager->method('isInstalled')->willReturn(true);
+ $this->userManager->method('get')->willReturn($this->createUserMock());
+ $this->cloudIdManager->method('resolveCloudId')->willReturn($this->createCloudIdMock());
+ $this->setupDbQueryMock($this->validShare());
+
+ $userFolder = $this->createMock(Folder::class);
+ $userFolder->method('get')->willThrowException(new InvalidPathException());
+ $this->rootFolder->method('getUserFolder')->willReturn($userFolder);
+
+ $response = $this->controller->receiveActivity(
+ 'token',
+ ['type' => 'Person', 'name' => 'alice'],
+ ['type' => 'Person', 'name' => 'admin@nextcloud2.local'],
+ 'Update', $this->validTimestamp(),
+ ['type' => 'Document', 'name' => '/file.txt'],
+ );
+
+ $this->assertSame(Http::STATUS_NOT_FOUND, $response->getStatus());
+ }
+
+ public function testPublishInvalidArgumentException(): void {
+ $this->appManager->method('isInstalled')->willReturn(true);
+ $this->userManager->method('get')->willReturn($this->createUserMock());
+ $this->cloudIdManager->method('resolveCloudId')->willReturn($this->createCloudIdMock());
+ $this->setupDbQueryMock($this->validShare());
+ $this->setupSuccessfulNode();
+ $this->setupSuccessfulEvent();
+
+ $this->activityManager->expects($this->once())
+ ->method('publish')
+ ->willThrowException(new \InvalidArgumentException());
+
+ $response = $this->controller->receiveActivity(
+ 'token',
+ ['type' => 'Person', 'name' => 'alice'],
+ ['type' => 'Person', 'name' => 'admin@nextcloud2.local'],
+ 'Update', $this->validTimestamp(),
+ ['type' => 'Document', 'name' => '/file.txt'],
+ );
+
+ $this->assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus());
+ }
+
+ public function testPublishBadMethodCallException(): void {
+ $this->appManager->method('isInstalled')->willReturn(true);
+ $this->userManager->method('get')->willReturn($this->createUserMock());
+ $this->cloudIdManager->method('resolveCloudId')->willReturn($this->createCloudIdMock());
+ $this->setupDbQueryMock($this->validShare());
+ $this->setupSuccessfulNode();
+ $this->setupSuccessfulEvent();
+
+ $this->activityManager->expects($this->once())
+ ->method('publish')
+ ->willThrowException(new \BadMethodCallException());
+
+ $response = $this->controller->receiveActivity(
+ 'token',
+ ['type' => 'Person', 'name' => 'alice'],
+ ['type' => 'Person', 'name' => 'admin@nextcloud2.local'],
+ 'Update', $this->validTimestamp(),
+ ['type' => 'Document', 'name' => '/file.txt'],
+ );
+
+ $this->assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus());
+ }
+
+ public static function dataSuccessfulNonMoveTypes(): array {
+ return [
+ 'Create' => ['Create'],
+ 'Update' => ['Update'],
+ 'Delete' => ['Delete'],
+ ];
+ }
+
+ /**
+ * @dataProvider dataSuccessfulNonMoveTypes
+ */
+ public function testSuccessfulNonMoveActivity(string $type): void {
+ $this->appManager->method('isInstalled')->willReturn(true);
+ $user = $this->createUserMock();
+ $this->userManager->method('get')->willReturn($user);
+ $this->cloudIdManager->method('resolveCloudId')->willReturn($this->createCloudIdMock());
+ $this->setupDbQueryMock($this->validShare());
+ $this->setupSuccessfulNode();
+ $this->setupSuccessfulEvent();
+
+ $this->activityManager->expects($this->once())->method('publish');
+
+ $response = $this->controller->receiveActivity(
+ 'token',
+ ['type' => 'Person', 'name' => 'alice'],
+ ['type' => 'Person', 'name' => 'admin@nextcloud2.local'],
+ $type, $this->validTimestamp(),
+ ['type' => 'Document', 'name' => '/file.txt'],
+ );
+
+ $this->assertSame(Http::STATUS_OK, $response->getStatus());
+ }
+
+ public function testSuccessfulMoveRename(): void {
+ $this->appManager->method('isInstalled')->willReturn(true);
+ $this->userManager->method('get')->willReturn($this->createUserMock());
+ $this->cloudIdManager->method('resolveCloudId')->willReturn($this->createCloudIdMock());
+ $this->setupDbQueryMock($this->validShare());
+
+ $targetName = '/new_name.png';
+ $originName = '/old_name.png';
+ $path = '/SharedFolder' . $targetName;
+
+ $this->setupSuccessfulNode($path, 55);
+ $this->setupSuccessfulEvent();
+ $this->activityManager->expects($this->once())->method('publish');
+
+ $response = $this->controller->receiveActivity(
+ 'token',
+ ['type' => 'Person', 'name' => 'alice'],
+ ['type' => 'Person', 'name' => 'admin@nextcloud2.local'],
+ 'Move', $this->validTimestamp(),
+ ['type' => 'Document', 'name' => ''],
+ ['type' => 'Document', 'name' => $targetName],
+ ['type' => 'Document', 'name' => $originName],
+ );
+
+ $this->assertSame(Http::STATUS_OK, $response->getStatus());
+ }
+
+ public function testStorageNotExternalStorage(): void {
+ $this->appManager->method('isInstalled')->willReturn(true);
+ $this->userManager->method('get')->willReturn($this->createUserMock());
+ $this->cloudIdManager->method('resolveCloudId')->willReturn($this->createCloudIdMock());
+ $this->setupDbQueryMock($this->validShare());
+
+ $storage = $this->createMock(InternalStorage::class);
+ $storage->method('instanceOfStorage')->willReturn(false);
+
+ $node = $this->createMock(Node::class);
+ $node->method('getStorage')->willReturn($storage);
+
+ $userFolder = $this->createMock(Folder::class);
+ $userFolder->method('get')->willReturn($node);
+ $this->rootFolder->method('getUserFolder')->willReturn($userFolder);
+
+ $response = $this->controller->receiveActivity(
+ 'token',
+ ['type' => 'Person', 'name' => 'alice'],
+ ['type' => 'Person', 'name' => 'admin@nextcloud2.local'],
+ 'Update', $this->validTimestamp(),
+ ['type' => 'Document', 'name' => '/file.txt'],
+ );
+
+ $this->assertSame(Http::STATUS_FORBIDDEN, $response->getStatus());
+ }
+
+ public function testStorageWrapperResolvesToNonExternalStorage(): void {
+ $this->appManager->method('isInstalled')->willReturn(true);
+ $this->userManager->method('get')->willReturn($this->createUserMock());
+ $this->cloudIdManager->method('resolveCloudId')->willReturn($this->createCloudIdMock());
+ $this->setupDbQueryMock($this->validShare());
+
+ // The outer storage reports instanceOfStorage true (passes first check),
+ // but unwrapping the Wrapper chain reveals a non-ExternalStorage backend.
+ $innerStorage = $this->createMock(InternalStorage::class);
+ $wrapperStorage = $this->getMockBuilder(Wrapper::class)
+ ->disableOriginalConstructor()
+ ->getMock();
+ $wrapperStorage->method('instanceOfStorage')->willReturn(true);
+ $wrapperStorage->method('getWrapperStorage')->willReturn($innerStorage);
+
+ $node = $this->createMock(Node::class);
+ $node->method('getStorage')->willReturn($wrapperStorage);
+
+ $userFolder = $this->createMock(Folder::class);
+ $userFolder->method('get')->willReturn($node);
+ $this->rootFolder->method('getUserFolder')->willReturn($userFolder);
+
+ $response = $this->controller->receiveActivity(
+ 'token',
+ ['type' => 'Person', 'name' => 'alice'],
+ ['type' => 'Person', 'name' => 'admin@nextcloud2.local'],
+ 'Update', $this->validTimestamp(),
+ ['type' => 'Document', 'name' => '/file.txt'],
+ );
+
+ $this->assertSame(Http::STATUS_FORBIDDEN, $response->getStatus());
+ }
+
+ public function testExternalStorageTokenMismatch(): void {
+ $this->appManager->method('isInstalled')->willReturn(true);
+ $this->userManager->method('get')->willReturn($this->createUserMock());
+ $this->cloudIdManager->method('resolveCloudId')->willReturn($this->createCloudIdMock());
+ $this->setupDbQueryMock($this->validShare());
+
+ $storage = $this->createMock(ExternalStorage::class);
+ $storage->method('instanceOfStorage')->willReturn(true);
+ $storage->method('getToken')->willReturn('wrong-token');
+
+ $node = $this->createMock(Node::class);
+ $node->method('getStorage')->willReturn($storage);
+
+ $userFolder = $this->createMock(Folder::class);
+ $userFolder->method('get')->willReturn($node);
+ $this->rootFolder->method('getUserFolder')->willReturn($userFolder);
+
+ $response = $this->controller->receiveActivity(
+ 'token',
+ ['type' => 'Person', 'name' => 'alice'],
+ ['type' => 'Person', 'name' => 'admin@nextcloud2.local'],
+ 'Update', $this->validTimestamp(),
+ ['type' => 'Document', 'name' => '/file.txt'],
+ );
+
+ $this->assertSame(Http::STATUS_FORBIDDEN, $response->getStatus());
+ }
+
+ public function testSuccessfulMoveToAnotherFolder(): void {
+ $this->appManager->method('isInstalled')->willReturn(true);
+ $this->userManager->method('get')->willReturn($this->createUserMock());
+ $this->cloudIdManager->method('resolveCloudId')->willReturn($this->createCloudIdMock());
+ $this->setupDbQueryMock($this->validShare());
+
+ $targetName = '/subfolder/image.png';
+ $originName = '/image.png';
+ $path = '/SharedFolder' . $targetName;
+
+ $parent = $this->createMock(Folder::class);
+ $parent->method('getId')->willReturn(77);
+
+ $storage = $this->createMock(ExternalStorage::class);
+ $storage->method('instanceOfStorage')->willReturn(true);
+ $storage->method('getToken')->willReturn('token');
+
+ $node = $this->createMock(Node::class);
+ $node->method('getId')->willReturn(99);
+ $node->method('getParent')->willReturn($parent);
+ $node->method('getStorage')->willReturn($storage);
+
+ $userFolder = $this->createMock(Folder::class);
+ $userFolder->method('get')->with($path)->willReturn($node);
+ $this->rootFolder->method('getUserFolder')->willReturn($userFolder);
+
+ $this->setupSuccessfulEvent();
+ $this->activityManager->expects($this->once())->method('publish');
+
+ $response = $this->controller->receiveActivity(
+ 'token',
+ ['type' => 'Person', 'name' => 'alice'],
+ ['type' => 'Person', 'name' => 'admin@nextcloud2.local'],
+ 'Move', $this->validTimestamp(),
+ ['type' => 'Document', 'name' => ''],
+ ['type' => 'Document', 'name' => $targetName],
+ ['type' => 'Document', 'name' => $originName],
+ );
+
+ $this->assertSame(Http::STATUS_OK, $response->getStatus());
+ }
+
+}
diff --git a/tests/psalm-baseline.xml b/tests/psalm-baseline.xml
index c4c7119c2..237e2895c 100644
--- a/tests/psalm-baseline.xml
+++ b/tests/psalm-baseline.xml
@@ -5,6 +5,12 @@
+
+
+
+
+
+
request->server]]>