Skip to content

Commit 9f0549f

Browse files
authored
Feature/profile deletion (#30)
* -Add validation logic around default profile. -Add help message for when default is not set (or points to an invalid/missing entry). * - update base cursor store to allow row deletions. - add clean() method to FileEventCursorStore to remove a profile's row(s). * add delete_password() to password module * add delete_profile() to ConfigAccessor * handle resetting default_profile internal value to __DEFAULT__ when the default profile is deleted. * add delete_profile() method to profile module that clears config/password/cursor_store * - add `delete_profile()`, `delete_all_profiles()` functions - add `delete`, and `delete_all` Commands and register them - fix typo in `_load_profile_create_descriptions()` * merge error * fix more merge errors * fix the merge error fix error * address PR feedback * update changelog * remove unused DEFAULT_PROFILE_IS_COMPLETE config item. * attempting some tests * a working delete_profile_clears_checkpoint test * fix test for py3.5 compat * formatting
1 parent bbc128c commit 9f0549f

10 files changed

Lines changed: 174 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,14 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta
1818
- `--filename` flag renamed to `--file-name`.
1919
- `--filepath` flag renamed to `--file-path`.
2020
- `--processOwner` flag renamed to `--process-owner`
21+
- Default profile validation logic added to prevent confusing error states.
2122

2223
### Added
2324

2425
- `code42 profile update` command.
2526
- `code42 profile create` command.
27+
- `code42 profile delete` command.
28+
- `code42 profile delete-all` command.
2629
- `code42 detection-lists high-risk` commands:
2730
- `bulk` with subcommands:
2831
- `add`: that takes a csv file of users.

src/code42cli/cmds/profile.py

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,21 @@ def load_subcommands():
5959
arg_customizer=_load_profile_update_descriptions,
6060
)
6161

62-
return [show, list_all, use, reset_pw, create, update]
62+
delete = Command(
63+
u"delete",
64+
"Deletes a profile and its stored password (if any).",
65+
u"{} {}".format(usage_prefix, u"delete <profile-name>"),
66+
handler=delete_profile,
67+
)
68+
69+
delete_all = Command(
70+
u"delete-all",
71+
u"Deletes all profiles and saved passwords (if any).",
72+
u"{} {}".format(usage_prefix, u"delete-all"),
73+
handler=delete_all_profiles,
74+
)
75+
76+
return [show, list_all, use, reset_pw, create, update, delete, delete_all]
6377

6478

6579
def show_profile(name=None):
@@ -117,6 +131,31 @@ def use_profile(profile):
117131
cliprofile.switch_default_profile(profile)
118132

119133

134+
def delete_profile(name):
135+
if cliprofile.is_default_profile(name):
136+
print(u"\n{} is currently the default profile!".format(name))
137+
if not does_user_agree(
138+
u"\nDeleting this profile will also delete any stored passwords and checkpoints. Are you sure? (y/n): "
139+
):
140+
return
141+
cliprofile.delete_profile(name)
142+
143+
144+
def delete_all_profiles():
145+
existing_profiles = cliprofile.get_all_profiles()
146+
if existing_profiles:
147+
print(u"\nAre you sure you want to delete the following profiles?")
148+
for profile in existing_profiles:
149+
print(u"\t{}".format(profile.name))
150+
if does_user_agree(
151+
u"\nThis will also delete any stored passwords and checkpoints. (y/n): "
152+
):
153+
for profile in existing_profiles:
154+
cliprofile.delete_profile(profile.name)
155+
else:
156+
print(u"\nNo profiles exist. Nothing to delete.")
157+
158+
120159
def _load_optional_profile_description(argument_collection):
121160
profile = argument_collection.arg_configs[u"name"]
122161
profile.add_short_option_name(u"-n")

src/code42cli/cmds/shared/cursor_store.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,13 @@ def _set(self, column_name, new_value, primary_key):
3333
with self._connection as conn:
3434
conn.execute(query, (new_value, primary_key))
3535

36+
def _delete(self, primary_key):
37+
query = u"DELETE FROM {0} WHERE {1}=?".format(
38+
self._table_name, self._PRIMARY_KEY_COLUMN_NAME
39+
)
40+
with self._connection as conn:
41+
conn.execute(query, (primary_key,))
42+
3643
def _row_exists(self, primary_key):
3744
query = u"SELECT * FROM {0} WHERE {1}=?"
3845
query = query.format(self._table_name, self._PRIMARY_KEY_COLUMN_NAME)
@@ -86,6 +93,10 @@ def replace_stored_insertion_timestamp(self, new_insertion_timestamp):
8693
primary_key=self._primary_key,
8794
)
8895

96+
def clean(self):
97+
"""Removes profile cursor data from store."""
98+
self._delete(self._primary_key)
99+
89100
def _init_table(self):
90101
columns = u"{0}, {1}".format(self._PRIMARY_KEY_COLUMN_NAME, _INSERTION_TIMESTAMP_FIELD_NAME)
91102
create_table_query = u"CREATE TABLE {0} ({1})".format(self._table_name, columns)
@@ -96,3 +107,7 @@ def _insert_new_row(self):
96107
insert_query = u"INSERT INTO {0} VALUES(?, null)".format(self._table_name)
97108
with self._connection as conn:
98109
conn.execute(insert_query, (self._primary_key,))
110+
111+
112+
def get_file_event_cursor_store(profile_name):
113+
return FileEventCursorStore(profile_name)

src/code42cli/config.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ class ConfigAccessor(object):
1818
AUTHORITY_KEY = u"c42_authority_url"
1919
USERNAME_KEY = u"c42_username"
2020
IGNORE_SSL_ERRORS_KEY = u"ignore-ssl-errors"
21-
DEFAULT_PROFILE_IS_COMPLETE = u"default_profile_is_complete"
2221
DEFAULT_PROFILE = u"default_profile"
2322
_INTERNAL_SECTION = u"Internal"
2423

@@ -81,6 +80,15 @@ def switch_default_profile(self, new_default_name):
8180
self._save()
8281
print(u"{} has been set as the default profile.".format(new_default_name))
8382

83+
def delete_profile(self, name):
84+
"""Deletes a profile."""
85+
if self.get_profile(name) is None:
86+
raise NoConfigProfileError()
87+
self.parser.remove_section(name)
88+
if name == self._default_profile_name:
89+
self._internal[self.DEFAULT_PROFILE] = self.DEFAULT_VALUE
90+
self._save()
91+
8492
def _set_authority_url(self, new_value, profile):
8593
profile[self.AUTHORITY_KEY] = new_value.strip()
8694

@@ -112,7 +120,6 @@ def _get_profile_names(self):
112120
def _create_internal_section(self):
113121
self.parser.add_section(self._INTERNAL_SECTION)
114122
self.parser[self._INTERNAL_SECTION] = {}
115-
self.parser[self._INTERNAL_SECTION][self.DEFAULT_PROFILE_IS_COMPLETE] = str(False)
116123
self.parser[self._INTERNAL_SECTION][self.DEFAULT_PROFILE] = self.DEFAULT_VALUE
117124

118125
def _create_profile_section(self, name):

src/code42cli/password.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@ def set_password(profile, new_password):
2929
keyring.set_password(service_name, profile.username, new_password)
3030

3131

32+
def delete_password(profile):
33+
"""Deletes password for the given profile name."""
34+
service_name = _get_keyring_service_name(profile.name)
35+
keyring.delete_password(service_name, profile.username)
36+
37+
3238
def _get_keyring_service_name(profile_name):
3339
return u"{}::{}".format(PRODUCT_NAME, profile_name)
3440

src/code42cli/profile.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import code42cli.password as password
2+
from code42cli.cmds.shared.cursor_store import get_file_event_cursor_store
23
from code42cli.config import ConfigAccessor, config_accessor, NoConfigProfileError
34
from code42cli.util import (
45
print_error,
@@ -70,6 +71,12 @@ def default_profile_exists():
7071
return False
7172

7273

74+
def is_default_profile(name):
75+
if default_profile_exists():
76+
default = get_profile()
77+
return name == default.name
78+
79+
7380
def validate_default_profile():
7481
if not default_profile_exists():
7582
existing_profiles = get_all_profiles()
@@ -100,6 +107,15 @@ def create_profile(name, server, username, ignore_ssl_errors):
100107
config_accessor.create_profile(name, server, username, ignore_ssl_errors)
101108

102109

110+
def delete_profile(profile_name):
111+
profile = _get_profile(profile_name)
112+
if password.get_stored_password(profile) is not None:
113+
password.delete_password(profile)
114+
cursor_store = get_file_event_cursor_store(profile_name)
115+
cursor_store.clean()
116+
config_accessor.delete_profile(profile_name)
117+
118+
103119
def update_profile(name, server, username, ignore_ssl_errors):
104120
config_accessor.update_profile(name, server, username, ignore_ssl_errors)
105121

tests/cmds/securitydata/test_cursor_store.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,3 +79,15 @@ def test_replace_stored_insertion_timestamp_executes_query_with_expected_primary
7979
with store._connection as conn:
8080
actual = conn.execute.call_args[0][1][0]
8181
assert actual == new_insertion_timestamp
82+
83+
def test_clean_executes_query_with_expected_primary_key(self, sqlite_connection):
84+
profile_name = "Profile"
85+
store = FileEventCursorStore(profile_name, self.MOCK_TEST_DB_NAME)
86+
store.clean()
87+
with store._connection as conn:
88+
expected_query = "DELETE FROM {0} WHERE {1}=?".format(
89+
store._table_name, store._PRIMARY_KEY_COLUMN_NAME
90+
)
91+
actual_query, pk = conn.execute.call_args[0]
92+
assert expected_query == actual_query
93+
assert pk == (profile_name,)

tests/cmds/test_profile.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,51 @@ def test_update_profile_if_user_agrees_and_valid_connection_sets_password(
163163
mock_cliprofile_namespace.set_password.assert_called_once_with("newpassword", mocker.ANY)
164164

165165

166+
def test_delete_profile_warns_if_deleting_default(
167+
capsys, user_agreement, mock_cliprofile_namespace
168+
):
169+
mock_cliprofile_namespace.is_default_profile.return_value = True
170+
profilecmd.delete_profile("mockdefault")
171+
capture = capsys.readouterr()
172+
assert "mockdefault is currently the default profile!" in capture.out
173+
174+
175+
def test_delete_all_warns_if_profiles_exist(capsys, user_agreement, mock_cliprofile_namespace):
176+
mock_cliprofile_namespace.get_all_profiles.return_value = [
177+
create_mock_profile("test1"),
178+
create_mock_profile("test2"),
179+
]
180+
profilecmd.delete_all_profiles()
181+
capture = capsys.readouterr()
182+
assert "Are you sure you want to delete the following profiles?" in capture.out
183+
assert "test1" in capture.out
184+
assert "test2" in capture.out
185+
186+
187+
def test_delete_profile_does_nothing_if_user_doesnt_agree(
188+
user_disagreement, mock_cliprofile_namespace
189+
):
190+
profilecmd.delete_profile("mockprofile")
191+
assert mock_cliprofile_namespace.delete_profile.call_count == 0
192+
193+
194+
def test_delete_all_profiles_does_nothing_if_user_doesnt_agree(
195+
user_disagreement, mock_cliprofile_namespace
196+
):
197+
profilecmd.delete_all_profiles()
198+
assert mock_cliprofile_namespace.delete_profile.call_count == 0
199+
200+
201+
def test_delete_all_deletes_all_existing_profiles(user_agreement, mock_cliprofile_namespace):
202+
mock_cliprofile_namespace.get_all_profiles.return_value = [
203+
create_mock_profile("test1"),
204+
create_mock_profile("test2"),
205+
]
206+
profilecmd.delete_all_profiles()
207+
mock_cliprofile_namespace.delete_profile.assert_any_call("test1")
208+
mock_cliprofile_namespace.delete_profile.assert_any_call("test2")
209+
210+
166211
def test_prompt_for_password_reset_if_credentials_valid_password_saved(
167212
mocker, user_agreement, mock_verify, mock_cliprofile_namespace
168213
):

tests/test_config.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,6 @@ def create_internal_object(is_complete, default_profile_name=None):
7171
default_profile_name = default_profile_name or ConfigAccessor.DEFAULT_VALUE
7272
internal_dict = {
7373
ConfigAccessor.DEFAULT_PROFILE: default_profile_name,
74-
ConfigAccessor.DEFAULT_PROFILE_IS_COMPLETE: is_complete,
7574
}
7675
internal_section = MockSection(_INTERNAL, internal_dict)
7776

@@ -164,7 +163,6 @@ def test_create_profile_when_no_default_profile_sets_default(
164163
):
165164
mock_profile = create_mock_profile_object(_TEST_PROFILE_NAME, None, None)
166165
mock_internal = create_internal_object(False)
167-
mock_internal["default_profile_is_complete"] = "False"
168166
setup_parser_one_profile(mock_internal, mock_internal, config_parser_for_create)
169167
accessor = ConfigAccessor(config_parser_for_create)
170168
accessor.switch_default_profile = mocker.MagicMock()
@@ -187,7 +185,6 @@ def test_create_profile_when_has_default_profile_does_not_set_default(
187185
def test_create_profile_when_not_existing_saves(self, config_parser_for_create, mock_saver):
188186
create_mock_profile_object(_TEST_PROFILE_NAME, None, None)
189187
mock_internal = create_internal_object(False)
190-
mock_internal["default_profile_is_complete"] = "False"
191188
setup_parser_one_profile(mock_internal, mock_internal, config_parser_for_create)
192189
accessor = ConfigAccessor(config_parser_for_create)
193190

@@ -198,7 +195,6 @@ def test_create_profile_when_not_existing_outputs_confirmation(
198195
self, capsys, config_parser_for_create, mock_saver
199196
):
200197
mock_internal = create_internal_object(False)
201-
mock_internal["default_profile_is_complete"] = "False"
202198
setup_parser_one_profile(mock_internal, mock_internal, config_parser_for_create)
203199
accessor = ConfigAccessor(config_parser_for_create)
204200

tests/test_profile.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from code42cli import PRODUCT_NAME
44
import code42cli.profile as cliprofile
55
from code42cli.config import ConfigAccessor, NoConfigProfileError
6+
from code42cli.cmds.shared.cursor_store import FileEventCursorStore
67
from .conftest import MockSection, create_mock_profile
78

89

@@ -23,6 +24,11 @@ def password_getter(mocker):
2324
return mocker.patch("{}.password.get_stored_password".format(PRODUCT_NAME))
2425

2526

27+
@pytest.fixture
28+
def password_deleter(mocker):
29+
return mocker.patch("code42cli.password.delete_password")
30+
31+
2632
class TestCode42Profile(object):
2733
def test_get_password_when_is_none_returns_password_from_getpass(self, mocker, password_getter):
2834
password_getter.return_value = None
@@ -182,3 +188,25 @@ def test_set_password_uses_expected_password(config_accessor, password_setter):
182188
test_profile = "testprofilename"
183189
cliprofile.set_password("newpassword", test_profile)
184190
assert password_setter.call_args[0][1] == "newpassword"
191+
192+
193+
def test_delete_profile_deletes_password_if_exists(
194+
config_accessor, mocker, password_getter, password_deleter
195+
):
196+
profile = create_mock_profile("deleteme")
197+
mock_get_profile = mocker.patch("code42cli.profile._get_profile")
198+
mock_get_profile.return_value = profile
199+
password_getter.return_value = "i_exist"
200+
cliprofile.delete_profile("deleteme")
201+
password_deleter.assert_called_once_with(profile)
202+
203+
204+
def test_delete_profile_clears_checkpoint(config_accessor, mocker):
205+
profile = create_mock_profile("deleteme")
206+
mock_get_profile = mocker.patch("code42cli.profile._get_profile")
207+
mock_get_profile.return_value = profile
208+
store = mocker.MagicMock(spec=FileEventCursorStore)
209+
mock_get_cursor_store = mocker.patch("code42cli.profile.get_file_event_cursor_store")
210+
mock_get_cursor_store.return_value = store
211+
cliprofile.delete_profile("deleteme")
212+
assert store.clean.call_count == 1

0 commit comments

Comments
 (0)