From 4b1c3fbe3bf8a05f53d687dd276eaea758a91abd Mon Sep 17 00:00:00 2001 From: Peter Ringelmann Date: Thu, 21 May 2026 07:35:57 +0200 Subject: [PATCH] fix(settings,oauth2): preserve wipe state across admin deletion paths Signed-off-by: Peter Ringelmann --- .../lib/Controller/SettingsController.php | 27 ++++- .../Controller/SettingsControllerTest.php | 114 +++++++++++++++++- apps/settings/lib/Activity/Provider.php | 4 + .../lib/Controller/AuthSettingsController.php | 8 +- .../settings/src/components/AuthToken.spec.ts | 3 +- apps/settings/src/components/AuthToken.vue | 2 + .../src/components/AuthTokenDeleteDialog.vue | 35 +++--- .../Controller/AuthSettingsControllerTest.php | 37 ++++++ core/Command/User/AuthTokens/Delete.php | 22 ++++ .../Command/User/AuthTokens/DeleteTest.php | 94 +++++++++++---- 10 files changed, 292 insertions(+), 54 deletions(-) diff --git a/apps/oauth2/lib/Controller/SettingsController.php b/apps/oauth2/lib/Controller/SettingsController.php index 9e994b80eb9..7cd8e8655dc 100644 --- a/apps/oauth2/lib/Controller/SettingsController.php +++ b/apps/oauth2/lib/Controller/SettingsController.php @@ -8,6 +8,7 @@ declare(strict_types=1); */ namespace OCA\OAuth2\Controller; +use OC\Authentication\Token\IProvider as IAuthTokenProvider; use OCA\OAuth2\Db\AccessTokenMapper; use OCA\OAuth2\Db\Client; use OCA\OAuth2\Db\ClientMapper; @@ -15,13 +16,15 @@ use OCP\AppFramework\Controller; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\PasswordConfirmationRequired; use OCP\AppFramework\Http\JSONResponse; -use OCP\Authentication\Token\IProvider as IAuthTokenProvider; +use OCP\Authentication\Exceptions\InvalidTokenException; +use OCP\Authentication\Exceptions\WipeTokenException; use OCP\IL10N; use OCP\IRequest; use OCP\IUser; use OCP\IUserManager; use OCP\Security\ICrypto; use OCP\Security\ISecureRandom; +use Psr\Log\LoggerInterface; class SettingsController extends Controller { @@ -37,6 +40,7 @@ class SettingsController extends Controller { private IAuthTokenProvider $tokenProvider, private IUserManager $userManager, private ICrypto $crypto, + private LoggerInterface $logger, ) { parent::__construct($appName, $request); } @@ -73,7 +77,26 @@ class SettingsController extends Controller { $client = $this->clientMapper->getByUid($id); $this->userManager->callForSeenUsers(function (IUser $user) use ($client): void { - $this->tokenProvider->invalidateTokensOfUser($user->getUID(), $client->getName()); + // Skip tokens that are marked for remote wipe so revoking the + // OAuth2 client does not silently cancel a pending wipe. + $tokens = $this->tokenProvider->getTokenByUser($user->getUID()); + foreach ($tokens as $token) { + if ($token->getName() !== $client->getName()) { + continue; + } + try { + $this->tokenProvider->getTokenById($token->getId()); + } catch (WipeTokenException $e) { + $this->logger->info('Preserving token {tokenId} of user {uid}: marked for remote wipe, OAuth2 client revoke would cancel the wipe.', [ + 'tokenId' => $token->getId(), + 'uid' => $user->getUID(), + ]); + continue; + } catch (InvalidTokenException $e) { + // Token already invalid; let invalidateTokenById handle it. + } + $this->tokenProvider->invalidateTokenById($user->getUID(), $token->getId()); + } }); $this->accessTokenMapper->deleteByClientId($id); diff --git a/apps/oauth2/tests/Controller/SettingsControllerTest.php b/apps/oauth2/tests/Controller/SettingsControllerTest.php index f7b9fb39257..41db7fe595a 100644 --- a/apps/oauth2/tests/Controller/SettingsControllerTest.php +++ b/apps/oauth2/tests/Controller/SettingsControllerTest.php @@ -6,13 +6,15 @@ */ namespace OCA\OAuth2\Tests\Controller; +use OC\Authentication\Token\IProvider as IAuthTokenProvider; use OCA\OAuth2\Controller\SettingsController; use OCA\OAuth2\Db\AccessTokenMapper; use OCA\OAuth2\Db\Client; use OCA\OAuth2\Db\ClientMapper; use OCP\AppFramework\Http; use OCP\AppFramework\Http\JSONResponse; -use OCP\Authentication\Token\IProvider as IAuthTokenProvider; +use OCP\Authentication\Exceptions\WipeTokenException; +use OCP\Authentication\Token\IToken; use OCP\IL10N; use OCP\IRequest; use OCP\IUser; @@ -20,6 +22,8 @@ use OCP\IUserManager; use OCP\Security\ICrypto; use OCP\Security\ISecureRandom; use OCP\Server; +use PHPUnit\Framework\MockObject\MockObject; +use Psr\Log\LoggerInterface; use Test\TestCase; #[\PHPUnit\Framework\Attributes\Group(name: 'DB')] @@ -42,6 +46,7 @@ class SettingsControllerTest extends TestCase { private $l; /** @var ICrypto|\PHPUnit\Framework\MockObject\MockObject */ private $crypto; + private LoggerInterface&MockObject $logger; protected function setUp(): void { parent::setUp(); @@ -53,6 +58,7 @@ class SettingsControllerTest extends TestCase { $this->authTokenProvider = $this->createMock(IAuthTokenProvider::class); $this->userManager = $this->createMock(IUserManager::class); $this->crypto = $this->createMock(ICrypto::class); + $this->logger = $this->createMock(LoggerInterface::class); $this->l = $this->createMock(IL10N::class); $this->l->method('t') ->willReturnArgument(0); @@ -65,7 +71,8 @@ class SettingsControllerTest extends TestCase { $this->l, $this->authTokenProvider, $this->userManager, - $this->crypto + $this->crypto, + $this->logger, ); } @@ -132,11 +139,15 @@ class SettingsControllerTest extends TestCase { $user1->updateLastLoginTimestamp(); $tokenProviderMock = $this->getMockBuilder(IAuthTokenProvider::class)->getMock(); - // expect one call per user and ensure the correct client name + // One getTokenByUser call per user; we return no matching tokens here + // so invalidateTokenById is never invoked. $tokenProviderMock ->expects($this->exactly($count + 1)) - ->method('invalidateTokensOfUser') - ->with($this->isType('string'), 'My Client Name'); + ->method('getTokenByUser') + ->willReturn([]); + $tokenProviderMock + ->expects($this->never()) + ->method('invalidateTokenById'); $client = new Client(); $client->setId(123); @@ -167,7 +178,8 @@ class SettingsControllerTest extends TestCase { $this->l, $tokenProviderMock, $userManager, - $this->crypto + $this->crypto, + $this->logger, ); $result = $settingsController->deleteClient(123); @@ -177,6 +189,96 @@ class SettingsControllerTest extends TestCase { $user1->delete(); } + public function testDeleteClientPreservesWipePendingToken(): void { + $userManager = Server::get(IUserManager::class); + $user = $userManager->createUser('test_wipe_preserve', 'test_wipe_preserve'); + $user->updateLastLoginTimestamp(); + + $client = new Client(); + $client->setId(456); + $client->setName('My Client Name'); + $client->setRedirectUri('https://example.com/'); + $client->setSecret(bin2hex('MyHashedSecret')); + $client->setClientIdentifier('MyClientIdentifier'); + + // Token marked for wipe with a matching client name: must NOT be invalidated. + $wipeToken = $this->createMock(IToken::class); + $wipeToken->method('getId')->willReturn(11); + $wipeToken->method('getName')->willReturn('My Client Name'); + + // Regular token with matching name: must be invalidated. + $regularToken = $this->createMock(IToken::class); + $regularToken->method('getId')->willReturn(12); + $regularToken->method('getName')->willReturn('My Client Name'); + + // Non-matching name: must be left alone. + $otherToken = $this->createMock(IToken::class); + $otherToken->method('getId')->willReturn(13); + $otherToken->method('getName')->willReturn('Some Other Client'); + + $tokenProviderMock = $this->getMockBuilder(IAuthTokenProvider::class)->getMock(); + $tokenProviderMock + ->method('getTokenByUser') + ->willReturnCallback(function (string $uid) use ($wipeToken, $regularToken, $otherToken) { + return $uid === 'test_wipe_preserve' + ? [$wipeToken, $regularToken, $otherToken] + : []; + }); + // Wipe state is signalled via WipeTokenException from getTokenById. + $tokenProviderMock + ->method('getTokenById') + ->willReturnCallback(function (int $id) use ($wipeToken, $regularToken) { + if ($id === 11) { + throw new WipeTokenException($wipeToken); + } + return $regularToken; + }); + $tokenProviderMock + ->expects($this->once()) + ->method('invalidateTokenById') + ->with('test_wipe_preserve', 12); + + $this->clientMapper + ->method('getByUid') + ->with(456) + ->willReturn($client); + $this->accessTokenMapper + ->expects($this->once()) + ->method('deleteByClientId') + ->with(456); + $this->clientMapper + ->expects($this->once()) + ->method('delete') + ->with($client); + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->atLeastOnce()) + ->method('info') + ->with($this->stringContains('Preserving token'), $this->callback(function (array $context) { + return ($context['tokenId'] ?? null) === 11 + && ($context['uid'] ?? null) === 'test_wipe_preserve'; + })); + + $settingsController = new SettingsController( + 'oauth2', + $this->request, + $this->clientMapper, + $this->secureRandom, + $this->accessTokenMapper, + $this->l, + $tokenProviderMock, + $userManager, + $this->crypto, + $logger, + ); + + $result = $settingsController->deleteClient(456); + $this->assertInstanceOf(JSONResponse::class, $result); + $this->assertEquals([], $result->getData()); + + $user->delete(); + } + public function testInvalidRedirectUri(): void { $result = $this->settingsController->addClient('test', 'invalidurl'); diff --git a/apps/settings/lib/Activity/Provider.php b/apps/settings/lib/Activity/Provider.php index 52c9f90d449..f6489833de3 100644 --- a/apps/settings/lib/Activity/Provider.php +++ b/apps/settings/lib/Activity/Provider.php @@ -27,6 +27,7 @@ class Provider implements IProvider { public const EMAIL_CHANGED = 'email_changed'; public const APP_TOKEN_CREATED = 'app_token_created'; public const APP_TOKEN_DELETED = 'app_token_deleted'; + public const APP_TOKEN_DELETED_WIPE_CANCELLED = 'app_token_deleted_wipe_cancelled'; public const APP_TOKEN_RENAMED = 'app_token_renamed'; public const APP_TOKEN_FILESYSTEM_GRANTED = 'app_token_filesystem_granted'; public const APP_TOKEN_FILESYSTEM_REVOKED = 'app_token_filesystem_revoked'; @@ -86,6 +87,8 @@ class Provider implements IProvider { } } elseif ($event->getSubject() === self::APP_TOKEN_DELETED) { $subject = $this->l->t('You deleted app password "{token}"'); + } elseif ($event->getSubject() === self::APP_TOKEN_DELETED_WIPE_CANCELLED) { + $subject = $this->l->t('You deleted app password "{token}" and cancelled its pending remote wipe'); } elseif ($event->getSubject() === self::APP_TOKEN_RENAMED) { $subject = $this->l->t('You renamed app password "{token}" to "{newToken}"'); } elseif ($event->getSubject() === self::APP_TOKEN_FILESYSTEM_GRANTED) { @@ -125,6 +128,7 @@ class Provider implements IProvider { ]; case self::APP_TOKEN_CREATED: case self::APP_TOKEN_DELETED: + case self::APP_TOKEN_DELETED_WIPE_CANCELLED: case self::APP_TOKEN_FILESYSTEM_GRANTED: case self::APP_TOKEN_FILESYSTEM_REVOKED: return [ diff --git a/apps/settings/lib/Controller/AuthSettingsController.php b/apps/settings/lib/Controller/AuthSettingsController.php index f39deeddd4e..dc7fa2ebd82 100644 --- a/apps/settings/lib/Controller/AuthSettingsController.php +++ b/apps/settings/lib/Controller/AuthSettingsController.php @@ -179,17 +179,21 @@ class AuthSettingsController extends Controller { return new JSONResponse([], Http::STATUS_BAD_REQUEST); } + $subject = Provider::APP_TOKEN_DELETED; try { $token = $this->findTokenByIdAndUser($id); } catch (WipeTokenException $e) { - //continue as we can destroy tokens in wipe + // Deleting a wipe-pending token cancels the pending wipe; the device + // may already be uninstalled so we allow it, but record it under a + // distinct subject so the audit trail captures the consequence. $token = $e->getToken(); + $subject = Provider::APP_TOKEN_DELETED_WIPE_CANCELLED; } catch (InvalidTokenException $e) { return new JSONResponse([], Http::STATUS_NOT_FOUND); } $this->tokenProvider->invalidateTokenById($this->userId, $token->getId()); - $this->publishActivity(Provider::APP_TOKEN_DELETED, $token->getId(), ['name' => $token->getName()]); + $this->publishActivity($subject, $token->getId(), ['name' => $token->getName()]); return []; } diff --git a/apps/settings/src/components/AuthToken.spec.ts b/apps/settings/src/components/AuthToken.spec.ts index fb3c0c06453..ca63bf77a76 100644 --- a/apps/settings/src/components/AuthToken.spec.ts +++ b/apps/settings/src/components/AuthToken.spec.ts @@ -119,7 +119,8 @@ describe('AuthToken revoke flow', () => { dialog.vm.$emit('update:open', false) await wrapper.vm.$nextTick() - expect(dialog.props('open')).toBe(false) + // Dialog is v-if'd off the tree once closed + expect(wrapper.findComponent(AuthTokenDeleteDialog).exists()).toBe(false) expect(store.deleteToken).not.toHaveBeenCalled() }) diff --git a/apps/settings/src/components/AuthToken.vue b/apps/settings/src/components/AuthToken.vue index 0d9662952a8..d94b9175930 100644 --- a/apps/settings/src/components/AuthToken.vue +++ b/apps/settings/src/components/AuthToken.vue @@ -83,11 +83,13 @@ @@ -19,7 +19,7 @@

- {{ bodyText }} + {{ copy.body }}

@@ -65,22 +65,19 @@ export default defineComponent({ return this.token.type === TokenType.WIPING_TOKEN }, - dialogTitle(): string { - return this.wiping - ? t('settings', 'Revoke and cancel pending wipe?') - : t('settings', 'Revoke app password?') - }, - - bodyText(): string { - return this.wiping - ? t('settings', 'Only continue if you no longer need the device to be wiped.') - : t('settings', 'The app or device will lose access on its next sync. This cannot be undone.') - }, - - destructiveLabel(): string { - return this.wiping - ? t('settings', 'Revoke and cancel wipe') - : t('settings', 'Revoke') + copy(): { title: string, body: string, action: string } { + if (this.wiping) { + return { + title: t('settings', 'Revoke and cancel pending wipe?'), + body: t('settings', 'Only continue if you no longer need the device to be wiped.'), + action: t('settings', 'Revoke and cancel wipe'), + } + } + return { + title: t('settings', 'Revoke app password?'), + body: t('settings', 'The app or device will lose access on its next sync. This cannot be undone.'), + action: t('settings', 'Revoke'), + } }, buttons(): IDialogButton[] { @@ -93,7 +90,7 @@ export default defineComponent({ }, }, { - label: this.destructiveLabel, + label: this.copy.action, variant: 'error', callback: () => { this.$emit('confirm') diff --git a/apps/settings/tests/Controller/AuthSettingsControllerTest.php b/apps/settings/tests/Controller/AuthSettingsControllerTest.php index 5d75a1aa09a..805e810144a 100644 --- a/apps/settings/tests/Controller/AuthSettingsControllerTest.php +++ b/apps/settings/tests/Controller/AuthSettingsControllerTest.php @@ -233,6 +233,43 @@ class AuthSettingsControllerTest extends TestCase { $this->assertSame([], $this->controller->destroy($tokenId)); } + public function testDestroyWipePendingEmitsCancelledSubject(): void { + $tokenId = 125; + $token = $this->createMock(PublicKeyToken::class); + + $token->method('getId')->willReturn($tokenId); + $token->method('getName')->willReturn('My phone'); + + $this->tokenProvider->expects($this->once()) + ->method('getTokenById') + ->with($tokenId) + ->willThrowException(new \OCP\Authentication\Exceptions\WipeTokenException($token)); + + // The token is still invalidated (the user opted into cancelling the wipe). + $this->tokenProvider->expects($this->once()) + ->method('invalidateTokenById') + ->with($this->uid, $tokenId); + + // Activity event must use the distinguishing subject. + $event = $this->createMock(IEvent::class); + $event->method('setApp')->willReturnSelf(); + $event->method('setType')->willReturnSelf(); + $event->method('setAffectedUser')->willReturnSelf(); + $event->method('setAuthor')->willReturnSelf(); + $event->method('setObject')->willReturnSelf(); + $event->expects($this->once()) + ->method('setSubject') + ->with(\OCA\Settings\Activity\Provider::APP_TOKEN_DELETED_WIPE_CANCELLED, ['name' => 'My phone']) + ->willReturnSelf(); + $this->activityManager->expects($this->once()) + ->method('generateEvent') + ->willReturn($event); + $this->activityManager->expects($this->once()) + ->method('publish'); + + $this->assertEquals([], $this->controller->destroy($tokenId)); + } + public function testDestroyWrongUser(): void { $tokenId = 124; $token = $this->createMock(PublicKeyToken::class); diff --git a/core/Command/User/AuthTokens/Delete.php b/core/Command/User/AuthTokens/Delete.php index 2c7a958d9a7..803bcf6a0b9 100644 --- a/core/Command/User/AuthTokens/Delete.php +++ b/core/Command/User/AuthTokens/Delete.php @@ -9,6 +9,8 @@ namespace OC\Core\Command\User\AuthTokens; use DateTimeImmutable; use OC\Authentication\Token\IProvider; use OC\Core\Command\Base; +use OCP\Authentication\Exceptions\InvalidTokenException; +use OCP\Authentication\Exceptions\WipeTokenException; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Exception\RuntimeException; use Symfony\Component\Console\Input\InputArgument; @@ -43,6 +45,12 @@ class Delete extends Base { null, InputOption::VALUE_REQUIRED, 'Delete tokens last used before a given date.' + ) + ->addOption( + 'cancel-wipe', + null, + InputOption::VALUE_NONE, + 'Allow deleting a token that is marked for remote wipe. The pending wipe will not run.' ); } @@ -51,6 +59,7 @@ class Delete extends Base { $uid = $input->getArgument('uid'); $id = (int)$input->getArgument('id'); $before = $input->getOption('last-used-before'); + $cancelWipe = (bool)$input->getOption('cancel-wipe'); if ($before) { if ($id) { @@ -63,6 +72,19 @@ class Delete extends Base { if (!$id) { throw new RuntimeException('Not enough arguments. Specify the token or use the --last-used-before option.'); } + + if (!$cancelWipe) { + try { + $this->tokenProvider->getTokenById($id); + } catch (WipeTokenException $e) { + $output->writeln('Token ' . $id . ' is marked for remote wipe. Pass --cancel-wipe to delete it anyway; the pending wipe will not run.'); + return Command::FAILURE; + } catch (InvalidTokenException $e) { + // Token doesn't exist, has expired, or is otherwise unusable. + // Defer to invalidateTokenById, which handles the no-op case. + } + } + return $this->deleteById($uid, $id); } diff --git a/tests/Core/Command/User/AuthTokens/DeleteTest.php b/tests/Core/Command/User/AuthTokens/DeleteTest.php index edde4ded25d..c7b7f2ce43c 100644 --- a/tests/Core/Command/User/AuthTokens/DeleteTest.php +++ b/tests/Core/Command/User/AuthTokens/DeleteTest.php @@ -8,6 +8,8 @@ namespace Tests\Core\Command\User\AuthTokens; use OC\Authentication\Token\IProvider; use OC\Core\Command\User\AuthTokens\Delete; +use OCP\Authentication\Exceptions\WipeTokenException; +use OCP\Authentication\Token\IToken; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Exception\RuntimeException; use Symfony\Component\Console\Input\InputInterface; @@ -39,6 +41,20 @@ class DeleteTest extends TestCase { $this->command = new Delete($tokenProvider); } + /** + * Default option mapping: --last-used-before unset, --cancel-wipe unset. + * + * @param string|null $lastUsedBefore + * @param bool $cancelWipe + */ + private function mockOptions(?string $lastUsedBefore = null, bool $cancelWipe = false): void { + $this->consoleInput->method('getOption') + ->willReturnMap([ + ['last-used-before', $lastUsedBefore], + ['cancel-wipe', $cancelWipe], + ]); + } + public function testDeleteTokenById(): void { $this->consoleInput->expects($this->exactly(2)) ->method('getArgument') @@ -47,10 +63,7 @@ class DeleteTest extends TestCase { ['id', '42'] ]); - $this->consoleInput->expects($this->once()) - ->method('getOption') - ->with('last-used-before') - ->willReturn(null); + $this->mockOptions(); $this->tokenProvider->expects($this->once()) ->method('invalidateTokenById') @@ -68,10 +81,7 @@ class DeleteTest extends TestCase { ['id', null] ]); - $this->consoleInput->expects($this->once()) - ->method('getOption') - ->with('last-used-before') - ->willReturn(null); + $this->mockOptions(); $this->expectException(RuntimeException::class); @@ -89,10 +99,7 @@ class DeleteTest extends TestCase { ['id', null] ]); - $this->consoleInput->expects($this->once()) - ->method('getOption') - ->with('last-used-before') - ->willReturn('946684800'); + $this->mockOptions('946684800'); $this->tokenProvider->expects($this->once()) ->method('invalidateLastUsedBefore') @@ -110,10 +117,7 @@ class DeleteTest extends TestCase { ['id', null] ]); - $this->consoleInput->expects($this->once()) - ->method('getOption') - ->with('last-used-before') - ->willReturn('2000-01-01T00:00:00Z'); + $this->mockOptions('2000-01-01T00:00:00Z'); $this->tokenProvider->expects($this->once()) ->method('invalidateLastUsedBefore') @@ -131,10 +135,7 @@ class DeleteTest extends TestCase { ['id', null] ]); - $this->consoleInput->expects($this->once()) - ->method('getOption') - ->with('last-used-before') - ->willReturn('2000-01-01'); + $this->mockOptions('2000-01-01'); $this->tokenProvider->expects($this->once()) ->method('invalidateLastUsedBefore') @@ -152,10 +153,7 @@ class DeleteTest extends TestCase { ['id', '42'] ]); - $this->consoleInput->expects($this->once()) - ->method('getOption') - ->with('last-used-before') - ->willReturn('946684800'); + $this->mockOptions('946684800'); $this->expectException(RuntimeException::class); @@ -164,4 +162,52 @@ class DeleteTest extends TestCase { $result = self::invokePrivate($this->command, 'execute', [$this->consoleInput, $this->consoleOutput]); $this->assertSame(Command::SUCCESS, $result); } + + public function testDeleteByIdRefusesWipePendingWithoutFlag(): void { + $this->consoleInput->expects($this->exactly(2)) + ->method('getArgument') + ->willReturnMap([ + ['uid', 'user'], + ['id', '42'] + ]); + + $this->mockOptions(); + + $wipeToken = $this->createMock(IToken::class); + $this->tokenProvider->expects($this->once()) + ->method('getTokenById') + ->with(42) + ->willThrowException(new WipeTokenException($wipeToken)); + + $this->tokenProvider->expects($this->never())->method('invalidateTokenById'); + + $this->consoleOutput->expects($this->once()) + ->method('writeln') + ->with($this->stringContains('marked for remote wipe')); + + $result = self::invokePrivate($this->command, 'execute', [$this->consoleInput, $this->consoleOutput]); + $this->assertSame(Command::FAILURE, $result); + } + + public function testDeleteByIdAllowsWipePendingWithFlag(): void { + $this->consoleInput->expects($this->exactly(2)) + ->method('getArgument') + ->willReturnMap([ + ['uid', 'user'], + ['id', '42'] + ]); + + $this->mockOptions(null, true); + + // With --cancel-wipe, the wipe-state pre-check is skipped entirely + // (the operator has already opted in), so getTokenById should not run. + $this->tokenProvider->expects($this->never())->method('getTokenById'); + + $this->tokenProvider->expects($this->once()) + ->method('invalidateTokenById') + ->with('user', 42); + + $result = self::invokePrivate($this->command, 'execute', [$this->consoleInput, $this->consoleOutput]); + $this->assertSame(Command::SUCCESS, $result); + } }