From 711b3935c303766cc2d1116ddc171c0646e40a09 Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 25 Sep 2026 08:39:12 -0400 Subject: [PATCH 1/2] fix(occ): dispatch password reset events for user:resetpassword Emit BeforePasswordResetEvent before setting the password and PasswordResetEvent only after the password change succeeds. Emitting these events is the correct lifecycle behavior and lets server-side encryption use its reset flow instead of treating the CLI operation as a normal password change, matching the web-based lost-password flow. Because this command does not provide an encryption recovery password, existing encrypted files may become inaccessible after the reset. The interactive CLI and web flow warn about this; the --password-from-env path does not, which is existing behavior. Fixes #51235 Signed-off-by: Josh --- core/Command/User/ResetPassword.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/core/Command/User/ResetPassword.php b/core/Command/User/ResetPassword.php index 813c38fe1d539..d02f92dd4e2b2 100644 --- a/core/Command/User/ResetPassword.php +++ b/core/Command/User/ResetPassword.php @@ -9,7 +9,10 @@ namespace OC\Core\Command\User; use OC\Core\Command\Base; +use OC\Core\Events\BeforePasswordResetEvent; +use OC\Core\Events\PasswordResetEvent; use OCP\App\IAppManager; +use OCP\EventDispatcher\IEventDispatcher; use OCP\IUser; use OCP\IUserManager; use Stecman\Component\Symfony\Console\BashCompletion\CompletionContext; @@ -25,6 +28,7 @@ class ResetPassword extends Base { public function __construct( protected IUserManager $userManager, private IAppManager $appManager, + private IEventDispatcher $eventDispatcher, ) { parent::__construct(); } @@ -118,6 +122,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int } try { + $this->eventDispatcher->dispatchTyped(new BeforePasswordResetEvent($user, $password)); $success = $user->setPassword($password); } catch (\Exception $e) { $output->writeln('' . $e->getMessage() . ''); @@ -125,6 +130,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int } if ($success) { + $this->eventDispatcher->dispatchTyped(new PasswordResetEvent($user, $password)); $output->writeln('Successfully reset password for ' . $username . ''); } else { $output->writeln('Error while resetting password!'); From 1a32198b985513688a4f24118215b0afaaa2446a Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 25 Sep 2026 08:47:10 -0400 Subject: [PATCH 2/2] test(occ): cover user password reset events Verify that user:resetpassword dispatches reset events around a successful password change and does not dispatch PasswordResetEvent when the change fails. Assisted-by: Copilot:gpt-6-luna Signed-off-by: Josh --- tests/Core/Command/User/ResetPasswordTest.php | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 tests/Core/Command/User/ResetPasswordTest.php diff --git a/tests/Core/Command/User/ResetPasswordTest.php b/tests/Core/Command/User/ResetPasswordTest.php new file mode 100644 index 0000000000000..a9f4cff40f1ae --- /dev/null +++ b/tests/Core/Command/User/ResetPasswordTest.php @@ -0,0 +1,117 @@ +createMock(IUser::class); + $user->expects(self::once()) + ->method('setPassword') + ->with('NewPassword') + ->willReturnCallback(static function () use (&$sequence, $passwordChangeSucceeds): bool { + $sequence[] = 'setPassword'; + return $passwordChangeSucceeds; + }); + + $userManager = $this->createMock(IUserManager::class); + $userManager->expects(self::once()) + ->method('get') + ->with('alice') + ->willReturn($user); + + $eventDispatcher = $this->createMock(IEventDispatcher::class); + $eventDispatcher->expects(self::exactly(count($expectedSequence) - 1)) + ->method('dispatchTyped') + ->willReturnCallback(static function (object $event) use (&$sequence, $user): void { + if ($event instanceof BeforePasswordResetEvent) { + self::assertSame($user, $event->getUser()); + self::assertSame('NewPassword', $event->getPassword()); + $sequence[] = 'beforeReset'; + return; + } + + self::assertInstanceOf(PasswordResetEvent::class, $event); + self::assertSame($user, $event->getUser()); + self::assertSame('NewPassword', $event->getPassword()); + $sequence[] = 'afterReset'; + }); + + $input = $this->createMock(InputInterface::class); + $input->method('getArgument') + ->with('user') + ->willReturn('alice'); + $input->method('getOption') + ->with('password-from-env') + ->willReturn(true); + + $output = $this->createMock(OutputInterface::class); + $output->expects(self::once()) + ->method('writeln') + ->with($expectedOutput); + + $command = new ResetPassword( + $userManager, + $this->createStub(IAppManager::class), + $eventDispatcher, + ); + + $previousPassword = getenv('NC_PASS'); + putenv('NC_PASS=NewPassword'); + + try { + $exitCode = self::invokePrivate($command, 'execute', [$input, $output]); + } finally { + if ($previousPassword === false) { + putenv('NC_PASS'); + } else { + putenv('NC_PASS=' . $previousPassword); + } + } + + self::assertSame($expectedExitCode, $exitCode); + self::assertSame($expectedSequence, $sequence); + } + + public static function passwordResetResults(): array { + return [ + 'success dispatches both reset events around the password change' => [ + true, + 0, + ['beforeReset', 'setPassword', 'afterReset'], + 'Successfully reset password for alice', + ], + 'failure does not dispatch the after-reset event' => [ + false, + 1, + ['beforeReset', 'setPassword'], + 'Error while resetting password!', + ], + ]; + } +}