From 1e03107a9645835fa2d6a8880e9830a6a173e239 Mon Sep 17 00:00:00 2001 From: Git'Fellow <12234510+solracsf@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:59:17 +0200 Subject: [PATCH] fix(user): invalidate folder ETag when quota changes Signed-off-by: Git'Fellow <12234510+solracsf@users.noreply.github.com> --- lib/composer/composer/autoload_classmap.php | 1 + lib/composer/composer/autoload_static.php | 1 + lib/private/Server.php | 2 + .../Listeners/UserQuotaChangedListener.php | 53 +++++++++++ ...serQuotaChangedListenerIntegrationTest.php | 52 +++++++++++ .../UserQuotaChangedListenerTest.php | 90 +++++++++++++++++++ 6 files changed, 199 insertions(+) create mode 100644 lib/private/User/Listeners/UserQuotaChangedListener.php create mode 100644 tests/lib/User/Listeners/UserQuotaChangedListenerIntegrationTest.php create mode 100644 tests/lib/User/Listeners/UserQuotaChangedListenerTest.php diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index 35992e16837d2..9d9f374e57ce1 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -2257,6 +2257,7 @@ 'OC\\User\\LazyUser' => $baseDir . '/lib/private/User/LazyUser.php', 'OC\\User\\Listeners\\BeforeUserDeletedListener' => $baseDir . '/lib/private/User/Listeners/BeforeUserDeletedListener.php', 'OC\\User\\Listeners\\UserChangedListener' => $baseDir . '/lib/private/User/Listeners/UserChangedListener.php', + 'OC\\User\\Listeners\\UserQuotaChangedListener' => $baseDir . '/lib/private/User/Listeners/UserQuotaChangedListener.php', 'OC\\User\\LoginException' => $baseDir . '/lib/private/User/LoginException.php', 'OC\\User\\Manager' => $baseDir . '/lib/private/User/Manager.php', 'OC\\User\\NoUserException' => $baseDir . '/lib/private/User/NoUserException.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index 79c4de8f32767..2ea94019989a4 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -2298,6 +2298,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\User\\LazyUser' => __DIR__ . '/../../..' . '/lib/private/User/LazyUser.php', 'OC\\User\\Listeners\\BeforeUserDeletedListener' => __DIR__ . '/../../..' . '/lib/private/User/Listeners/BeforeUserDeletedListener.php', 'OC\\User\\Listeners\\UserChangedListener' => __DIR__ . '/../../..' . '/lib/private/User/Listeners/UserChangedListener.php', + 'OC\\User\\Listeners\\UserQuotaChangedListener' => __DIR__ . '/../../..' . '/lib/private/User/Listeners/UserQuotaChangedListener.php', 'OC\\User\\LoginException' => __DIR__ . '/../../..' . '/lib/private/User/LoginException.php', 'OC\\User\\Manager' => __DIR__ . '/../../..' . '/lib/private/User/Manager.php', 'OC\\User\\NoUserException' => __DIR__ . '/../../..' . '/lib/private/User/NoUserException.php', diff --git a/lib/private/Server.php b/lib/private/Server.php index e24d47f3f0611..b8a8b3d53e94c 100644 --- a/lib/private/Server.php +++ b/lib/private/Server.php @@ -157,6 +157,7 @@ use OC\User\DisplayNameCache; use OC\User\Listeners\BeforeUserDeletedListener; use OC\User\Listeners\UserChangedListener; +use OC\User\Listeners\UserQuotaChangedListener; use OC\User\Session; use OC\User\User; use OCA\Theming\ImageManager; @@ -1342,6 +1343,7 @@ private function connectDispatcher(): void { $eventDispatcher->addServiceListener(LoginFailed::class, LoginFailedListener::class); $eventDispatcher->addServiceListener(PostLoginEvent::class, UserLoggedInListener::class); $eventDispatcher->addServiceListener(UserChangedEvent::class, UserChangedListener::class); + $eventDispatcher->addServiceListener(UserChangedEvent::class, UserQuotaChangedListener::class); $eventDispatcher->addServiceListener(BeforeUserDeletedEvent::class, BeforeUserDeletedListener::class); FilesMetadataManager::loadListeners($eventDispatcher); diff --git a/lib/private/User/Listeners/UserQuotaChangedListener.php b/lib/private/User/Listeners/UserQuotaChangedListener.php new file mode 100644 index 0000000000000..1c9c35a79e13c --- /dev/null +++ b/lib/private/User/Listeners/UserQuotaChangedListener.php @@ -0,0 +1,53 @@ + + */ +class UserQuotaChangedListener implements IEventListener { + public function __construct( + private IRootFolder $rootFolder, + private LoggerInterface $logger, + ) { + } + + #[\Override] + public function handle(Event $event): void { + if (!$event instanceof UserChangedEvent) { + return; + } + + if ($event->getFeature() !== 'quota') { + return; + } + + try { + $userFolder = $this->rootFolder->getUserFolder($event->getUser()->getUID()); + $userFolder->getStorage()->getCache()->update( + $userFolder->getId(), + ['etag' => uniqid()] + ); + } catch (\Throwable $e) { + // Non-fatal: best-effort ETag invalidation. + // Stale quota corrects itself on the client's next full sync. + $this->logger->warning('Failed to invalidate user root etag after quota change', [ + 'user' => $event->getUser()->getUID(), + 'exception' => $e, + ]); + } + } +} diff --git a/tests/lib/User/Listeners/UserQuotaChangedListenerIntegrationTest.php b/tests/lib/User/Listeners/UserQuotaChangedListenerIntegrationTest.php new file mode 100644 index 0000000000000..98241a2b1d2cf --- /dev/null +++ b/tests/lib/User/Listeners/UserQuotaChangedListenerIntegrationTest.php @@ -0,0 +1,52 @@ +get(self::TEST_USER)?->delete(); + parent::tearDown(); + } + + public function testSetQuotaInvalidatesUserRootEtag(): void { + $userManager = Server::get(IUserManager::class); + $userManager->get(self::TEST_USER)?->delete(); + $user = $userManager->createUser(self::TEST_USER, 'correct-Horse-battery1!'); + $this->assertNotFalse($user); + + $userFolder = Server::get(IRootFolder::class)->getUserFolder(self::TEST_USER); + $cache = $userFolder->getStorage()->getCache(); + $etagBefore = $cache->get($userFolder->getInternalPath())->getEtag(); + + $user->setQuota('5 GB'); + + $etagAfter = $cache->get($userFolder->getInternalPath())->getEtag(); + $this->assertNotEquals( + $etagBefore, + $etagAfter, + 'User root etag must change when the quota changes so clients re-fetch quota-available-bytes', + ); + } +} diff --git a/tests/lib/User/Listeners/UserQuotaChangedListenerTest.php b/tests/lib/User/Listeners/UserQuotaChangedListenerTest.php new file mode 100644 index 0000000000000..0c42667685bae --- /dev/null +++ b/tests/lib/User/Listeners/UserQuotaChangedListenerTest.php @@ -0,0 +1,90 @@ +rootFolder = $this->createMock(IRootFolder::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->listener = new UserQuotaChangedListener($this->rootFolder, $this->logger); + } + + public function testIgnoresNonUserChangedEvent(): void { + $this->rootFolder->expects($this->never())->method('getUserFolder'); + $this->listener->handle($this->createMock(Event::class)); + } + + public function testIgnoresNonQuotaFeature(): void { + $user = $this->createMock(IUser::class); + $event = new UserChangedEvent($user, 'displayName', 'Alice', 'Bob'); + + $this->rootFolder->expects($this->never())->method('getUserFolder'); + $this->listener->handle($event); + } + + public function testInvalidatesEtagOnQuotaChange(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('alice'); + + $cache = $this->createMock(ICache::class); + $cache->expects($this->once()) + ->method('update') + ->with($this->isInt(), $this->callback( + fn (array $data) => isset($data['etag']) && $data['etag'] !== '' + )); + + $storage = $this->createMock(IStorage::class); + $storage->method('getCache')->willReturn($cache); + + $userFolder = $this->createMock(Folder::class); + $userFolder->method('getStorage')->willReturn($storage); + $userFolder->method('getId')->willReturn(42); + + $this->rootFolder->method('getUserFolder')->with('alice')->willReturn($userFolder); + + $event = new UserChangedEvent($user, 'quota', '5 GB', '1 GB'); + $this->listener->handle($event); + } + + public function testSwallowsExceptionButLogsWarning(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('alice'); + + $this->rootFolder->method('getUserFolder') + ->willThrowException(new \Exception('Storage unavailable')); + + $this->logger->expects($this->once()) + ->method('warning') + ->with($this->isString(), $this->callback( + fn (array $context) => $context['user'] === 'alice' && $context['exception'] instanceof \Exception + )); + + // Must not throw + $event = new UserChangedEvent($user, 'quota', '5 GB', '1 GB'); + $this->listener->handle($event); + } +}