mirror of
https://github.com/nextcloud/server.git
synced 2026-05-28 04:32:30 -04:00
Merge pull request #60644 from nextcloud/fix/app-token-delete-confirm
Some checks are pending
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
Integration sqlite / changes (push) Waiting to run
Integration sqlite / integration-sqlite (master, main, 8.4, main, --tags ~@large files_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, capabilities_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, collaboration_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, comments_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, dav_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, federation_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, file_conversions) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, files_reminders) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, filesdrop_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, guests_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, ldap_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, openldap_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, openldap_numerical_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, remoteapi_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, routing_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, setup_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, sharees_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, sharing_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, theming_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, videoverification_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite-summary (push) Blocked by required conditions
Psalm static code analysis / changes (push) Waiting to run
Psalm static code analysis / static-code-analysis (push) Blocked by required conditions
Psalm static code analysis / static-code-analysis-security (push) Blocked by required conditions
Psalm static code analysis / static-code-analysis-ocp (push) Blocked by required conditions
Psalm static code analysis / static-code-analysis-ncu (push) Blocked by required conditions
Psalm static code analysis / static-code-analysis-strict (push) Blocked by required conditions
Psalm static code analysis / static-code-analysis-summary (push) Blocked by required conditions
Some checks are pending
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
Integration sqlite / changes (push) Waiting to run
Integration sqlite / integration-sqlite (master, main, 8.4, main, --tags ~@large files_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, capabilities_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, collaboration_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, comments_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, dav_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, federation_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, file_conversions) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, files_reminders) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, filesdrop_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, guests_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, ldap_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, openldap_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, openldap_numerical_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, remoteapi_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, routing_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, setup_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, sharees_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, sharing_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, theming_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, videoverification_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite-summary (push) Blocked by required conditions
Psalm static code analysis / changes (push) Waiting to run
Psalm static code analysis / static-code-analysis (push) Blocked by required conditions
Psalm static code analysis / static-code-analysis-security (push) Blocked by required conditions
Psalm static code analysis / static-code-analysis-ocp (push) Blocked by required conditions
Psalm static code analysis / static-code-analysis-ncu (push) Blocked by required conditions
Psalm static code analysis / static-code-analysis-strict (push) Blocked by required conditions
Psalm static code analysis / static-code-analysis-summary (push) Blocked by required conditions
fix(settings): confirm app-token revoke and preserve wipe state
This commit is contained in:
commit
492a42b4b3
27 changed files with 596 additions and 82 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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 [
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,9 +3,196 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// AuthToken.vue reads window.oc_defaults at module evaluation time. vi.hoisted
|
||||
// runs before imports, so this guarantees the property is set on the existing
|
||||
// jsdom window before the SFC is first parsed.
|
||||
vi.hoisted(() => {
|
||||
(window as unknown as { oc_defaults: { productName: string } }).oc_defaults = { productName: 'Nextcloud' }
|
||||
})
|
||||
|
||||
import type { IToken } from '../store/authtoken.ts'
|
||||
|
||||
// Mock @nextcloud/dialogs so the wipe action's showConfirmation call resolves
|
||||
// synchronously in tests. Hoisted so it's installed before AuthToken.vue imports.
|
||||
const showConfirmationMock = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@nextcloud/dialogs', () => ({
|
||||
showConfirmation: showConfirmationMock,
|
||||
}))
|
||||
|
||||
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
|
||||
import AuthToken from './AuthToken.vue'
|
||||
import AuthTokenDeleteDialog from './AuthTokenDeleteDialog.vue'
|
||||
import { TokenType, useAuthTokenStore } from '../store/authtoken.ts'
|
||||
import { detect } from '../utils/userAgentDetect.ts'
|
||||
|
||||
function makeToken(overrides: Partial<IToken> = {}): IToken {
|
||||
return {
|
||||
id: 1,
|
||||
name: 'Test device',
|
||||
type: TokenType.PERMANENT_TOKEN,
|
||||
lastActivity: 1700000000,
|
||||
canDelete: true,
|
||||
canRename: true,
|
||||
scope: { filesystem: true },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function mountAuthToken(token: IToken) {
|
||||
return mount(AuthToken, {
|
||||
// Vue Test Utils v1 (legacy pipeline) uses propsData; v2 also accepts it
|
||||
propsData: { token },
|
||||
mocks: {
|
||||
t: (_: string, text: string) => text,
|
||||
},
|
||||
stubs: {
|
||||
NcActions: true,
|
||||
NcActionButton: true,
|
||||
NcActionCheckbox: true,
|
||||
NcButton: true,
|
||||
NcDateTime: true,
|
||||
NcIconSvgWrapper: true,
|
||||
NcTextField: true,
|
||||
},
|
||||
pinia: createTestingPinia({
|
||||
createSpy: vi.fn,
|
||||
initialState: { 'auth-token': { tokens: [token] } },
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
function mountDeleteDialog(token: IToken, open = true) {
|
||||
return mount(AuthTokenDeleteDialog, {
|
||||
propsData: { token, open },
|
||||
mocks: {
|
||||
t: (_: string, text: string) => text,
|
||||
},
|
||||
stubs: {
|
||||
NcDialog: { template: '<div><slot /></div>' },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('AuthToken revoke flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('does not call deleteToken when the revoke action is triggered (dialog opens first)', async () => {
|
||||
const token = makeToken()
|
||||
const wrapper = mountAuthToken(token)
|
||||
const store = useAuthTokenStore()
|
||||
|
||||
;(wrapper.vm as unknown as { revoke: () => void }).revoke()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const dialog = wrapper.findComponent(AuthTokenDeleteDialog)
|
||||
expect(dialog.exists()).toBe(true)
|
||||
expect(dialog.props('open')).toBe(true)
|
||||
expect(store.deleteToken).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls deleteToken only after the dialog emits confirm', async () => {
|
||||
const token = makeToken()
|
||||
const wrapper = mountAuthToken(token)
|
||||
const store = useAuthTokenStore()
|
||||
|
||||
;(wrapper.vm as unknown as { revoke: () => void }).revoke()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const dialog = wrapper.findComponent(AuthTokenDeleteDialog)
|
||||
dialog.vm.$emit('confirm')
|
||||
dialog.vm.$emit('update:open', false)
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(store.deleteToken).toHaveBeenCalledTimes(1)
|
||||
expect(store.deleteToken).toHaveBeenCalledWith(token)
|
||||
})
|
||||
|
||||
it('does not call deleteToken when the dialog is dismissed without confirming', async () => {
|
||||
const token = makeToken()
|
||||
const wrapper = mountAuthToken(token)
|
||||
const store = useAuthTokenStore()
|
||||
|
||||
;(wrapper.vm as unknown as { revoke: () => void }).revoke()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const dialog = wrapper.findComponent(AuthTokenDeleteDialog)
|
||||
dialog.vm.$emit('update:open', false)
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
// Dialog is v-if'd off the tree once closed
|
||||
expect(wrapper.findComponent(AuthTokenDeleteDialog).exists()).toBe(false)
|
||||
expect(store.deleteToken).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('passes the wipe-pending token to the dialog when revoke is triggered', async () => {
|
||||
const token = makeToken({ type: TokenType.WIPING_TOKEN, canRename: false })
|
||||
const wrapper = mountAuthToken(token)
|
||||
|
||||
;(wrapper.vm as unknown as { revoke: () => void }).revoke()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const dialog = wrapper.findComponent(AuthTokenDeleteDialog)
|
||||
expect(dialog.exists()).toBe(true)
|
||||
expect(dialog.props('open')).toBe(true)
|
||||
expect((dialog.props('token') as IToken).type).toBe(TokenType.WIPING_TOKEN)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AuthToken wipe flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('does not call wipeToken when the user rejects the confirmation', async () => {
|
||||
showConfirmationMock.mockResolvedValueOnce(false)
|
||||
const token = makeToken()
|
||||
const wrapper = mountAuthToken(token)
|
||||
const store = useAuthTokenStore()
|
||||
|
||||
await (wrapper.vm as unknown as { wipe: () => Promise<void> }).wipe()
|
||||
|
||||
expect(showConfirmationMock).toHaveBeenCalledTimes(1)
|
||||
expect(store.wipeToken).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls wipeToken when the user accepts the confirmation', async () => {
|
||||
showConfirmationMock.mockResolvedValueOnce(true)
|
||||
const token = makeToken()
|
||||
const wrapper = mountAuthToken(token)
|
||||
const store = useAuthTokenStore()
|
||||
|
||||
await (wrapper.vm as unknown as { wipe: () => Promise<void> }).wipe()
|
||||
|
||||
expect(showConfirmationMock).toHaveBeenCalledTimes(1)
|
||||
expect(store.wipeToken).toHaveBeenCalledTimes(1)
|
||||
expect(store.wipeToken).toHaveBeenCalledWith(token)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AuthTokenDeleteDialog wipe-pending warning', () => {
|
||||
it('omits the warning for a normal token', () => {
|
||||
const token = makeToken({ type: TokenType.PERMANENT_TOKEN })
|
||||
const wrapper = mountDeleteDialog(token)
|
||||
expect(wrapper.findComponent(NcNoteCard).exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders an accessible error NcNoteCard for a wipe-pending token', () => {
|
||||
const token = makeToken({ type: TokenType.WIPING_TOKEN })
|
||||
const wrapper = mountDeleteDialog(token)
|
||||
|
||||
const noteCard = wrapper.findComponent(NcNoteCard)
|
||||
expect(noteCard.exists()).toBe(true)
|
||||
expect(noteCard.props('type')).toBe('error')
|
||||
expect(noteCard.text()).toMatch(/wipe/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Android Chrome detection', () => {
|
||||
it('modern Android Chrome (no Build/ string, post-2021) should match androidChrome', () => {
|
||||
const ua = 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Mobile Safari/537.36'
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@
|
|||
|
||||
<!-- revoke & wipe -->
|
||||
<template v-if="token.canDelete">
|
||||
<template v-if="token.type !== 2">
|
||||
<template v-if="token.type !== TokenType.WIPING_TOKEN">
|
||||
<NcActionButton
|
||||
icon="icon-delete"
|
||||
@click.stop.prevent="revoke">
|
||||
|
|
@ -73,7 +73,7 @@
|
|||
</NcActionButton>
|
||||
</template>
|
||||
<NcActionButton
|
||||
v-else-if="token.type === 2"
|
||||
v-else
|
||||
icon="icon-delete"
|
||||
:name="t('settings', 'Revoke')"
|
||||
@click.stop.prevent="revoke">
|
||||
|
|
@ -82,6 +82,11 @@
|
|||
</template>
|
||||
</NcActions>
|
||||
</td>
|
||||
<AuthTokenDeleteDialog
|
||||
v-if="deleteDialogOpen"
|
||||
:token="token"
|
||||
:open.sync="deleteDialogOpen"
|
||||
@confirm="confirmDelete" />
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
|
|
@ -90,6 +95,7 @@ import type { PropType } from 'vue'
|
|||
import type { IToken } from '../store/authtoken.ts'
|
||||
|
||||
import { mdiAndroid, mdiAppleIos, mdiAppleSafari, mdiCellphone, mdiCheck, mdiFirefox, mdiGoogleChrome, mdiKeyOutline, mdiMicrosoftEdge, mdiMonitor, mdiTablet, mdiWeb } from '@mdi/js'
|
||||
import { showConfirmation } from '@nextcloud/dialogs'
|
||||
import { translate as t } from '@nextcloud/l10n'
|
||||
import { defineComponent } from 'vue'
|
||||
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
|
||||
|
|
@ -99,6 +105,7 @@ import NcButton from '@nextcloud/vue/components/NcButton'
|
|||
import NcDateTime from '@nextcloud/vue/components/NcDateTime'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
|
||||
import NcTextField from '@nextcloud/vue/components/NcTextField'
|
||||
import AuthTokenDeleteDialog from './AuthTokenDeleteDialog.vue'
|
||||
import { TokenType, useAuthTokenStore } from '../store/authtoken.ts'
|
||||
import { detect } from '../utils/userAgentDetect.ts'
|
||||
|
||||
|
|
@ -124,6 +131,7 @@ const nameMap = {
|
|||
export default defineComponent({
|
||||
name: 'AuthToken',
|
||||
components: {
|
||||
AuthTokenDeleteDialog,
|
||||
NcActions,
|
||||
NcActionButton,
|
||||
NcActionCheckbox,
|
||||
|
|
@ -151,7 +159,9 @@ export default defineComponent({
|
|||
renaming: false,
|
||||
newName: '',
|
||||
oldName: '',
|
||||
deleteDialogOpen: false,
|
||||
mdiCheck,
|
||||
TokenType,
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -277,6 +287,10 @@ export default defineComponent({
|
|||
|
||||
revoke() {
|
||||
this.actionOpen = false
|
||||
this.deleteDialogOpen = true
|
||||
},
|
||||
|
||||
confirmDelete() {
|
||||
this.authTokenStore.deleteToken(this.token)
|
||||
},
|
||||
|
||||
|
|
@ -285,9 +299,18 @@ export default defineComponent({
|
|||
this.authTokenStore.renameToken(this.token, this.newName)
|
||||
},
|
||||
|
||||
wipe() {
|
||||
async wipe() {
|
||||
this.actionOpen = false
|
||||
this.authTokenStore.wipeToken(this.token)
|
||||
const confirmed = await showConfirmation({
|
||||
name: t('settings', 'Confirm wipe'),
|
||||
text: t('settings', 'Do you really want to wipe your data from this device?'),
|
||||
labelConfirm: t('settings', 'Wipe device'),
|
||||
labelReject: t('settings', 'Cancel'),
|
||||
severity: 'warning',
|
||||
})
|
||||
if (confirmed) {
|
||||
this.authTokenStore.wipeToken(this.token)
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
|
|||
85
apps/settings/src/components/AuthTokenDeleteDialog.vue
Normal file
85
apps/settings/src/components/AuthTokenDeleteDialog.vue
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
<!--
|
||||
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { IDialogButton } from '@nextcloud/dialogs'
|
||||
import type { IToken } from '../store/authtoken.ts'
|
||||
|
||||
import { translate as t } from '@nextcloud/l10n'
|
||||
import { computed } from 'vue'
|
||||
import NcDialog from '@nextcloud/vue/components/NcDialog'
|
||||
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
|
||||
import { TokenType } from '../store/authtoken.ts'
|
||||
|
||||
const props = defineProps<{
|
||||
/** The token being revoked */
|
||||
token: IToken
|
||||
/** Whether the dialog is open */
|
||||
open: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [open: boolean]
|
||||
confirm: []
|
||||
}>()
|
||||
|
||||
const wiping = computed(() => props.token.type === TokenType.WIPING_TOKEN)
|
||||
|
||||
const messages = computed(() => {
|
||||
if (wiping.value) {
|
||||
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'),
|
||||
}
|
||||
})
|
||||
|
||||
const buttons = computed<IDialogButton[]>(() => [
|
||||
{
|
||||
label: t('settings', 'Cancel'),
|
||||
variant: 'tertiary',
|
||||
callback: () => emit('update:open', false),
|
||||
},
|
||||
{
|
||||
label: messages.value.action,
|
||||
variant: 'error',
|
||||
callback: () => {
|
||||
emit('confirm')
|
||||
emit('update:open', false)
|
||||
},
|
||||
},
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NcDialog
|
||||
:open="open"
|
||||
:name="messages.title"
|
||||
:buttons="buttons"
|
||||
size="normal"
|
||||
@update:open="emit('update:open', $event)">
|
||||
<NcNoteCard
|
||||
v-if="wiping"
|
||||
:heading="t('settings', 'Remote wipe has not started yet.')"
|
||||
type="error">
|
||||
{{ t('settings', 'Revoking now cancels the wipe. The device keeps its synced data.') }}
|
||||
</NcNoteCard>
|
||||
<p class="auth-token-delete-dialog__body">
|
||||
{{ messages.body }}
|
||||
</p>
|
||||
</NcDialog>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.auth-token-delete-dialog__body {
|
||||
margin-block-start: calc(var(--default-grid-baseline) * 2);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -14,20 +14,6 @@ import logger from '../logger.ts'
|
|||
const BASE_URL = generateUrl('/settings/personal/authtokens')
|
||||
addPasswordConfirmationInterceptors(axios)
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function confirm() {
|
||||
return new Promise((resolve) => {
|
||||
window.OC.dialogs.confirm(
|
||||
t('settings', 'Do you really want to wipe your data from this device?'),
|
||||
t('settings', 'Confirm wipe'),
|
||||
resolve,
|
||||
true,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export enum TokenType {
|
||||
TEMPORARY_TOKEN = 0,
|
||||
PERMANENT_TOKEN = 1,
|
||||
|
|
@ -134,11 +120,6 @@ export const useAuthTokenStore = defineStore('auth-token', {
|
|||
try {
|
||||
await confirmPassword()
|
||||
|
||||
if (!(await confirm())) {
|
||||
logger.debug('Wipe aborted by user')
|
||||
return
|
||||
}
|
||||
|
||||
await axios.post(`${BASE_URL}/wipe/${token.id}`)
|
||||
logger.debug('App token marked for wipe', { token })
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 <id> or use the --last-used-before option.');
|
||||
}
|
||||
|
||||
if (!$cancelWipe) {
|
||||
try {
|
||||
$this->tokenProvider->getTokenById($id);
|
||||
} catch (WipeTokenException $e) {
|
||||
$output->writeln('<error>Token ' . $id . ' is marked for remote wipe. Pass --cancel-wipe to delete it anyway; the pending wipe will not run.</error>');
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
|
|||
4
dist/core-common.js
vendored
4
dist/core-common.js
vendored
File diff suppressed because one or more lines are too long
2
dist/core-common.js.map
vendored
2
dist/core-common.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/core-public-page-menu.js
vendored
4
dist/core-public-page-menu.js
vendored
File diff suppressed because one or more lines are too long
2
dist/core-public-page-menu.js.map
vendored
2
dist/core-public-page-menu.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/files-reference-files.js
vendored
4
dist/files-reference-files.js
vendored
File diff suppressed because one or more lines are too long
2
dist/files-reference-files.js.map
vendored
2
dist/files-reference-files.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/files_sharing-personal-settings.js
vendored
4
dist/files_sharing-personal-settings.js
vendored
File diff suppressed because one or more lines are too long
2
dist/files_sharing-personal-settings.js.map
vendored
2
dist/files_sharing-personal-settings.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/settings-vue-settings-admin-mail.js
vendored
4
dist/settings-vue-settings-admin-mail.js
vendored
File diff suppressed because one or more lines are too long
2
dist/settings-vue-settings-admin-mail.js.map
vendored
2
dist/settings-vue-settings-admin-mail.js.map
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
4
dist/weather_status-weather-status.js
vendored
4
dist/weather_status-weather-status.js
vendored
File diff suppressed because one or more lines are too long
2
dist/weather_status-weather-status.js.map
vendored
2
dist/weather_status-weather-status.js.map
vendored
File diff suppressed because one or more lines are too long
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue