diff --git a/core/Command/User/Setting.php b/core/Command/User/Setting.php index 4d405c56281de..45088d8cbc7b5 100644 --- a/core/Command/User/Setting.php +++ b/core/Command/User/Setting.php @@ -9,6 +9,9 @@ namespace OC\Core\Command\User; use OC\Core\Command\Base; +use OCP\Config\BeforePreferenceDeletedEvent; +use OCP\Config\BeforePreferenceSetEvent; +use OCP\EventDispatcher\IEventDispatcher; use OCP\IConfig; use OCP\IUser; use OCP\IUserManager; @@ -22,6 +25,7 @@ class Setting extends Base { public function __construct( protected IUserManager $userManager, protected IConfig $config, + protected IEventDispatcher $eventDispatcher, ) { parent::__construct(); } @@ -91,6 +95,12 @@ protected function configure() { InputOption::VALUE_NONE, 'Checks whether the setting exists before deleting it' ) + ->addOption( + 'force', + null, + InputOption::VALUE_NONE, + 'Set or delete the setting even if the app rejects it' + ) ; } @@ -131,6 +141,9 @@ protected function checkInput(InputInterface $input) { if ($input->getOption('error-if-not-exists') && !$input->getOption('delete')) { throw new \InvalidArgumentException('The "error-if-not-exists" option can only be used together with "delete".'); } + if ($input->getOption('force') && !$input->getOption('delete') && $input->getArgument('value') === null) { + throw new \InvalidArgumentException('The "force" option can only be used together with "value" or "delete".'); + } } #[\Override] @@ -154,6 +167,16 @@ protected function execute(InputInterface $input, OutputInterface $output): int return 1; } + $event = new BeforePreferenceSetEvent($uid, $app, $key, $input->getArgument('value')); + $this->eventDispatcher->dispatchTyped($event); + if ($event->isRejected()) { + if (!$input->hasParameterOption('--force')) { + $output->writeln('The value was rejected for setting "' . $key . '" of app "' . $app . '", use --force to set it anyway.'); + return 1; + } + $output->writeln('The value was rejected for setting "' . $key . '" of app "' . $app . '", setting it anyway.'); + } + if ($app === 'settings' && in_array($key, ['email', 'display_name'], true)) { $user = $this->userManager->get($uid); if ($user instanceof IUser) { @@ -187,6 +210,16 @@ protected function execute(InputInterface $input, OutputInterface $output): int return 1; } + $event = new BeforePreferenceDeletedEvent($uid, $app, $key); + $this->eventDispatcher->dispatchTyped($event); + if ($event->isRejected()) { + if (!$input->hasParameterOption('--force')) { + $output->writeln('Deleting setting "' . $key . '" of app "' . $app . '" was rejected, use --force to delete it anyway.'); + return 1; + } + $output->writeln('Deleting setting "' . $key . '" of app "' . $app . '" was rejected, deleting it anyway.'); + } + if ($app === 'settings' && in_array($key, ['email', 'display_name'], true)) { $user = $this->userManager->get($uid); if ($user instanceof IUser) { diff --git a/lib/public/Config/BeforePreferenceDeletedEvent.php b/lib/public/Config/BeforePreferenceDeletedEvent.php index df89a2900bd7f..b6fe505444c84 100644 --- a/lib/public/Config/BeforePreferenceDeletedEvent.php +++ b/lib/public/Config/BeforePreferenceDeletedEvent.php @@ -16,7 +16,7 @@ class BeforePreferenceDeletedEvent extends Event { protected string $userId; protected string $appId; protected string $configKey; - protected bool $valid = false; + protected ?bool $valid = null; /** * @since 25.0.0 @@ -53,7 +53,19 @@ public function getConfigKey(): string { * @since 25.0.0 */ public function isValid(): bool { - return $this->valid; + return $this->valid === true; + } + + /** + * Whether a listener explicitly refused the change via setValid(false) + * + * Admin contexts (e.g. occ) only block the change when it was rejected, + * while user contexts require it to be valid. + * + * @since 36.0.0 + */ + public function isRejected(): bool { + return $this->valid === false; } /** diff --git a/lib/public/Config/BeforePreferenceSetEvent.php b/lib/public/Config/BeforePreferenceSetEvent.php index 6534e4ae371de..4b6d75e63fd99 100644 --- a/lib/public/Config/BeforePreferenceSetEvent.php +++ b/lib/public/Config/BeforePreferenceSetEvent.php @@ -17,7 +17,7 @@ class BeforePreferenceSetEvent extends Event { protected string $appId; protected string $configKey; protected string $configValue; - protected bool $valid = false; + protected ?bool $valid = null; /** * @since 25.0.0 @@ -62,7 +62,19 @@ public function getConfigValue(): string { * @since 25.0.0 */ public function isValid(): bool { - return $this->valid; + return $this->valid === true; + } + + /** + * Whether a listener explicitly refused the change via setValid(false) + * + * Admin contexts (e.g. occ) only block the change when it was rejected, + * while user contexts require it to be valid. + * + * @since 36.0.0 + */ + public function isRejected(): bool { + return $this->valid === false; } /** diff --git a/tests/Core/Command/User/SettingTest.php b/tests/Core/Command/User/SettingTest.php index 75c70a9994995..3cc960f129919 100644 --- a/tests/Core/Command/User/SettingTest.php +++ b/tests/Core/Command/User/SettingTest.php @@ -10,6 +10,9 @@ use InvalidArgumentException; use OC\Core\Command\User\Setting; +use OCP\Config\BeforePreferenceDeletedEvent; +use OCP\Config\BeforePreferenceSetEvent; +use OCP\EventDispatcher\IEventDispatcher; use OCP\IConfig; use OCP\IDBConnection; use OCP\IUserManager; @@ -22,6 +25,7 @@ class SettingTest extends TestCase { protected IUserManager&MockObject $userManager; protected IConfig&MockObject $config; protected IDBConnection&MockObject $connection; + protected IEventDispatcher&MockObject $eventDispatcher; protected InputInterface&MockObject $consoleInput; protected MockObject&OutputInterface $consoleOutput; @@ -32,18 +36,20 @@ protected function setUp(): void { $this->userManager = $this->createMock(IUserManager::class); $this->config = $this->createMock(IConfig::class); $this->connection = $this->createMock(IDBConnection::class); + $this->eventDispatcher = $this->createMock(IEventDispatcher::class); $this->consoleInput = $this->createMock(InputInterface::class); $this->consoleOutput = $this->createMock(OutputInterface::class); } public function getCommand(array $methods = []) { if (empty($methods)) { - return new Setting($this->userManager, $this->config); + return new Setting($this->userManager, $this->config, $this->eventDispatcher); } else { $mock = $this->getMockBuilder(Setting::class) ->setConstructorArgs([ $this->userManager, $this->config, + $this->eventDispatcher, ]) ->onlyMethods($methods) ->getMock(); @@ -161,6 +167,27 @@ public static function dataCheckInput(): array { false, 'The "error-if-not-exists" option can only be used together with "delete".', ], + [ + [['uid', 'username'], ['key', 'configkey'], ['value', '']], + [['ignore-missing-user', true], ['force', true]], + [], + false, + false, + ], + [ + [['uid', 'username'], ['key', 'configkey']], + [['ignore-missing-user', true], ['delete', true], ['force', true]], + [], + false, + false, + ], + [ + [['uid', 'username'], ['key', 'configkey'], ['value', null]], + [['ignore-missing-user', true], ['force', true]], + [], + false, + 'The "force" option can only be used together with "value" or "delete".', + ], ]; } @@ -356,6 +383,119 @@ public function testExecuteSet($value, $updateOnly, $expectedLine, $expectedRetu $this->assertEquals($expectedReturn, $this->invokePrivate($command, 'execute', [$this->consoleInput, $this->consoleOutput])); } + public function testExecuteSetRejected(): void { + $command = $this->getCommand(['checkInput']); + + $this->consoleInput->method('getArgument') + ->willReturnMap([ + ['uid', 'username'], + ['app', 'appname'], + ['key', 'configkey'], + ['value', 'setValue'], + ]); + $this->consoleInput->method('hasParameterOption') + ->willReturnMap([ + ['--update-only', false, false], + ['--force', false, false], + ]); + $this->eventDispatcher->expects($this->once()) + ->method('dispatchTyped') + ->willReturnCallback(function (BeforePreferenceSetEvent $event): void { + $this->assertSame('username', $event->getUserId()); + $this->assertSame('appname', $event->getAppId()); + $this->assertSame('configkey', $event->getConfigKey()); + $this->assertSame('setValue', $event->getConfigValue()); + $event->setValid(false); + }); + $this->config->expects($this->never()) + ->method('setUserValue'); + + $this->assertEquals(1, $this->invokePrivate($command, 'execute', [$this->consoleInput, $this->consoleOutput])); + } + + public function testExecuteDeleteRejected(): void { + $command = $this->getCommand(['checkInput']); + + $this->consoleInput->method('getArgument') + ->willReturnMap([ + ['uid', 'username'], + ['app', 'appname'], + ['key', 'configkey'], + ]); + $this->consoleInput->method('hasParameterOption') + ->willReturnMap([ + ['--delete', false, true], + ['--error-if-not-exists', false, false], + ['--force', false, false], + ]); + $this->eventDispatcher->expects($this->once()) + ->method('dispatchTyped') + ->willReturnCallback(function (BeforePreferenceDeletedEvent $event): void { + $this->assertSame('username', $event->getUserId()); + $this->assertSame('appname', $event->getAppId()); + $this->assertSame('configkey', $event->getConfigKey()); + $event->setValid(false); + }); + $this->config->expects($this->never()) + ->method('deleteUserValue'); + + $this->assertEquals(1, $this->invokePrivate($command, 'execute', [$this->consoleInput, $this->consoleOutput])); + } + + public function testExecuteSetRejectedForced(): void { + $command = $this->getCommand(['checkInput']); + + $this->consoleInput->method('getArgument') + ->willReturnMap([ + ['uid', 'username'], + ['app', 'appname'], + ['key', 'configkey'], + ['value', 'setValue'], + ]); + $this->consoleInput->method('hasParameterOption') + ->willReturnMap([ + ['--update-only', false, false], + ['--force', false, true], + ]); + $this->eventDispatcher->expects($this->once()) + ->method('dispatchTyped') + ->willReturnCallback(function (BeforePreferenceSetEvent $event): void { + $event->setValid(false); + }); + $this->config->expects($this->once()) + ->method('setUserValue') + ->with('username', 'appname', 'configkey', 'setValue'); + + $this->assertEquals(0, $this->invokePrivate($command, 'execute', [$this->consoleInput, $this->consoleOutput])); + } + + public function testExecuteDeleteRejectedForced(): void { + $command = $this->getCommand(['checkInput']); + + $this->consoleInput->method('getArgument') + ->willReturnMap([ + ['uid', 'username'], + ['app', 'appname'], + ['key', 'configkey'], + ]); + $this->consoleInput->method('hasParameterOption') + ->willReturnMap([ + ['--delete', false, true], + ['--error-if-not-exists', false, false], + ['--force', false, true], + ]); + $this->eventDispatcher->expects($this->once()) + ->method('dispatchTyped') + ->willReturnCallback(function (BeforePreferenceDeletedEvent $event): void { + $event->setValid(false); + }); + $this->config->expects($this->once()) + ->method('deleteUserValue') + ->with('username', 'appname', 'configkey'); + + $this->assertEquals(0, $this->invokePrivate($command, 'execute', [$this->consoleInput, $this->consoleOutput])); + } + public static function dataExecuteGet(): array { return [ ['config', null, 'config', 0],