diff --git a/apps/admin_audit/lib/Actions/Action.php b/apps/admin_audit/lib/Actions/Action.php index f367e029064bb..43e2d28e3816c 100644 --- a/apps/admin_audit/lib/Actions/Action.php +++ b/apps/admin_audit/lib/Actions/Action.php @@ -8,8 +8,20 @@ namespace OCA\AdminAudit\Actions; +use OC\Files\Storage\FailedStorage; +use OC\Files\Storage\Local; +use OC\Files\Storage\Wrapper\Jail; use OCA\AdminAudit\IAuditLogger; use OCA\AdminAudit\Operation; +use OCP\Files\ForbiddenException; +use OCP\Files\IHomeStorage; +use OCP\Files\InvalidPathException; +use OCP\Files\Node; +use OCP\Files\NotFoundException; +use OCP\Files\Storage\ISharedStorage; +use OCP\Files\Storage\IStorage; +use OCP\IConfig; +use OCP\Server; class Action { @@ -79,4 +91,93 @@ public function log( $context, ); } + + /** + * Returns [$key => path] with the path of the node as seen by the owner of the + * share it was accessed through, or [] if it was not accessed through a share + * + * @return array + */ + protected function getOwnerPathParam(string $key, Node $node): array { + $ownerPath = $this->resolveNodePath($node, static function (IStorage $storage, string $internalPath): ?string { + if (!$storage->instanceOfStorage(ISharedStorage::class)) { + return null; + } + /** @var ISharedStorage $storage */ + $sharePath = $storage->getShare()->getNode()->getPath(); + return $internalPath === '' ? $sharePath : $sharePath . '/' . $internalPath; + }); + return $ownerPath === null ? [] : [$key => $ownerPath]; + } + + /** + * Returns [$key => path] with the location of the node in the storage that holds it, + * or [] if it is held in a home storage or the storage is not available. On local + * storage this is the path relative to the data directory + * (e.g. "__groupfolders/1/files/a.txt"), otherwise the storage id followed by the + * path in that storage. + * + * @return array + */ + protected function getRealPathParam(string $key, Node $node): array { + $realPath = $this->resolveNodePath($node, static function (IStorage $storage, string $internalPath): ?string { + while ($storage->instanceOfStorage(Jail::class)) { + /** @var Jail $storage */ + $internalPath = $storage->getUnjailedPath($internalPath); + $storage = $storage->getUnjailedStorage(); + } + if ($storage->instanceOfStorage(IHomeStorage::class) || $storage->instanceOfStorage(FailedStorage::class)) { + return null; + } + if (!$storage->instanceOfStorage(Local::class)) { + return rtrim($storage->getId(), '/') . '/' . $internalPath; + } + /** @var Local $storage */ + $sourcePath = rtrim($storage->getSourcePath($internalPath), '/'); + $dataDirectory = rtrim(Server::get(IConfig::class)->getSystemValueString('datadirectory', \OC::$SERVERROOT . '/data'), '/') . '/'; + return str_starts_with($sourcePath, $dataDirectory) ? substr($sourcePath, strlen($dataDirectory)) : $sourcePath; + }); + return $realPath === null ? [] : [$key => $realPath]; + } + + /** + * Appends placeholders for the real path parameters in $params to $text. + * They must directly follow the parameters used by $text. + */ + protected function appendRealPathText(string $text, array $params): string { + if (isset($params['realPath'])) { + return $text . ' (real path = "%s")'; + } + $old = isset($params['oldRealPath']); + $new = isset($params['newRealPath']); + if ($old && $new) { + return $text . ' (real path from "%s" to "%s")'; + } + if ($old) { + return $text . ' (real path from "%s")'; + } + if ($new) { + return $text . ' (real path to "%s")'; + } + return $text; + } + + /** + * @param callable(IStorage, string): ?string $resolve + */ + private function resolveNodePath(Node $node, callable $resolve): ?string { + try { + try { + $storage = $node->getStorage(); + $internalPath = $node->getInternalPath(); + } catch (NotFoundException) { + // Nodes that no longer exist, like the source of a rename, are resolved through their parent + $parentPath = $this->resolveNodePath($node->getParent(), $resolve); + return $parentPath === null ? null : $parentPath . '/' . $node->getName(); + } + return $resolve($storage, $internalPath); + } catch (InvalidPathException|NotFoundException|ForbiddenException) { + return null; + } + } } diff --git a/apps/admin_audit/lib/Actions/Files.php b/apps/admin_audit/lib/Actions/Files.php index c6a8c122ecdbf..e8978c8f80858 100644 --- a/apps/admin_audit/lib/Actions/Files.php +++ b/apps/admin_audit/lib/Actions/Files.php @@ -36,7 +36,8 @@ public function read(BeforeNodeReadEvent $event): void { $params = [ 'id' => $node instanceof NonExistingFile ? 'not-yet-assigned' : $node->getId(), 'path' => $node->getPath(), - ]; + ] + $this->getRealPathParam('realPath', $node) + + $this->getOwnerPathParam('ownerPath', $node); } catch (InvalidPathException|NotFoundException $e) { Server::get(LoggerInterface::class)->error( 'Exception thrown in file read: ' . $e->getMessage(), ['app' => 'admin_audit', 'exception' => $e] @@ -45,7 +46,7 @@ public function read(BeforeNodeReadEvent $event): void { } $this->log( Operation::FileRead, - 'File with id "%s" accessed: "%s"', + $this->appendRealPathText('File with id "%s" accessed: "%s"', $params), $params, array_keys($params) ); @@ -62,7 +63,10 @@ public function afterRename(NodeRenamedEvent $event): void { 'newid' => $target->getId(), 'oldpath' => $source->getPath(), 'newpath' => $target->getPath(), - ]; + ] + $this->getRealPathParam('oldRealPath', $source) + + $this->getRealPathParam('newRealPath', $target) + + $this->getOwnerPathParam('oldOwnerPath', $source) + + $this->getOwnerPathParam('newOwnerPath', $target); } catch (InvalidPathException|NotFoundException $e) { Server::get(LoggerInterface::class)->error( 'Exception thrown in file rename: ' . $e->getMessage(), ['app' => 'admin_audit', 'exception' => $e] @@ -72,7 +76,7 @@ public function afterRename(NodeRenamedEvent $event): void { $this->log( Operation::FileRenamed, - 'File renamed with id "%s" from "%s" to "%s"', + $this->appendRealPathText('File renamed with id "%s" from "%s" to "%s"', $params), $params, array_keys($params) ); @@ -87,7 +91,8 @@ public function create(NodeCreatedEvent $event): void { $params = [ 'id' => $node->getId(), 'path' => $node->getPath(), - ]; + ] + $this->getRealPathParam('realPath', $node) + + $this->getOwnerPathParam('ownerPath', $node); } catch (InvalidPathException|NotFoundException $e) { Server::get(LoggerInterface::class)->error( 'Exception thrown in file create: ' . $e->getMessage(), ['app' => 'admin_audit', 'exception' => $e] @@ -99,7 +104,7 @@ public function create(NodeCreatedEvent $event): void { } $this->log( Operation::FileCreated, - 'File with id "%s" created: "%s"', + $this->appendRealPathText('File with id "%s" created: "%s"', $params), $params, array_keys($params) ); @@ -117,7 +122,10 @@ public function copy(NodeCopiedEvent $event): void { 'newid' => $target->getId(), 'oldpath' => $source->getPath(), 'newpath' => $target->getPath(), - ]; + ] + $this->getRealPathParam('oldRealPath', $source) + + $this->getRealPathParam('newRealPath', $target) + + $this->getOwnerPathParam('oldOwnerPath', $source) + + $this->getOwnerPathParam('newOwnerPath', $target); } catch (InvalidPathException|NotFoundException $e) { Server::get(LoggerInterface::class)->error( 'Exception thrown in file copy: ' . $e->getMessage(), ['app' => 'admin_audit', 'exception' => $e] @@ -126,7 +134,7 @@ public function copy(NodeCopiedEvent $event): void { } $this->log( Operation::FileCopied, - 'File id copied from: "%s" to "%s", path from "%s" to "%s"', + $this->appendRealPathText('File id copied from: "%s" to "%s", path from "%s" to "%s"', $params), $params, array_keys($params) ); @@ -141,7 +149,8 @@ public function write(NodeWrittenEvent $event): void { $params = [ 'id' => $node->getId(), 'path' => $node->getPath(), - ]; + ] + $this->getRealPathParam('realPath', $node) + + $this->getOwnerPathParam('ownerPath', $node); } catch (InvalidPathException|NotFoundException $e) { Server::get(LoggerInterface::class)->error( 'Exception thrown in file write: ' . $e->getMessage(), ['app' => 'admin_audit', 'exception' => $e] @@ -154,7 +163,7 @@ public function write(NodeWrittenEvent $event): void { $this->log( Operation::FileWritten, - 'File with id "%s" written to: "%s"', + $this->appendRealPathText('File with id "%s" written to: "%s"', $params), $params, array_keys($params) ); @@ -169,7 +178,8 @@ public function delete(BeforeNodeDeletedEvent $event): void { $params = [ 'id' => $node->getId(), 'path' => $node->getPath(), - ]; + ] + $this->getRealPathParam('realPath', $node) + + $this->getOwnerPathParam('ownerPath', $node); } catch (InvalidPathException|NotFoundException $e) { Server::get(LoggerInterface::class)->error( 'Exception thrown in file delete: ' . $e->getMessage(), ['app' => 'admin_audit', 'exception' => $e] @@ -178,7 +188,7 @@ public function delete(BeforeNodeDeletedEvent $event): void { } $this->log( Operation::FileDeleted, - 'File with id "%s" deleted: "%s"', + $this->appendRealPathText('File with id "%s" deleted: "%s"', $params), $params, array_keys($params) ); diff --git a/apps/admin_audit/lib/Listener/FileEventListener.php b/apps/admin_audit/lib/Listener/FileEventListener.php index 1d8902673e26c..fe670ef373fac 100644 --- a/apps/admin_audit/lib/Listener/FileEventListener.php +++ b/apps/admin_audit/lib/Listener/FileEventListener.php @@ -46,10 +46,11 @@ private function beforePreviewFetched(BeforePreviewFetchedEvent $event): void { 'crop' => $event->isCrop(), 'mode' => $event->getMode(), 'path' => $file->getPath(), - ]; + ] + $this->getRealPathParam('realPath', $file) + + $this->getOwnerPathParam('ownerPath', $file); $this->log( Operation::PreviewAccessed, - 'Preview accessed: (id: "%s", width: "%s", height: "%s" crop: "%s", mode: "%s", path: "%s")', + $this->appendRealPathText('Preview accessed: (id: "%s", width: "%s", height: "%s" crop: "%s", mode: "%s", path: "%s")', $params), $params, array_keys($params) ); diff --git a/apps/admin_audit/tests/Actions/FilesTest.php b/apps/admin_audit/tests/Actions/FilesTest.php index caead03c36eec..a3c471bde010b 100644 --- a/apps/admin_audit/tests/Actions/FilesTest.php +++ b/apps/admin_audit/tests/Actions/FilesTest.php @@ -9,6 +9,10 @@ namespace OCA\AdminAudit\Tests\Actions; +use OC\Files\Storage\FailedStorage; +use OC\Files\Storage\Home; +use OC\Files\Storage\Local; +use OC\Files\Storage\Wrapper\Jail; use OCA\AdminAudit\Actions\Files; use OCA\AdminAudit\IAuditLogger; use OCP\Files\Events\Node\BeforeNodeDeletedEvent; @@ -18,6 +22,15 @@ use OCP\Files\Events\Node\NodeRenamedEvent; use OCP\Files\Events\Node\NodeWrittenEvent; use OCP\Files\File; +use OCP\Files\Folder; +use OCP\Files\IHomeStorage; +use OCP\Files\NotFoundException; +use OCP\Files\Storage\ISharedStorage; +use OCP\Files\Storage\IStorage; +use OCA\Files_Sharing\SharedStorage; +use OCP\IConfig; +use OCP\Server; +use OCP\Share\IShare; use PHPUnit\Framework\MockObject\MockObject; use Test\TestCase; @@ -36,9 +49,13 @@ protected function setUp(): void { $this->source = $this->createMock(File::class); $this->source->method('getId')->willReturn(41); $this->source->method('getPath')->willReturn('/alice/files/a.txt'); + $this->source->method('getInternalPath')->willReturn('files/a.txt'); + $this->source->method('getStorage')->willReturn($this->createHomeStorage()); $this->target = $this->createMock(File::class); $this->target->method('getId')->willReturn(42); $this->target->method('getPath')->willReturn('/alice/files/b.txt'); + $this->target->method('getInternalPath')->willReturn('files/b.txt'); + $this->target->method('getStorage')->willReturn($this->createHomeStorage()); } private function expectInfo(string $message, string $operation, array $params): void { @@ -76,4 +93,256 @@ public function testDelete(): void { $this->expectInfo('File with id "42" deleted: "/alice/files/b.txt"', 'files.file.deleted', ['id' => 42, 'path' => '/alice/files/b.txt']); $this->files->delete(new BeforeNodeDeletedEvent($this->target)); } + + private function createHomeStorage(): IHomeStorage&MockObject { + $storage = $this->createMock(IHomeStorage::class); + $storage->method('instanceOfStorage')->willReturnCallback(static fn (string $class): bool => $class === IHomeStorage::class); + return $storage; + } + + private function createSharedStorage(string $sharePath, ?IStorage $sourceStorage = null, string $shareRoot = ''): SharedStorage&MockObject { + $sourceStorage ??= $this->createHomeStorage(); + $shareNode = $this->createMock(Folder::class); + $shareNode->method('getPath')->willReturn($sharePath); + $share = $this->createMock(IShare::class); + $share->method('getNode')->willReturn($shareNode); + $storage = $this->createMock(SharedStorage::class); + $storage->method('instanceOfStorage')->willReturnCallback( + static fn (string $class): bool => in_array($class, [ISharedStorage::class, Jail::class], true) + ); + $storage->method('getShare')->willReturn($share); + $storage->method('getUnjailedPath')->willReturnCallback(static fn (string $path): string => trim($shareRoot . '/' . $path, '/')); + $storage->method('getUnjailedStorage')->willReturn($sourceStorage); + return $storage; + } + + /** + * Storage of a group folder: a jail on the "files" folder of a local storage in __groupfolders/ + */ + private function createGroupFolderStorage(int $folderId): Jail&MockObject { + $dataDirectory = rtrim(Server::get(IConfig::class)->getSystemValueString('datadirectory', \OC::$SERVERROOT . '/data'), '/'); + $local = $this->createMock(Local::class); + $local->method('instanceOfStorage')->willReturnCallback(static fn (string $class): bool => $class === Local::class); + $local->method('getSourcePath')->willReturnCallback(static fn (string $path): string => $dataDirectory . '/__groupfolders/' . $folderId . '/' . $path); + $jail = $this->createMock(Jail::class); + $jail->method('instanceOfStorage')->willReturnCallback(static fn (string $class): bool => $class === Jail::class); + $jail->method('getUnjailedPath')->willReturnCallback(static fn (string $path): string => trim('files/' . $path, '/')); + $jail->method('getUnjailedStorage')->willReturn($local); + return $jail; + } + + private function createFile(int $id, string $path, string $internalPath, IStorage $storage): File&MockObject { + $file = $this->createMock(File::class); + $file->method('getId')->willReturn($id); + $file->method('getPath')->willReturn($path); + $file->method('getInternalPath')->willReturn($internalPath); + $file->method('getStorage')->willReturn($storage); + return $file; + } + + private function createSharedFile(int $id, string $path, string $internalPath): File&MockObject { + return $this->createFile($id, $path, $internalPath, $this->createSharedStorage('/bob/files/GROUPFOLDER1/myfolder1/myfolder2/myfolder3')); + } + + public function testWriteThroughShare(): void { + $file = $this->createSharedFile(42, '/alice/files/myfolder3/file1.xlsx', 'file1.xlsx'); + + $this->expectInfo('File with id "42" written to: "/alice/files/myfolder3/file1.xlsx"', 'files.file.written', [ + 'id' => 42, + 'path' => '/alice/files/myfolder3/file1.xlsx', + 'ownerPath' => '/bob/files/GROUPFOLDER1/myfolder1/myfolder2/myfolder3/file1.xlsx', + ]); + $this->files->write(new NodeWrittenEvent($file)); + } + + public function testReadShareRoot(): void { + $file = $this->createSharedFile(42, '/alice/files/myfolder3', ''); + + $this->expectInfo('File with id "42" accessed: "/alice/files/myfolder3"', 'files.file.read', [ + 'id' => 42, + 'path' => '/alice/files/myfolder3', + 'ownerPath' => '/bob/files/GROUPFOLDER1/myfolder1/myfolder2/myfolder3', + ]); + $this->files->read(new BeforeNodeReadEvent($file)); + } + + public function testDeleteThroughShareWithMissingShareNode(): void { + $share = $this->createMock(IShare::class); + $share->method('getNode')->willThrowException(new NotFoundException()); + $failedStorage = $this->createMock(FailedStorage::class); + $failedStorage->method('instanceOfStorage')->willReturnCallback(static fn (string $class): bool => $class === FailedStorage::class); + $storage = $this->createMock(SharedStorage::class); + $storage->method('instanceOfStorage')->willReturnCallback(static fn (string $class): bool => in_array($class, [ISharedStorage::class, Jail::class], true)); + $storage->method('getShare')->willReturn($share); + $storage->method('getUnjailedPath')->willReturnArgument(0); + $storage->method('getUnjailedStorage')->willReturn($failedStorage); + $file = $this->createFile(42, '/alice/files/myfolder3/file1.xlsx', 'file1.xlsx', $storage); + + $this->expectInfo('File with id "42" deleted: "/alice/files/myfolder3/file1.xlsx"', 'files.file.deleted', ['id' => 42, 'path' => '/alice/files/myfolder3/file1.xlsx']); + $this->files->delete(new BeforeNodeDeletedEvent($file)); + } + + public function testRenameOutOfShare(): void { + $parent = $this->createMock(Folder::class); + $parent->method('getPath')->willReturn('/alice/files/myfolder3'); + $parent->method('getInternalPath')->willReturn(''); + $parent->method('getStorage')->willReturn($this->createSharedStorage('/bob/files/GROUPFOLDER1/myfolder1/myfolder2/myfolder3')); + $source = $this->createMock(File::class); + $source->method('getPath')->willReturn('/alice/files/myfolder3/file1.xlsx'); + $source->method('getName')->willReturn('file1.xlsx'); + $source->method('getStorage')->willThrowException(new NotFoundException()); + $source->method('getParent')->willReturn($parent); + + $this->expectInfo('File renamed with id "42" from "/alice/files/myfolder3/file1.xlsx" to "/alice/files/b.txt"', 'files.file.renamed', [ + 'newid' => 42, + 'oldpath' => '/alice/files/myfolder3/file1.xlsx', + 'newpath' => '/alice/files/b.txt', + 'oldOwnerPath' => '/bob/files/GROUPFOLDER1/myfolder1/myfolder2/myfolder3/file1.xlsx', + ]); + $this->files->afterRename(new NodeRenamedEvent($source, $this->target)); + } + + public function testCopyIntoShare(): void { + $target = $this->createSharedFile(43, '/alice/files/myfolder3/a.txt', 'a.txt'); + + $this->expectInfo('File id copied from: "41" to "43", path from "/alice/files/a.txt" to "/alice/files/myfolder3/a.txt"', 'files.file.copied', [ + 'oldid' => 41, + 'newid' => 43, + 'oldpath' => '/alice/files/a.txt', + 'newpath' => '/alice/files/myfolder3/a.txt', + 'newOwnerPath' => '/bob/files/GROUPFOLDER1/myfolder1/myfolder2/myfolder3/a.txt', + ]); + $this->files->copy(new NodeCopiedEvent($this->source, $target)); + } + + public function testWriteInGroupFolder(): void { + $file = $this->createFile(42, '/alice/files/GROUPFOLDER1/myfolder1/file1.xlsx', 'myfolder1/file1.xlsx', $this->createGroupFolderStorage(5)); + + $this->expectInfo( + 'File with id "42" written to: "/alice/files/GROUPFOLDER1/myfolder1/file1.xlsx" (real path = "__groupfolders/5/files/myfolder1/file1.xlsx")', + 'files.file.written', + [ + 'id' => 42, + 'path' => '/alice/files/GROUPFOLDER1/myfolder1/file1.xlsx', + 'realPath' => '__groupfolders/5/files/myfolder1/file1.xlsx', + ], + ); + $this->files->write(new NodeWrittenEvent($file)); + } + + public function testReadThroughShareFromGroupFolder(): void { + $storage = $this->createSharedStorage('/bob/files/GROUPFOLDER1/myfolder1/myfolder2/myfolder3', $this->createGroupFolderStorage(5), 'myfolder1/myfolder2/myfolder3'); + $file = $this->createFile(42, '/alice/files/myfolder3/file1.xlsx', 'file1.xlsx', $storage); + + $this->expectInfo( + 'File with id "42" accessed: "/alice/files/myfolder3/file1.xlsx" (real path = "__groupfolders/5/files/myfolder1/myfolder2/myfolder3/file1.xlsx")', + 'files.file.read', + [ + 'id' => 42, + 'path' => '/alice/files/myfolder3/file1.xlsx', + 'realPath' => '__groupfolders/5/files/myfolder1/myfolder2/myfolder3/file1.xlsx', + 'ownerPath' => '/bob/files/GROUPFOLDER1/myfolder1/myfolder2/myfolder3/file1.xlsx', + ], + ); + $this->files->read(new BeforeNodeReadEvent($file)); + } + + public function testHomeFileHasNoRealPath(): void { + $home = $this->createMock(Home::class); + $home->method('instanceOfStorage')->willReturnCallback(static fn (string $class): bool => in_array($class, [Local::class, IHomeStorage::class], true)); + $home->method('getSourcePath')->willReturnCallback(static fn (string $path): string => '/var/www/html/data/alice/' . $path); + $file = $this->createFile(42, '/alice/files/b.txt', 'files/b.txt', $home); + + $this->expectInfo('File with id "42" deleted: "/alice/files/b.txt"', 'files.file.deleted', ['id' => 42, 'path' => '/alice/files/b.txt']); + $this->files->delete(new BeforeNodeDeletedEvent($file)); + } + + public function testRealPathOnObjectStorage(): void { + $objectStorage = $this->createMock(IStorage::class); + $objectStorage->method('instanceOfStorage')->willReturn(false); + $objectStorage->method('getId')->willReturn('object::store:object::groupfolder:5.amazon::nextcloud'); + $jail = $this->createMock(Jail::class); + $jail->method('instanceOfStorage')->willReturnCallback(static fn (string $class): bool => $class === Jail::class); + $jail->method('getUnjailedPath')->willReturnCallback(static fn (string $path): string => trim('files/' . $path, '/')); + $jail->method('getUnjailedStorage')->willReturn($objectStorage); + $file = $this->createFile(42, '/alice/files/GROUPFOLDER1/a.txt', 'a.txt', $jail); + + $this->expectInfo( + 'File with id "42" written to: "/alice/files/GROUPFOLDER1/a.txt" (real path = "object::store:object::groupfolder:5.amazon::nextcloud/files/a.txt")', + 'files.file.written', + [ + 'id' => 42, + 'path' => '/alice/files/GROUPFOLDER1/a.txt', + 'realPath' => 'object::store:object::groupfolder:5.amazon::nextcloud/files/a.txt', + ], + ); + $this->files->write(new NodeWrittenEvent($file)); + } + + public function testRealPathOutsideDataDirectory(): void { + $local = $this->createMock(Local::class); + $local->method('instanceOfStorage')->willReturnCallback(static fn (string $class): bool => $class === Local::class); + $local->method('getSourcePath')->willReturnCallback(static fn (string $path): string => '/mnt/external/' . $path); + $file = $this->createFile(42, '/alice/files/external/b.txt', 'b.txt', $local); + + $this->expectInfo( + 'File with id "42" created: "/alice/files/external/b.txt" (real path = "/mnt/external/b.txt")', + 'files.file.created', + ['id' => 42, 'path' => '/alice/files/external/b.txt', 'realPath' => '/mnt/external/b.txt'], + ); + $this->files->create(new NodeCreatedEvent($file)); + } + + public function testRenameWithinGroupFolder(): void { + $storage = $this->createGroupFolderStorage(5); + $source = $this->createFile(42, '/alice/files/GROUPFOLDER1/a.txt', 'a.txt', $storage); + $target = $this->createFile(42, '/alice/files/GROUPFOLDER1/b.txt', 'b.txt', $storage); + + $this->expectInfo( + 'File renamed with id "42" from "/alice/files/GROUPFOLDER1/a.txt" to "/alice/files/GROUPFOLDER1/b.txt" (real path from "__groupfolders/5/files/a.txt" to "__groupfolders/5/files/b.txt")', + 'files.file.renamed', + [ + 'newid' => 42, + 'oldpath' => '/alice/files/GROUPFOLDER1/a.txt', + 'newpath' => '/alice/files/GROUPFOLDER1/b.txt', + 'oldRealPath' => '__groupfolders/5/files/a.txt', + 'newRealPath' => '__groupfolders/5/files/b.txt', + ], + ); + $this->files->afterRename(new NodeRenamedEvent($source, $target)); + } + + public function testCopyIntoGroupFolder(): void { + $target = $this->createFile(43, '/alice/files/GROUPFOLDER1/a.txt', 'a.txt', $this->createGroupFolderStorage(5)); + + $this->expectInfo( + 'File id copied from: "41" to "43", path from "/alice/files/a.txt" to "/alice/files/GROUPFOLDER1/a.txt" (real path to "__groupfolders/5/files/a.txt")', + 'files.file.copied', + [ + 'oldid' => 41, + 'newid' => 43, + 'oldpath' => '/alice/files/a.txt', + 'newpath' => '/alice/files/GROUPFOLDER1/a.txt', + 'newRealPath' => '__groupfolders/5/files/a.txt', + ], + ); + $this->files->copy(new NodeCopiedEvent($this->source, $target)); + } + + public function testCopyOutOfGroupFolder(): void { + $source = $this->createFile(41, '/alice/files/GROUPFOLDER1/a.txt', 'a.txt', $this->createGroupFolderStorage(5)); + + $this->expectInfo( + 'File id copied from: "41" to "42", path from "/alice/files/GROUPFOLDER1/a.txt" to "/alice/files/b.txt" (real path from "__groupfolders/5/files/a.txt")', + 'files.file.copied', + [ + 'oldid' => 41, + 'newid' => 42, + 'oldpath' => '/alice/files/GROUPFOLDER1/a.txt', + 'newpath' => '/alice/files/b.txt', + 'oldRealPath' => '__groupfolders/5/files/a.txt', + ], + ); + $this->files->copy(new NodeCopiedEvent($source, $this->target)); + } } diff --git a/apps/admin_audit/tests/Listener/FileEventListenerTest.php b/apps/admin_audit/tests/Listener/FileEventListenerTest.php index 2fe3fe02e65a4..b1ff6e92860fd 100644 --- a/apps/admin_audit/tests/Listener/FileEventListenerTest.php +++ b/apps/admin_audit/tests/Listener/FileEventListenerTest.php @@ -9,12 +9,18 @@ namespace OCA\AdminAudit\Tests\Listener; +use OC\Files\Storage\Wrapper\Jail; use OCA\AdminAudit\IAuditLogger; use OCA\AdminAudit\Listener\FileEventListener; +use OCA\Files_Sharing\SharedStorage; use OCA\Files_Versions\Events\VersionRestoredEvent; use OCA\Files_Versions\Versions\IVersion; use OCP\Files\File; +use OCP\Files\Folder; +use OCP\Files\IHomeStorage; +use OCP\Files\Storage\ISharedStorage; use OCP\Preview\BeforePreviewFetchedEvent; +use OCP\Share\IShare; use PHPUnit\Framework\MockObject\MockObject; use Test\TestCase; @@ -33,6 +39,10 @@ public function testPreviewAccessed(): void { $file = $this->createMock(File::class); $file->method('getId')->willReturn(42); $file->method('getPath')->willReturn('/alice/files/a.jpg'); + $file->method('getInternalPath')->willReturn('files/a.jpg'); + $storage = $this->createMock(IHomeStorage::class); + $storage->method('instanceOfStorage')->willReturnCallback(static fn (string $class): bool => $class === IHomeStorage::class); + $file->method('getStorage')->willReturn($storage); $this->logger->expects($this->once()) ->method('info') @@ -44,6 +54,34 @@ public function testPreviewAccessed(): void { $this->listener->handle(new BeforePreviewFetchedEvent($file, 64, 64, true, 'fill')); } + public function testPreviewAccessedThroughShare(): void { + $shareNode = $this->createMock(Folder::class); + $shareNode->method('getPath')->willReturn('/bob/files/GROUPFOLDER1/photos'); + $share = $this->createMock(IShare::class); + $share->method('getNode')->willReturn($shareNode); + $home = $this->createMock(IHomeStorage::class); + $home->method('instanceOfStorage')->willReturnCallback(static fn (string $class): bool => $class === IHomeStorage::class); + $storage = $this->createMock(SharedStorage::class); + $storage->method('instanceOfStorage')->willReturnCallback(static fn (string $class): bool => in_array($class, [ISharedStorage::class, Jail::class], true)); + $storage->method('getShare')->willReturn($share); + $storage->method('getUnjailedPath')->willReturnCallback(static fn (string $path): string => 'files/GROUPFOLDER1/photos/' . $path); + $storage->method('getUnjailedStorage')->willReturn($home); + $file = $this->createMock(File::class); + $file->method('getId')->willReturn(42); + $file->method('getPath')->willReturn('/alice/files/photos/a.jpg'); + $file->method('getInternalPath')->willReturn('a.jpg'); + $file->method('getStorage')->willReturn($storage); + + $this->logger->expects($this->once()) + ->method('info') + ->with( + 'Preview accessed: (id: "42", width: "64", height: "64" crop: "1", mode: "fill", path: "/alice/files/photos/a.jpg")', + ['app' => 'admin_audit', 'operation' => 'files.preview.accessed', 'params' => ['id' => 42, 'width' => 64, 'height' => 64, 'crop' => true, 'mode' => 'fill', 'path' => '/alice/files/photos/a.jpg', 'ownerPath' => '/bob/files/GROUPFOLDER1/photos/a.jpg']], + ); + + $this->listener->handle(new BeforePreviewFetchedEvent($file, 64, 64, true, 'fill')); + } + public function testVersionRestored(): void { $version = $this->createMock(IVersion::class); $version->method('getRevisionId')->willReturn(1700000000);