From 9858ff581fbf859527de73de5912c3f13d2a9896 Mon Sep 17 00:00:00 2001 From: Ghassen kefi Date: Fri, 23 Oct 2026 11:26:39 +0200 Subject: [PATCH] fix(admin_audit): log the real and owner path of files in shares and group folders File entries only contained the path as seen by the acting user, so access to a file through a share or inside a group folder could not be traced back to where the file is stored. File and preview entries now add the location of the file in its storage as "realPath" (relative to the data directory on local storage, e.g. "__groupfolders/1/files/a.txt", otherwise prefixed with the storage id) and append it to the message. Files in home storages are left as they are. Files accessed through a share also get "ownerPath", the path as seen by the share owner. Copy and rename entries get the old/new variants of both. Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: Ghassen kefi --- apps/admin_audit/lib/Actions/Action.php | 101 +++++++ apps/admin_audit/lib/Actions/Files.php | 34 ++- .../lib/Listener/FileEventListener.php | 5 +- apps/admin_audit/tests/Actions/FilesTest.php | 269 ++++++++++++++++++ .../tests/Listener/FileEventListenerTest.php | 38 +++ 5 files changed, 433 insertions(+), 14 deletions(-) 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);