diff --git a/clive/__private/cli/commands/abc/world_based_command.py b/clive/__private/cli/commands/abc/world_based_command.py index d26e0970135fe4304ec3be08c4c3223789fb1ee0..179174afce776bc062015209187b25a934456a14 100644 --- a/clive/__private/cli/commands/abc/world_based_command.py +++ b/clive/__private/cli/commands/abc/world_based_command.py @@ -47,6 +47,10 @@ class WorldBasedCommand(ContextualCLICommand[World], ABC): def is_session_token_set(self) -> bool: return safe_settings.beekeeper.is_session_token_set + @property + def should_validate_if_remote_address_required(self) -> bool: + return True + @property def should_validate_if_session_token_required(self) -> bool: return True @@ -56,7 +60,8 @@ class WorldBasedCommand(ContextualCLICommand[World], ABC): return True async def validate(self) -> None: - self._validate_beekeeper_remote_address_set() + if self.should_validate_if_remote_address_required: + self._validate_beekeeper_remote_address_set() if self.should_validate_if_session_token_required: self._validate_beekeeper_session_token_set() await self._validate_remote_beekeeper_running() diff --git a/clive/__private/cli/commands/configure/profile.py b/clive/__private/cli/commands/configure/profile.py index e9456afe501cfd0d3a0fc7b655ed265f5af79c8d..45b57ea576bc772b155b5a2b1c21fcebf1faba81 100644 --- a/clive/__private/cli/commands/configure/profile.py +++ b/clive/__private/cli/commands/configure/profile.py @@ -7,7 +7,6 @@ from getpass import getpass from beekeepy.exceptions import CommunicationError -from clive.__private.cli.commands.abc.external_cli_command import ExternalCLICommand from clive.__private.cli.commands.abc.forceable_cli_command import ForceableCLICommand from clive.__private.cli.commands.abc.world_based_command import WorldBasedCommand from clive.__private.cli.exceptions import ( @@ -98,11 +97,23 @@ class CreateProfile(WorldBasedCommand): @dataclass(kw_only=True) -class DeleteProfile(ExternalCLICommand, ForceableCLICommand): +class DeleteProfile(WorldBasedCommand, ForceableCLICommand): profile_name: str + @property + def should_validate_if_remote_address_required(self) -> bool: + return False + + @property + def should_validate_if_session_token_required(self) -> bool: + return False + + @property + def should_require_unlocked_wallet(self) -> bool: + return False + async def _run(self) -> None: try: - Profile.delete_by_name(self.profile_name, force=self.force) + await self.world.commands.delete_profile(profile_name_to_delete=self.profile_name, force=self.force) except MultipleProfileVersionsError as error: raise CLIMultipleProfileVersionsError(self.profile_name) from error diff --git a/clive/__private/core/commands/commands.py b/clive/__private/core/commands/commands.py index 1249cd4bd52239f9faf4bd7dc24f6962c9b4b138..5702b81b33f5133244d03b9f6f56847186301c8f 100644 --- a/clive/__private/core/commands/commands.py +++ b/clive/__private/core/commands/commands.py @@ -28,6 +28,7 @@ from clive.__private.core.commands.data_retrieval.witnesses_data import ( WitnessesDataRetrieval, ) from clive.__private.core.commands.decrypt import Decrypt +from clive.__private.core.commands.delete_profile import DeleteProfile from clive.__private.core.commands.does_account_exist_in_node import DoesAccountExistsInNode from clive.__private.core.commands.encrypt import Encrypt from clive.__private.core.commands.find_accounts import FindAccounts @@ -156,6 +157,16 @@ class Commands[WorldT: World]: ) ) + async def delete_profile(self, *, profile_name_to_delete: str, force: bool = False) -> CommandWrapper: + return await self.__surround_with_exception_handlers( + DeleteProfile( + profile_name_to_delete=profile_name_to_delete, + profile_name_currently_unlocked=self._world.profile.name if self._world.is_profile_available else None, + session=self._world.beekeeper_manager.session if self._world.is_profile_available else None, + force=force, + ) + ) + async def does_account_exists_in_node(self, *, account_name: str) -> CommandWithResultWrapper[bool]: return await self.__surround_with_exception_handlers( DoesAccountExistsInNode(node=self._world.node, account_name=account_name) diff --git a/clive/__private/core/commands/delete_profile.py b/clive/__private/core/commands/delete_profile.py new file mode 100644 index 0000000000000000000000000000000000000000..e3af985fee1f9c543cafc3e8fb5e1f1b7ecb2712 --- /dev/null +++ b/clive/__private/core/commands/delete_profile.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from clive.__private.core.commands.abc.command import Command +from clive.__private.core.commands.lock import Lock +from clive.__private.core.profile import Profile + +if TYPE_CHECKING: + from beekeepy import AsyncSession + + +@dataclass(kw_only=True) +class DeleteProfile(Command): + """Delete profile and lock if it was unlocked.""" + + profile_name_to_delete: str + profile_name_currently_unlocked: str | None + session: AsyncSession | None + force: bool = False + + async def _execute(self) -> None: + Profile.delete_by_name(self.profile_name_to_delete, force=self.force) + if self.profile_name_to_delete == self.profile_name_currently_unlocked: + assert self.session is not None, "Session must be provided to delete currently unlocked profile" + await Lock(session=self.session).execute() diff --git a/clive/__private/core/profile.py b/clive/__private/core/profile.py index eb90814c737a281bff147d2983013f3e66177690..de204a7d31cf13de689e899effedb6df4ce7f9df 100644 --- a/clive/__private/core/profile.py +++ b/clive/__private/core/profile.py @@ -217,6 +217,10 @@ class Profile: def is_only_one_profile_saved(cls) -> bool: return len(cls.list_profiles()) == 1 + @classmethod + def is_profile_stored(cls, profile_name: str) -> bool: + return PersistentStorageService.is_profile_stored(profile_name) + @classmethod def create( # noqa: PLR0913 cls, diff --git a/tests/clive-local-tools/clive_local_tools/checkers/profile_checker.py b/tests/clive-local-tools/clive_local_tools/checkers/profile_checker.py index e68ee674cef53cc03dfbcf2233ef746898c43c24..b968946aebfba5a079d8a936571445012641f1c0 100644 --- a/tests/clive-local-tools/clive_local_tools/checkers/profile_checker.py +++ b/tests/clive-local-tools/clive_local_tools/checkers/profile_checker.py @@ -8,13 +8,12 @@ from clive.__private.core.commands.get_unlocked_encryption_wallet import GetUnlo from clive.__private.core.commands.get_unlocked_user_wallet import GetUnlockedUserWallet from clive.__private.core.commands.load_profile import LoadProfile from clive.__private.core.commands.unlock import Unlock +from clive.__private.core.profile import Profile from clive.__private.core.wallet_container import WalletContainer if TYPE_CHECKING: from collections.abc import AsyncGenerator, Iterable - from clive.__private.core.profile import Profile - class IsNotSet: """A class to represent a value that is not set.""" @@ -65,6 +64,16 @@ class ProfileChecker: unlocked_encryption_wallet=self._wallets.encryption_wallet, ).execute_with_result() + @classmethod + def assert_profile_is_stored(cls, profile_name: str, *, should_be_stored: bool = True, context: str = "") -> None: + is_stored = Profile.is_profile_stored(profile_name) + message = ( + "Profile is not stored while should be." if should_be_stored else "Profile is stored while should not be." + ) + if context: + message += f" Context: {context}" + assert is_stored == should_be_stored, message + async def assert_working_account(self, working_account: str | IsNotSet | None = None) -> None: """ Check working account of profile. diff --git a/tests/clive-local-tools/clive_local_tools/cli/checkers.py b/tests/clive-local-tools/clive_local_tools/cli/checkers.py index 948e88757d61778bb770d2e4da0d8cf9c586eb35..ebd52fb8ed2fdf17ef81bfe9707bac2540e238fc 100644 --- a/tests/clive-local-tools/clive_local_tools/cli/checkers.py +++ b/tests/clive-local-tools/clive_local_tools/cli/checkers.py @@ -5,9 +5,11 @@ from typing import TYPE_CHECKING import pytest from click.testing import Result +from clive.__private.cli.exceptions import CLINoProfileUnlockedError from clive.__private.core.formatters.humanize import humanize_bool from .cli_tester import CLITester +from .exceptions import CLITestCommandError if TYPE_CHECKING: from collections.abc import Callable @@ -18,8 +20,6 @@ if TYPE_CHECKING: from clive.__private.cli.types import AuthorityType from clive.__private.models.schemas import PublicKey - from .exceptions import CLITestCommandError - def assert_balances( context: CLITester | Result, @@ -197,3 +197,8 @@ def assert_unlocked_profile(context: CLITester | Result, profile_name: str) -> N output = _get_output(context, CLITester.show_profile) expected_output = f"Profile name: {profile_name}" assert_output_contains(expected_output, output, "show profile") + + +def assert_locked_profile(cli_tester: CLITester) -> None: + with pytest.raises(CLITestCommandError, match=CLINoProfileUnlockedError.MESSAGE): + cli_tester.show_profile() diff --git a/tests/functional/cli/configure/test_configure_profile_delete.py b/tests/functional/cli/configure/test_configure_profile_delete.py new file mode 100644 index 0000000000000000000000000000000000000000..a059f551f8de1566a9a6ba4245df939ea674b7dc --- /dev/null +++ b/tests/functional/cli/configure/test_configure_profile_delete.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from clive_local_tools.checkers.profile_checker import ProfileChecker +from clive_local_tools.cli.checkers import assert_locked_profile, assert_unlocked_profile +from clive_local_tools.data.constants import WORKING_ACCOUNT_PASSWORD +from clive_local_tools.testnet_block_log import ALT_WORKING_ACCOUNT1_NAME, WORKING_ACCOUNT_NAME + +if TYPE_CHECKING: + from clive_local_tools.cli.cli_tester import CLITester + + +async def test_remove_profile_when_locked(cli_tester_locked: CLITester) -> None: + # ACT + cli_tester_locked.configure_profile_delete(profile_name=WORKING_ACCOUNT_NAME) + + # ASSERT + ProfileChecker.assert_profile_is_stored(WORKING_ACCOUNT_NAME, should_be_stored=False) + + +async def test_remove_currently_unlocked_profile(cli_tester: CLITester) -> None: + # ARRANGE + # profile would be saved in world_cm fixture but after deletion it is locked and saving gives errors + cli_tester.world.profile.skip_saving() + + # ACT + cli_tester.configure_profile_delete(profile_name=WORKING_ACCOUNT_NAME) + + # ASSERT + ProfileChecker.assert_profile_is_stored(WORKING_ACCOUNT_NAME, should_be_stored=False) + assert_locked_profile(cli_tester) + + +async def test_remove_other_profile_when_unlocked(cli_tester_locked_with_second_profile: CLITester) -> None: + # ARRANGE + cli_tester = cli_tester_locked_with_second_profile + cli_tester.unlock(profile_name=WORKING_ACCOUNT_NAME, password_stdin=WORKING_ACCOUNT_PASSWORD) + + # ACT + cli_tester.configure_profile_delete(profile_name=ALT_WORKING_ACCOUNT1_NAME) + + # ASSERT + ProfileChecker.assert_profile_is_stored(ALT_WORKING_ACCOUNT1_NAME, should_be_stored=False) + assert_unlocked_profile(cli_tester, WORKING_ACCOUNT_NAME) + + +async def test_remove_profile_session_token_not_set(cli_tester_without_session_token: CLITester) -> None: + # ACT + cli_tester_without_session_token.configure_profile_delete(profile_name=WORKING_ACCOUNT_NAME) + + # ASSERT + ProfileChecker.assert_profile_is_stored(WORKING_ACCOUNT_NAME, should_be_stored=False) diff --git a/tests/functional/cli/configure/test_configure_profile_delete_without_remote_address.py b/tests/functional/cli/configure/test_configure_profile_delete_without_remote_address.py new file mode 100644 index 0000000000000000000000000000000000000000..c52294c86fae2e2fd92caf413d0a04e5bd4b4734 --- /dev/null +++ b/tests/functional/cli/configure/test_configure_profile_delete_without_remote_address.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from beekeepy import AsyncBeekeeper +from beekeepy.exceptions.common import InvalidatedStateByClosingBeekeeperError + +from clive.__private.settings import safe_settings +from clive_local_tools.checkers.profile_checker import ProfileChecker +from clive_local_tools.testnet_block_log import WORKING_ACCOUNT_NAME + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator + + from clive_local_tools.cli.cli_tester import CLITester + + +@pytest.fixture +async def beekeeper_local() -> AsyncGenerator[AsyncBeekeeper]: + """We need to handle error on double teardown of beekeeper.""" + with pytest.raises( + InvalidatedStateByClosingBeekeeperError + ): # we can use fixture beekeeper_local from conftest after issue #19 in beekeepey is resolved + async with await AsyncBeekeeper.factory( + settings=safe_settings.beekeeper.settings_local_factory() + ) as beekeeper_cm: + yield beekeeper_cm + + +async def test_remove_profile_remote_address_not_set( + beekeeper_local: AsyncBeekeeper, cli_tester_without_remote_address: CLITester +) -> None: + # ARRANGE + # profile can't be saved without beekeeper because there would be error during encryption + cli_tester_without_remote_address.world.profile.skip_saving() + # we call teardown here because configure_profile_delete runs second beekeeper when no remote_address is set + beekeeper_local.teardown() + + # ACT + cli_tester_without_remote_address.configure_profile_delete(profile_name=WORKING_ACCOUNT_NAME) + + # ASSERT + ProfileChecker.assert_profile_is_stored(WORKING_ACCOUNT_NAME, should_be_stored=False) diff --git a/tests/functional/cli/conftest.py b/tests/functional/cli/conftest.py index b337e9ef9af6913b6812b57da00fed984e784fdd..4b1421704f2eafa2a294ce72c735d019738b0648 100644 --- a/tests/functional/cli/conftest.py +++ b/tests/functional/cli/conftest.py @@ -14,10 +14,14 @@ from clive.__private.logger import logger from clive.__private.settings import safe_settings from clive_local_tools.cli.cli_tester import CLITester from clive_local_tools.data.constants import ( + ALT_WORKING_ACCOUNT1_KEY_ALIAS, + ALT_WORKING_ACCOUNT1_PASSWORD, WORKING_ACCOUNT_KEY_ALIAS, WORKING_ACCOUNT_PASSWORD, ) from clive_local_tools.testnet_block_log import ( + ALT_WORKING_ACCOUNT1_DATA, + ALT_WORKING_ACCOUNT1_NAME, KNOWN_ACCOUNTS, WATCHED_ACCOUNTS_NAMES, WORKING_ACCOUNT_DATA, @@ -124,6 +128,31 @@ async def cli_tester_locked(cli_tester: CLITester) -> CLITester: return cli_tester +@pytest.fixture +async def cli_tester_locked_with_second_profile(cli_tester_locked: CLITester) -> CLITester: + async with World() as world_cm: + await world_cm.create_new_profile_with_wallets(ALT_WORKING_ACCOUNT1_NAME, ALT_WORKING_ACCOUNT1_PASSWORD) + world_cm.profile.keys.add_to_import( + PrivateKeyAliased( + value=ALT_WORKING_ACCOUNT1_DATA.account.private_key, alias=f"{ALT_WORKING_ACCOUNT1_KEY_ALIAS}" + ) + ) + await world_cm.commands.sync_data_with_beekeeper() + await world_cm.commands.save_profile() # required for saving imported keys aliases + await world_cm.commands.lock() + world_cm.profile.skip_saving() # cannot save profile when it is locked because encryption is not possible + return cli_tester_locked + + +@pytest.fixture +async def cli_tester_without_remote_address( + beekeeper_remote_address_env_context_factory: EnvContextFactory, + cli_tester: CLITester, +) -> AsyncGenerator[CLITester]: + with beekeeper_remote_address_env_context_factory(None): + yield cli_tester + + @pytest.fixture async def cli_tester_without_session_token( beekeeper_session_token_env_context_factory: EnvContextFactory, diff --git a/tests/functional/cli/test_locking.py b/tests/functional/cli/test_locking.py index 338e1735893a23db161792af7a944835696763f5..123453e0d31637299e318a9149197d8d829b34e4 100644 --- a/tests/functional/cli/test_locking.py +++ b/tests/functional/cli/test_locking.py @@ -5,52 +5,20 @@ from typing import TYPE_CHECKING import pytest from clive.__private.cli.exceptions import CLIBeekeeperRemoteAddressIsNotSetError, CLIBeekeeperSessionTokenNotSetError -from clive.__private.core.keys.keys import PrivateKeyAliased -from clive.__private.core.world import World from clive_local_tools.checkers.wallet_checkers import assert_wallet_unlocked, assert_wallets_locked from clive_local_tools.cli.checkers import assert_unlocked_profile from clive_local_tools.cli.exceptions import CLITestCommandError from clive_local_tools.data.constants import ( - ALT_WORKING_ACCOUNT1_KEY_ALIAS, ALT_WORKING_ACCOUNT1_PASSWORD, WORKING_ACCOUNT_PASSWORD, ) from clive_local_tools.testnet_block_log import ( - ALT_WORKING_ACCOUNT1_DATA, ALT_WORKING_ACCOUNT1_NAME, WORKING_ACCOUNT_NAME, ) if TYPE_CHECKING: - from collections.abc import AsyncGenerator - from clive_local_tools.cli.cli_tester import CLITester - from clive_local_tools.types import EnvContextFactory - - -@pytest.fixture -async def cli_tester_locked_with_second_profile(cli_tester_locked: CLITester) -> CLITester: - async with World() as world_cm: - await world_cm.create_new_profile_with_wallets(ALT_WORKING_ACCOUNT1_NAME, ALT_WORKING_ACCOUNT1_PASSWORD) - world_cm.profile.keys.add_to_import( - PrivateKeyAliased( - value=ALT_WORKING_ACCOUNT1_DATA.account.private_key, alias=f"{ALT_WORKING_ACCOUNT1_KEY_ALIAS}" - ) - ) - await world_cm.commands.sync_data_with_beekeeper() - await world_cm.commands.save_profile() # required for saving imported keys aliases - await world_cm.commands.lock() - world_cm.profile.skip_saving() # cannot save profile when it is locked because encryption is not possible - return cli_tester_locked - - -@pytest.fixture -async def cli_tester_without_remote_address( - beekeeper_remote_address_env_context_factory: EnvContextFactory, - cli_tester: CLITester, -) -> AsyncGenerator[CLITester]: - with beekeeper_remote_address_env_context_factory(None): - yield cli_tester async def test_negative_lock_without_remote_address(cli_tester_without_remote_address: CLITester) -> None: