From 58a10e1532871df4e968189cd5470387d7bb020f Mon Sep 17 00:00:00 2001 From: Joas Schilling Date: Fri, 30 Oct 2026 10:41:55 +0100 Subject: [PATCH 1/5] feat(appconfig): Allow apps to validate/reject setting values via provisioning_api and occ Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: Joas Schilling --- .../lib/Controller/AppConfigController.php | 22 +++++- .../Controller/AppConfigControllerTest.php | 46 +++++++++++++ core/Command/Config/App/Base.php | 2 + core/Command/Config/App/DeleteConfig.php | 8 +++ core/Command/Config/App/SetConfig.php | 10 ++- .../Config/BeforeAppConfigDeletedEvent.php | 61 ++++++++++++++++ lib/public/Config/BeforeAppConfigSetEvent.php | 69 +++++++++++++++++++ .../Command/Config/App/DeleteConfigTest.php | 25 +++++++ .../Core/Command/Config/App/SetConfigTest.php | 38 ++++++++++ 9 files changed, 278 insertions(+), 3 deletions(-) create mode 100644 lib/public/Config/BeforeAppConfigDeletedEvent.php create mode 100644 lib/public/Config/BeforeAppConfigSetEvent.php diff --git a/apps/provisioning_api/lib/Controller/AppConfigController.php b/apps/provisioning_api/lib/Controller/AppConfigController.php index c8206e7a232ab..418d80ba240ab 100644 --- a/apps/provisioning_api/lib/Controller/AppConfigController.php +++ b/apps/provisioning_api/lib/Controller/AppConfigController.php @@ -19,7 +19,10 @@ use OCP\AppFramework\Http\Attribute\PasswordConfirmationRequired; use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\OCSController; +use OCP\Config\BeforeAppConfigDeletedEvent; +use OCP\Config\BeforeAppConfigSetEvent; use OCP\Config\ValueType; +use OCP\EventDispatcher\IEventDispatcher; use OCP\Exceptions\AppConfigUnknownKeyException; use OCP\IAppConfig; use OCP\IGroupManager; @@ -42,6 +45,7 @@ public function __construct( private IManager $settingManager, private IAppManager $appManager, private readonly ConfigManager $configManager, + private readonly IEventDispatcher $eventDispatcher, ) { parent::__construct($appName, $request); } @@ -108,9 +112,10 @@ public function getValue(string $app, string $key, string $defaultValue = ''): D * @param string $app ID of the app * @param string $key Key to update * @param string $value New value for the key - * @return DataResponse, array{}>|DataResponse + * @return DataResponse, array{}>|DataResponse * * 200: Value updated successfully + * 400: Value rejected * 403: App or key is not allowed */ #[PasswordConfirmationRequired] @@ -133,6 +138,12 @@ public function setValue(string $app, string $key, string $value): DataResponse return new DataResponse(['data' => ['message' => $e->getMessage()]], Http::STATUS_FORBIDDEN); } + $event = new BeforeAppConfigSetEvent($app, $key, $value); + $this->eventDispatcher->dispatchTyped($event); + if ($event->isRejected()) { + return new DataResponse(['data' => ['message' => $this->l10n->t('The given value was rejected')]], Http::STATUS_BAD_REQUEST); + } + $type = null; // checking expected type from lexicon @@ -166,9 +177,10 @@ public function setValue(string $app, string $key, string $value): DataResponse * * @param string $app ID of the app * @param string $key Key to delete - * @return DataResponse, array{}>|DataResponse + * @return DataResponse, array{}>|DataResponse * * 200: Key deleted successfully + * 400: Deletion rejected * 403: App or key is not allowed */ #[PasswordConfirmationRequired] @@ -180,6 +192,12 @@ public function deleteKey(string $app, string $key): DataResponse { return new DataResponse(['data' => ['message' => $e->getMessage()]], Http::STATUS_FORBIDDEN); } + $event = new BeforeAppConfigDeletedEvent($app, $key); + $this->eventDispatcher->dispatchTyped($event); + if ($event->isRejected()) { + return new DataResponse(['data' => ['message' => $this->l10n->t('Deleting the given key was rejected')]], Http::STATUS_BAD_REQUEST); + } + $this->appConfig->deleteKey($app, $key); return new DataResponse(); } diff --git a/apps/provisioning_api/tests/Controller/AppConfigControllerTest.php b/apps/provisioning_api/tests/Controller/AppConfigControllerTest.php index 305eb29e88ba0..69fe7bec390b4 100644 --- a/apps/provisioning_api/tests/Controller/AppConfigControllerTest.php +++ b/apps/provisioning_api/tests/Controller/AppConfigControllerTest.php @@ -14,6 +14,9 @@ use OCP\App\IAppManager; use OCP\AppFramework\Http; use OCP\AppFramework\Http\DataResponse; +use OCP\Config\BeforeAppConfigDeletedEvent; +use OCP\Config\BeforeAppConfigSetEvent; +use OCP\EventDispatcher\IEventDispatcher; use OCP\Exceptions\AppConfigUnknownKeyException; use OCP\IAppConfig; use OCP\IGroupManager; @@ -41,6 +44,7 @@ class AppConfigControllerTest extends TestCase { private IGroupManager&MockObject $groupManager; private IAppManager $appManager; private ConfigManager $configManager; + private IEventDispatcher&MockObject $eventDispatcher; protected function setUp(): void { parent::setUp(); @@ -52,6 +56,7 @@ protected function setUp(): void { $this->groupManager = $this->createMock(IGroupManager::class); $this->appManager = Server::get(IAppManager::class); $this->configManager = Server::get(ConfigManager::class); + $this->eventDispatcher = $this->createMock(IEventDispatcher::class); } /** @@ -72,6 +77,7 @@ protected function getInstance(array $methods = []) { $this->settingManager, $this->appManager, $this->configManager, + $this->eventDispatcher, ); } else { return $this->getMockBuilder(AppConfigController::class) @@ -85,6 +91,7 @@ protected function getInstance(array $methods = []) { $this->settingManager, $this->appManager, $this->configManager, + $this->eventDispatcher, ]) ->onlyMethods($methods) ->getMock(); @@ -339,6 +346,45 @@ public function testDeleteValue(string $app, string $key, ?\Throwable $appThrows } } + public function testSetValueRejected(): void { + $adminUser = $this->createMock(IUser::class); + $adminUser->method('getUid') + ->willReturn('admin'); + $this->userSession->method('getUser') + ->willReturn($adminUser); + $this->groupManager->method('isAdmin') + ->with('admin') + ->willReturn(true); + $this->eventDispatcher->expects($this->once()) + ->method('dispatchTyped') + ->willReturnCallback(function (BeforeAppConfigSetEvent $event): void { + $this->assertSame('app1', $event->getAppId()); + $this->assertSame('key', $event->getConfigKey()); + $this->assertSame('value', $event->getConfigValue()); + $event->setValid(false); + }); + $this->appConfig->expects($this->never()) + ->method('setValueMixed'); + + $result = $this->getInstance(['verifyAppId', 'verifyConfigKey'])->setValue('app1', 'key', 'value'); + $this->assertSame(Http::STATUS_BAD_REQUEST, $result->getStatus()); + } + + public function testDeleteValueRejected(): void { + $this->eventDispatcher->expects($this->once()) + ->method('dispatchTyped') + ->willReturnCallback(function (BeforeAppConfigDeletedEvent $event): void { + $this->assertSame('app1', $event->getAppId()); + $this->assertSame('key', $event->getConfigKey()); + $event->setValid(false); + }); + $this->appConfig->expects($this->never()) + ->method('deleteKey'); + + $result = $this->getInstance(['verifyAppId', 'verifyConfigKey'])->deleteKey('app1', 'key'); + $this->assertSame(Http::STATUS_BAD_REQUEST, $result->getStatus()); + } + public function testVerifyAppId(): void { $api = $this->getInstance(); $this->invokePrivate($api, 'verifyAppId', ['activity']); diff --git a/core/Command/Config/App/Base.php b/core/Command/Config/App/Base.php index ccbb1d701d93a..e9d393802770c 100644 --- a/core/Command/Config/App/Base.php +++ b/core/Command/Config/App/Base.php @@ -9,6 +9,7 @@ namespace OC\Core\Command\Config\App; use OC\Config\ConfigManager; +use OCP\EventDispatcher\IEventDispatcher; use OCP\IAppConfig; use Stecman\Component\Symfony\Console\BashCompletion\CompletionContext; @@ -16,6 +17,7 @@ abstract class Base extends \OC\Core\Command\Base { public function __construct( protected IAppConfig $appConfig, protected readonly ConfigManager $configManager, + protected readonly IEventDispatcher $eventDispatcher, ) { parent::__construct(); } diff --git a/core/Command/Config/App/DeleteConfig.php b/core/Command/Config/App/DeleteConfig.php index fb6590a19e257..6f76b95d90335 100644 --- a/core/Command/Config/App/DeleteConfig.php +++ b/core/Command/Config/App/DeleteConfig.php @@ -9,6 +9,7 @@ namespace OC\Core\Command\Config\App; +use OCP\Config\BeforeAppConfigDeletedEvent; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -51,6 +52,13 @@ protected function execute(InputInterface $input, OutputInterface $output): int return 1; } + $event = new BeforeAppConfigDeletedEvent($appName, $configName); + $this->eventDispatcher->dispatchTyped($event); + if ($event->isRejected()) { + $output->writeln('Deleting config ' . $configName . ' of app ' . $appName . ' was rejected'); + return 1; + } + $this->appConfig->deleteKey($appName, $configName); $output->writeln('Config value ' . $configName . ' of app ' . $appName . ' deleted'); return 0; diff --git a/core/Command/Config/App/SetConfig.php b/core/Command/Config/App/SetConfig.php index e33f1ea63bf00..e0224770d681d 100644 --- a/core/Command/Config/App/SetConfig.php +++ b/core/Command/Config/App/SetConfig.php @@ -10,6 +10,7 @@ namespace OC\Core\Command\Config\App; use OC\AppConfig; +use OCP\Config\BeforeAppConfigSetEvent; use OCP\Exceptions\AppConfigUnknownKeyException; use OCP\IAppConfig; use Symfony\Component\Console\Helper\QuestionHelper; @@ -124,6 +125,14 @@ protected function execute(InputInterface $input, OutputInterface $output): int $updated = $this->appConfig->updateType($appName, $configName, $type) || $updated; } } else { + $value = (string)$input->getOption('value'); + $event = new BeforeAppConfigSetEvent($appName, $configName, $value); + $this->eventDispatcher->dispatchTyped($event); + if ($event->isRejected()) { + $output->writeln('The value was rejected for config ' . $configName . ' of app ' . $appName . ''); + return self::FAILURE; + } + /** * If --type is specified in the command line, we upgrade the type in database * after a confirmation from admin. @@ -174,7 +183,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int return self::FAILURE; } - $value = (string)$input->getOption('value'); switch ($type) { case IAppConfig::VALUE_MIXED: $updated = $this->appConfig->setValueMixed($appName, $configName, $value, $lazy, $sensitive); diff --git a/lib/public/Config/BeforeAppConfigDeletedEvent.php b/lib/public/Config/BeforeAppConfigDeletedEvent.php new file mode 100644 index 0000000000000..ce11c7bc92ffc --- /dev/null +++ b/lib/public/Config/BeforeAppConfigDeletedEvent.php @@ -0,0 +1,61 @@ +appId; + } + + /** + * @since 36.0.0 + */ + public function getConfigKey(): string { + return $this->configKey; + } + + /** + * @since 36.0.0 + */ + public function isRejected(): bool { + return !$this->valid; + } + + /** + * @since 36.0.0 + */ + public function setValid(bool $valid): void { + $this->valid = $valid; + } +} diff --git a/lib/public/Config/BeforeAppConfigSetEvent.php b/lib/public/Config/BeforeAppConfigSetEvent.php new file mode 100644 index 0000000000000..2b9174b1d2c94 --- /dev/null +++ b/lib/public/Config/BeforeAppConfigSetEvent.php @@ -0,0 +1,69 @@ +appId; + } + + /** + * @since 36.0.0 + */ + public function getConfigKey(): string { + return $this->configKey; + } + + /** + * @since 36.0.0 + */ + public function getConfigValue(): string { + return $this->configValue; + } + + /** + * @since 36.0.0 + */ + public function isRejected(): bool { + return !$this->valid; + } + + /** + * @since 36.0.0 + */ + public function setValid(bool $valid): void { + $this->valid = $valid; + } +} diff --git a/tests/Core/Command/Config/App/DeleteConfigTest.php b/tests/Core/Command/Config/App/DeleteConfigTest.php index 09b710da99809..3e52b8e56e021 100644 --- a/tests/Core/Command/Config/App/DeleteConfigTest.php +++ b/tests/Core/Command/Config/App/DeleteConfigTest.php @@ -10,6 +10,8 @@ namespace Tests\Core\Command\Config\App; use OC\Core\Command\Config\App\DeleteConfig; +use OCP\Config\BeforeAppConfigDeletedEvent; +use OCP\EventDispatcher\IEventDispatcher; use OCP\IAppConfig; use PHPUnit\Framework\MockObject\MockObject; use Symfony\Component\Console\Command\Command; @@ -90,4 +92,27 @@ public function testDelete(string $configName, bool $configExists, bool $checkIf $this->assertSame($expectedReturn, self::invokePrivate($this->command, 'execute', [$this->consoleInput, $this->consoleOutput])); } + + public function testDeleteRejected(): void { + $this->getAutoMock(IEventDispatcher::class)->expects($this->once()) + ->method('dispatchTyped') + ->willReturnCallback(function (BeforeAppConfigDeletedEvent $event): void { + $this->assertSame('app-name', $event->getAppId()); + $this->assertSame('name', $event->getConfigKey()); + $event->setValid(false); + }); + $this->getAutoMock(IAppConfig::class)->expects($this->never()) + ->method('deleteKey'); + + $this->consoleInput->method('getArgument') + ->willReturnMap([ + ['app', 'app-name'], + ['name', 'name'], + ]); + $this->consoleOutput->expects($this->once()) + ->method('writeln') + ->with($this->stringContains('error')); + + $this->assertSame(1, self::invokePrivate($this->command, 'execute', [$this->consoleInput, $this->consoleOutput])); + } } diff --git a/tests/Core/Command/Config/App/SetConfigTest.php b/tests/Core/Command/Config/App/SetConfigTest.php index 10e4bb51fc92b..baafbc6d633c8 100644 --- a/tests/Core/Command/Config/App/SetConfigTest.php +++ b/tests/Core/Command/Config/App/SetConfigTest.php @@ -11,6 +11,8 @@ use OC\AppConfig; use OC\Core\Command\Config\App\SetConfig; +use OCP\Config\BeforeAppConfigSetEvent; +use OCP\EventDispatcher\IEventDispatcher; use OCP\Exceptions\AppConfigUnknownKeyException; use OCP\IAppConfig; use PHPUnit\Framework\MockObject\MockObject; @@ -104,4 +106,40 @@ public function testSet(string $configName, mixed $newValue, bool $configExists, self::invokePrivate($this->command, 'execute', [$this->consoleInput, $this->consoleOutput]); } + + public function testSetRejected(): void { + $this->getAutoMock(IEventDispatcher::class)->expects($this->once()) + ->method('dispatchTyped') + ->willReturnCallback(function (BeforeAppConfigSetEvent $event): void { + $this->assertSame('app-name', $event->getAppId()); + $this->assertSame('name', $event->getConfigKey()); + $this->assertSame('newvalue', $event->getConfigValue()); + $event->setValid(false); + }); + $this->getAutoMock(AppConfig::class)->expects($this->never()) + ->method('updateType'); + $this->getAutoMock(AppConfig::class)->expects($this->never()) + ->method('setValueMixed'); + + $this->consoleInput->method('getArgument') + ->willReturnMap([ + ['app', 'app-name'], + ['name', 'name'], + ]); + $this->consoleInput->method('getOption') + ->willReturnMap([ + ['value', 'newvalue'], + ]); + $this->consoleInput->method('hasParameterOption') + ->willReturnMap([ + ['--update-only', false, false], + ['--type', false, false], + ['--value', false, true], + ]); + $this->consoleOutput->expects($this->once()) + ->method('writeln') + ->with($this->stringContains('error')); + + $this->assertSame(1, self::invokePrivate($this->command, 'execute', [$this->consoleInput, $this->consoleOutput])); + } } From 1c21be77375443d782bfaa8885e36594303cdba9 Mon Sep 17 00:00:00 2001 From: Joas Schilling Date: Fri, 30 Oct 2026 10:50:40 +0100 Subject: [PATCH 2/5] chore(assets): Recompile assets Signed-off-by: Joas Schilling --- .../openapi-administration.json | 46 ++++++++++ apps/provisioning_api/openapi-full.json | 92 +++++++++++++++++++ apps/provisioning_api/openapi.json | 46 ++++++++++ openapi.json | 92 +++++++++++++++++++ 4 files changed, 276 insertions(+) diff --git a/apps/provisioning_api/openapi-administration.json b/apps/provisioning_api/openapi-administration.json index 68f694a431e85..4c77de36e2b04 100644 --- a/apps/provisioning_api/openapi-administration.json +++ b/apps/provisioning_api/openapi-administration.json @@ -2605,6 +2605,52 @@ } } }, + "400": { + "description": "Deletion rejected", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + }, "403": { "description": "App or key is not allowed", "content": { diff --git a/apps/provisioning_api/openapi-full.json b/apps/provisioning_api/openapi-full.json index e80c1c5eaa464..2d3862ef8a0b4 100644 --- a/apps/provisioning_api/openapi-full.json +++ b/apps/provisioning_api/openapi-full.json @@ -2911,6 +2911,52 @@ } } }, + "400": { + "description": "Deletion rejected", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + }, "403": { "description": "App or key is not allowed", "content": { @@ -3105,6 +3151,52 @@ } } }, + "400": { + "description": "Value rejected", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + }, "403": { "description": "App or key is not allowed", "content": { diff --git a/apps/provisioning_api/openapi.json b/apps/provisioning_api/openapi.json index 52a6c10dbf371..48b442441bcf9 100644 --- a/apps/provisioning_api/openapi.json +++ b/apps/provisioning_api/openapi.json @@ -4010,6 +4010,52 @@ } } }, + "400": { + "description": "Value rejected", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + }, "403": { "description": "App or key is not allowed", "content": { diff --git a/openapi.json b/openapi.json index 6c26d3ac0f2ce..b6a818a3c6c88 100644 --- a/openapi.json +++ b/openapi.json @@ -33728,6 +33728,52 @@ } } }, + "400": { + "description": "Deletion rejected", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + }, "403": { "description": "App or key is not allowed", "content": { @@ -33922,6 +33968,52 @@ } } }, + "400": { + "description": "Value rejected", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + }, "403": { "description": "App or key is not allowed", "content": { From 4eea40fa4bc611d9c1762f00ba48324eb107c8cd Mon Sep 17 00:00:00 2001 From: Joas Schilling Date: Fri, 30 Oct 2026 10:56:32 +0100 Subject: [PATCH 3/5] build: Update autoloader Signed-off-by: Joas Schilling --- lib/composer/composer/autoload_classmap.php | 2 ++ lib/composer/composer/autoload_static.php | 2 ++ 2 files changed, 4 insertions(+) diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index ed0949d9cd9e4..5a2f86fd626c6 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -345,6 +345,8 @@ 'OCP\\Comments\\MessageTooLongException' => $baseDir . '/lib/public/Comments/MessageTooLongException.php', 'OCP\\Comments\\NotFoundException' => $baseDir . '/lib/public/Comments/NotFoundException.php', 'OCP\\Common\\Exception\\NotFoundException' => $baseDir . '/lib/public/Common/Exception/NotFoundException.php', + 'OCP\\Config\\BeforeAppConfigDeletedEvent' => $baseDir . '/lib/public/Config/BeforeAppConfigDeletedEvent.php', + 'OCP\\Config\\BeforeAppConfigSetEvent' => $baseDir . '/lib/public/Config/BeforeAppConfigSetEvent.php', 'OCP\\Config\\BeforePreferenceDeletedEvent' => $baseDir . '/lib/public/Config/BeforePreferenceDeletedEvent.php', 'OCP\\Config\\BeforePreferenceSetEvent' => $baseDir . '/lib/public/Config/BeforePreferenceSetEvent.php', 'OCP\\Config\\Exceptions\\IncorrectTypeException' => $baseDir . '/lib/public/Config/Exceptions/IncorrectTypeException.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index 42edbbffa04ab..db90f3a83227f 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -386,6 +386,8 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OCP\\Comments\\MessageTooLongException' => __DIR__ . '/../../..' . '/lib/public/Comments/MessageTooLongException.php', 'OCP\\Comments\\NotFoundException' => __DIR__ . '/../../..' . '/lib/public/Comments/NotFoundException.php', 'OCP\\Common\\Exception\\NotFoundException' => __DIR__ . '/../../..' . '/lib/public/Common/Exception/NotFoundException.php', + 'OCP\\Config\\BeforeAppConfigDeletedEvent' => __DIR__ . '/../../..' . '/lib/public/Config/BeforeAppConfigDeletedEvent.php', + 'OCP\\Config\\BeforeAppConfigSetEvent' => __DIR__ . '/../../..' . '/lib/public/Config/BeforeAppConfigSetEvent.php', 'OCP\\Config\\BeforePreferenceDeletedEvent' => __DIR__ . '/../../..' . '/lib/public/Config/BeforePreferenceDeletedEvent.php', 'OCP\\Config\\BeforePreferenceSetEvent' => __DIR__ . '/../../..' . '/lib/public/Config/BeforePreferenceSetEvent.php', 'OCP\\Config\\Exceptions\\IncorrectTypeException' => __DIR__ . '/../../..' . '/lib/public/Config/Exceptions/IncorrectTypeException.php', From d8d6f9f2ddcc4150835db0151c29a7db2785d9cc Mon Sep 17 00:00:00 2001 From: Joas Schilling Date: Fri, 30 Oct 2026 13:00:28 +0100 Subject: [PATCH 4/5] chore(cs): Apply autofixes Signed-off-by: Joas Schilling --- .../tests/Controller/AppConfigControllerTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/provisioning_api/tests/Controller/AppConfigControllerTest.php b/apps/provisioning_api/tests/Controller/AppConfigControllerTest.php index 69fe7bec390b4..315adf85328fa 100644 --- a/apps/provisioning_api/tests/Controller/AppConfigControllerTest.php +++ b/apps/provisioning_api/tests/Controller/AppConfigControllerTest.php @@ -91,7 +91,7 @@ protected function getInstance(array $methods = []) { $this->settingManager, $this->appManager, $this->configManager, - $this->eventDispatcher, + $this->eventDispatcher, ]) ->onlyMethods($methods) ->getMock(); From 67339da2781bc1c89752577d23682dd3af447062 Mon Sep 17 00:00:00 2001 From: Joas Schilling Date: Fri, 30 Oct 2026 14:39:40 +0100 Subject: [PATCH 5/5] feat(appconfig): Add option to still force-fully set a value Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: Joas Schilling --- core/Command/Config/App/DeleteConfig.php | 13 +++++-- core/Command/Config/App/SetConfig.php | 13 +++++-- .../Command/Config/App/DeleteConfigTest.php | 23 ++++++++++++ .../Core/Command/Config/App/SetConfigTest.php | 35 +++++++++++++++++++ 4 files changed, 80 insertions(+), 4 deletions(-) diff --git a/core/Command/Config/App/DeleteConfig.php b/core/Command/Config/App/DeleteConfig.php index 6f76b95d90335..9c3062601c1a5 100644 --- a/core/Command/Config/App/DeleteConfig.php +++ b/core/Command/Config/App/DeleteConfig.php @@ -39,6 +39,12 @@ protected function configure() { InputOption::VALUE_NONE, 'Checks whether the config exists before deleting it' ) + ->addOption( + 'force', + 'f', + InputOption::VALUE_NONE, + 'Delete the value even if the app rejects it', + ) ; } @@ -55,8 +61,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int $event = new BeforeAppConfigDeletedEvent($appName, $configName); $this->eventDispatcher->dispatchTyped($event); if ($event->isRejected()) { - $output->writeln('Deleting config ' . $configName . ' of app ' . $appName . ' was rejected'); - return 1; + if (!$input->getOption('force')) { + $output->writeln('Deleting config ' . $configName . ' of app ' . $appName . ' was rejected, use --force to delete it anyway'); + return 1; + } + $output->writeln('Deleting config ' . $configName . ' of app ' . $appName . ' was rejected, deleting it anyway'); } $this->appConfig->deleteKey($appName, $configName); diff --git a/core/Command/Config/App/SetConfig.php b/core/Command/Config/App/SetConfig.php index e0224770d681d..0c6b28a692201 100644 --- a/core/Command/Config/App/SetConfig.php +++ b/core/Command/Config/App/SetConfig.php @@ -75,6 +75,12 @@ protected function configure() { InputOption::VALUE_NONE, 'Only updates the value, if it is not set before, it is not being added' ) + ->addOption( + 'force', + 'f', + InputOption::VALUE_NONE, + 'Set the value even if the app rejects it', + ) ; } @@ -129,8 +135,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int $event = new BeforeAppConfigSetEvent($appName, $configName, $value); $this->eventDispatcher->dispatchTyped($event); if ($event->isRejected()) { - $output->writeln('The value was rejected for config ' . $configName . ' of app ' . $appName . ''); - return self::FAILURE; + if (!$input->getOption('force')) { + $output->writeln('The value was rejected for config ' . $configName . ' of app ' . $appName . ', use --force to set it anyway'); + return self::FAILURE; + } + $output->writeln('The value was rejected for config ' . $configName . ' of app ' . $appName . ', setting it anyway'); } /** diff --git a/tests/Core/Command/Config/App/DeleteConfigTest.php b/tests/Core/Command/Config/App/DeleteConfigTest.php index 3e52b8e56e021..55d5b5878759a 100644 --- a/tests/Core/Command/Config/App/DeleteConfigTest.php +++ b/tests/Core/Command/Config/App/DeleteConfigTest.php @@ -115,4 +115,27 @@ public function testDeleteRejected(): void { $this->assertSame(1, self::invokePrivate($this->command, 'execute', [$this->consoleInput, $this->consoleOutput])); } + + public function testDeleteRejectedForced(): void { + $this->getAutoMock(IEventDispatcher::class)->expects($this->once()) + ->method('dispatchTyped') + ->willReturnCallback(function (BeforeAppConfigDeletedEvent $event): void { + $event->setValid(false); + }); + $this->getAutoMock(IAppConfig::class)->expects($this->once()) + ->method('deleteKey') + ->with('app-name', 'name'); + + $this->consoleInput->method('getArgument') + ->willReturnMap([ + ['app', 'app-name'], + ['name', 'name'], + ]); + $this->consoleInput->method('getOption') + ->willReturnMap([ + ['force', true], + ]); + + $this->assertSame(0, self::invokePrivate($this->command, 'execute', [$this->consoleInput, $this->consoleOutput])); + } } diff --git a/tests/Core/Command/Config/App/SetConfigTest.php b/tests/Core/Command/Config/App/SetConfigTest.php index baafbc6d633c8..14a5f0305ba61 100644 --- a/tests/Core/Command/Config/App/SetConfigTest.php +++ b/tests/Core/Command/Config/App/SetConfigTest.php @@ -142,4 +142,39 @@ public function testSetRejected(): void { $this->assertSame(1, self::invokePrivate($this->command, 'execute', [$this->consoleInput, $this->consoleOutput])); } + + public function testSetRejectedForced(): void { + $this->getAutoMock(IEventDispatcher::class)->expects($this->once()) + ->method('dispatchTyped') + ->willReturnCallback(function (BeforeAppConfigSetEvent $event): void { + $event->setValid(false); + }); + $this->getAutoMock(AppConfig::class)->method('getValueType') + ->willReturn(IAppConfig::VALUE_MIXED); + $this->getAutoMock(AppConfig::class)->expects($this->once()) + ->method('setValueMixed') + ->with('app-name', 'name', 'newvalue') + ->willReturn(true); + $this->getAutoMock(AppConfig::class)->method('getDetails') + ->willReturn(['sensitive' => false, 'value' => 'newvalue', 'typeString' => 'mixed', 'lazy' => false]); + + $this->consoleInput->method('getArgument') + ->willReturnMap([ + ['app', 'app-name'], + ['name', 'name'], + ]); + $this->consoleInput->method('getOption') + ->willReturnMap([ + ['value', 'newvalue'], + ['force', true], + ]); + $this->consoleInput->method('hasParameterOption') + ->willReturnMap([ + ['--update-only', false, false], + ['--type', false, false], + ['--value', false, true], + ]); + + $this->assertSame(0, self::invokePrivate($this->command, 'execute', [$this->consoleInput, $this->consoleOutput])); + } }