From e82a44e521d28bcdf4ca59b9500c50cf3e3f37fa Mon Sep 17 00:00:00 2001 From: Git'Fellow <12234510+solracsf@users.noreply.github.com> Date: Thu, 2 Apr 2026 08:24:24 +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 + .../Listeners/UserQuotaChangedListener.php | 45 +++++++++++ .../UserQuotaChangedListenerTest.php | 81 +++++++++++++++++++ 4 files changed, 128 insertions(+) create mode 100644 lib/private/User/Listeners/UserQuotaChangedListener.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/User/Listeners/UserQuotaChangedListener.php b/lib/private/User/Listeners/UserQuotaChangedListener.php new file mode 100644 index 0000000000000..807fb098ab037 --- /dev/null +++ b/lib/private/User/Listeners/UserQuotaChangedListener.php @@ -0,0 +1,45 @@ + + */ +class UserQuotaChangedListener implements IEventListener { + public function __construct( + private IRootFolder $rootFolder, + ) { + } + + 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) { + // Non-fatal: best-effort ETag invalidation. + // Stale quota corrects itself on the client's next full sync. + } + } +} diff --git a/tests/lib/User/Listeners/UserQuotaChangedListenerTest.php b/tests/lib/User/Listeners/UserQuotaChangedListenerTest.php new file mode 100644 index 0000000000000..ea98fa0530470 --- /dev/null +++ b/tests/lib/User/Listeners/UserQuotaChangedListenerTest.php @@ -0,0 +1,81 @@ +rootFolder = $this->createMock(IRootFolder::class); + $this->listener = new UserQuotaChangedListener($this->rootFolder); + } + + 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 testSwallowsExceptionGracefully(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('alice'); + + $this->rootFolder->method('getUserFolder') + ->willThrowException(new \Exception('Storage unavailable')); + + // Should not throw + $event = new UserChangedEvent($user, 'quota', '5 GB', '1 GB'); + $this->listener->handle($event); + $this->addToAssertionCount(1); + } +}