diff --git a/CHANGELOG.md b/CHANGELOG.md index 23608d6a..f1337167 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,13 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta ### Added +- Support for Code42 API clients. + - You can create a new profile with API client authentication using `code42 profile create-api-client` + - Or, update your existing profile to use API clients with `code42 update --api-client-id --secret ` +- When using API client authentication, changes to the following `legal-hold` commands: + - `code42 legal-hold list` - Change in response shape. + - `code42 legal-hold show` - Change in response shape. + - `code42 legal-hold search-events` - **Not available.** - New commands to view details for user risk profiles: - `code42 users list-risk-profiles` - `code42 users show-risk-profile` diff --git a/docs/userguides/profile.md b/docs/userguides/profile.md index d1e248d4..da9dcac4 100644 --- a/docs/userguides/profile.md +++ b/docs/userguides/profile.md @@ -3,7 +3,9 @@ Use the [code42 profile](../commands/profile.md) set of commands to establish the Code42 environment you're working within and your user information. -First, create your profile: +## User token authentication + +Use the following command to create your profile with user token authentication: ```bash code42 profile create --name MY_FIRST_PROFILE --server example.authority.com --username security.admin@example.com ``` @@ -15,6 +17,19 @@ Your password is not shown when you do `code42 profile show`. However, `code42 p password exists for your profile. If you do not set a password, you will be securely prompted to enter a password each time you run a command. +## API client authentication + +Once you've generated an API Client in your Code42 console, use the following command to create your profile with API client authentication: +```bash +code42 profile create-api-client --name MY_API_CLIENT_PROFILE --server example.authority.com --api-client-id "key-42" --secret "code42%api%client%secret" +``` + +```{eval-rst} +.. note:: Remember to wrap your API client secret with single quotes to avoid issues with bash expansion and special characters. +``` + +## View profiles + You can add multiple profiles with different names and the change the default profile with the `use` command: ```bash diff --git a/docs/userguides/v2apis.md b/docs/userguides/v2apis.md index c429bbc9..59366a15 100644 --- a/docs/userguides/v2apis.md +++ b/docs/userguides/v2apis.md @@ -11,6 +11,7 @@ V1 file event APIs were marked deprecated in May 2022 and will be no longer be s Use the `--use-v2-file-events True` option with the `code42 profile create` or `code42 profile update` commands to enable your code42 CLI profile to use the latest V2 file event data model. Use `code42 profile show` to check the status of this setting on your profile: + ```bash % code42 profile update --use-v2-file-events True diff --git a/setup.py b/setup.py index 38d45afc..c123e5b6 100644 --- a/setup.py +++ b/setup.py @@ -39,7 +39,7 @@ "keyrings.alt==3.2.0", "ipython==7.16.3", "pandas>=1.1.3", - "py42>=1.24.0", + "py42>=1.26.0", ], extras_require={ "dev": [ diff --git a/src/code42cli/cmds/legal_hold.py b/src/code42cli/cmds/legal_hold.py index b8fe0234..df17d5c6 100644 --- a/src/code42cli/cmds/legal_hold.py +++ b/src/code42cli/cmds/legal_hold.py @@ -4,6 +4,7 @@ import click from click import echo +from click import style from code42cli.bulk import generate_template_cmd_factory from code42cli.bulk import run_bulk_process @@ -89,7 +90,7 @@ def add_user(state, matter_id, username): @sdk_options() def remove_user(state, matter_id, username): """Release a custodian from a legal hold matter.""" - _remove_user_from_legal_hold(state.sdk, matter_id, username) + _remove_user_from_legal_hold(state, state.sdk, matter_id, username) @legal_hold.command("list") @@ -98,7 +99,7 @@ def remove_user(state, matter_id, username): def _list(state, format=None): """Fetch existing legal hold matters.""" formatter = OutputFormatter(format, _MATTER_KEYS_MAP) - matters = _get_all_active_matters(state.sdk) + matters = _get_all_active_matters(state) if matters: formatter.echo_formatted_list(matters) @@ -120,14 +121,21 @@ def _list(state, format=None): def show(state, matter_id, include_inactive=False, include_policy=False): """Display details of a given legal hold matter.""" matter = _check_matter_is_accessible(state.sdk, matter_id) - matter["creator_username"] = matter["creator"]["username"] + + if state.profile.api_client_auth == "True": + try: + matter["creator_username"] = matter["creator"]["user"]["email"] + except KeyError: + pass + else: + matter["creator_username"] = matter["creator"]["username"] matter = json.loads(matter.text) # if `active` is None then all matters (whether active or inactive) are returned. True returns # only those that are active. active = None if include_inactive else True memberships = _get_legal_hold_memberships_for_matter( - state.sdk, matter_id, active=active + state, state.sdk, matter_id, active=active ) active_usernames = [ member["user"]["username"] for member in memberships if member["active"] @@ -161,6 +169,15 @@ def show(state, matter_id, include_inactive=False, include_policy=False): @sdk_options() def search_events(state, matter_id, event_type, begin, end, format): """Tools for getting legal hold event data.""" + if state.profile.api_client_auth == "True": + echo( + style( + "WARNING: This method is unavailable with API Client Authentication.", + fg="red", + ), + err=True, + ) + formatter = OutputFormatter(format, _EVENT_KEYS_MAP) events = _get_all_events(state.sdk, matter_id, begin, end) if event_type: @@ -214,7 +231,7 @@ def remove(state, csv_rows): sdk = state.sdk def handle_row(matter_id, username): - _remove_user_from_legal_hold(sdk, matter_id, username) + _remove_user_from_legal_hold(state, sdk, matter_id, username) run_bulk_process( handle_row, csv_rows, progress_label="Removing users from legal hold:" @@ -227,11 +244,20 @@ def _add_user_to_legal_hold(sdk, matter_id, username): sdk.legalhold.add_to_matter(user_id, matter_id) -def _remove_user_from_legal_hold(sdk, matter_id, username): +def _remove_user_from_legal_hold(state, sdk, matter_id, username): _check_matter_is_accessible(sdk, matter_id) - membership_id = _get_legal_hold_membership_id_for_user_and_matter( - sdk, username, matter_id + + user_id = get_user_id(sdk, username) + memberships = _get_legal_hold_memberships_for_matter( + state, sdk, matter_id, active=True ) + membership_id = None + for member in memberships: + if member["user"]["userUid"] == user_id: + membership_id = member["legalHoldMembershipUid"] + if not membership_id: + raise UserNotInLegalHoldError(username, matter_id) + sdk.legalhold.remove_from_matter(membership_id) @@ -241,37 +267,41 @@ def _get_and_print_preservation_policy(sdk, policy_uid): echo(pformat(json.loads(preservation_policy.text))) -def _get_legal_hold_membership_id_for_user_and_matter(sdk, username, matter_id): - user_id = get_user_id(sdk, username) - memberships = _get_legal_hold_memberships_for_matter(sdk, matter_id, active=True) - for member in memberships: - if member["user"]["userUid"] == user_id: - return member["legalHoldMembershipUid"] - raise UserNotInLegalHoldError(username, matter_id) - - -def _get_legal_hold_memberships_for_matter(sdk, matter_id, active=True): +def _get_legal_hold_memberships_for_matter(state, sdk, matter_id, active=True): memberships_generator = sdk.legalhold.get_all_matter_custodians( - legal_hold_uid=matter_id, active=active + matter_id, active=active ) - memberships = [ - member - for page in memberships_generator - for member in page["legalHoldMemberships"] - ] + if state.profile.api_client_auth == "True": + memberships = [member for page in memberships_generator for member in page] + else: + memberships = [ + member + for page in memberships_generator + for member in page["legalHoldMemberships"] + ] return memberships -def _get_all_active_matters(sdk): - matters_generator = sdk.legalhold.get_all_matters() - matters = [ - matter - for page in matters_generator - for matter in page["legalHolds"] - if matter["active"] - ] - for matter in matters: - matter["creator_username"] = matter["creator"]["username"] +def _get_all_active_matters(state): + matters_generator = state.sdk.legalhold.get_all_matters() + if state.profile.api_client_auth == "True": + matters = [ + matter for page in matters_generator for matter in page if matter["active"] + ] + for matter in matters: + try: + matter["creator_username"] = matter["creator"]["user"]["email"] + except KeyError: + pass + else: + matters = [ + matter + for page in matters_generator + for matter in page["legalHolds"] + if matter["active"] + ] + for matter in matters: + matter["creator_username"] = matter["creator"]["username"] return matters diff --git a/src/code42cli/cmds/profile.py b/src/code42cli/cmds/profile.py index 35b7744f..b248501d 100644 --- a/src/code42cli/cmds/profile.py +++ b/src/code42cli/cmds/profile.py @@ -5,6 +5,7 @@ from click import secho import code42cli.profile as cliprofile +from code42cli.click_ext.options import incompatible_with from code42cli.click_ext.types import PromptChoice from code42cli.click_ext.types import TOTP from code42cli.errors import Code42CLIError @@ -58,12 +59,14 @@ def username_option(required=False): "-u", "--username", required=required, + cls=incompatible_with(["api_client_id", "secret"]), help="The username of the Code42 API user.", ) password_option = click.option( "--password", + cls=incompatible_with(["api_client_id", "secret"]), help="The password for the Code42 API user. If this option is omitted, interactive prompts " "will be used to obtain the password.", ) @@ -84,18 +87,44 @@ def username_option(required=False): ) +def api_client_id_option(required=False): + return click.option( + "--api-client-id", + required=required, + cls=incompatible_with(["username", "password", "totp"]), + help="The API client key for API client authentication. Used with the `--secret` option.", + ) + + +def secret_option(required=False): + return click.option( + "--secret", + required=required, + cls=incompatible_with(["username", "password", "totp"]), + help="The API secret for API client authentication. Used with the `--api-client` option.", + ) + + @profile.command() @profile_name_arg() def show(profile_name): """Print the details of a profile.""" c42profile = cliprofile.get_profile(profile_name) echo(f"\n{c42profile.name}:") - echo(f"\t* username = {c42profile.username}") + if c42profile.api_client_auth == "True": + echo(f"\t* api-client-id = {c42profile.username}") + else: + echo(f"\t* username = {c42profile.username}") echo(f"\t* authority url = {c42profile.authority_url}") echo(f"\t* ignore-ssl-errors = {c42profile.ignore_ssl_errors}") echo(f"\t* use-v2-file-events = {c42profile.use_v2_file_events}") - if cliprofile.get_stored_password(c42profile.name) is not None: - echo("\t* A password is set.") + echo(f"\t* api-client-auth-profile = {c42profile.api_client_auth}") + if c42profile.api_client_auth == "True": + if cliprofile.get_stored_password(c42profile.name) is not None: + echo("\t* The API client secret is set.") + else: + if cliprofile.get_stored_password(c42profile.name) is not None: + echo("\t* A password is set.") echo("") echo("") @@ -120,28 +149,75 @@ def create( debug, totp, ): - """Create profile settings. The first profile created will be the default.""" + """ + Create a profile with username/password authentication. + The first profile created will be the default. + """ cliprofile.create_profile( - name, server, username, disable_ssl_errors, use_v2_file_events + name, + server, + username, + disable_ssl_errors, + use_v2_file_events, + api_client_auth=False, ) - password = password or _prompt_for_password(name) + password = password or _prompt_for_password() if password: - _set_pw(name, password, debug, totp=totp) + _set_pw(name, password, debug, totp=totp, api_client=False) + echo(f"Successfully created profile '{name}'.") + + +@profile.command() +@name_option(required=True) +@server_option(required=True) +@api_client_id_option(required=True) +@secret_option(required=True) +@yes_option(hidden=True) +@disable_ssl_option +@use_v2_file_events_option +@debug_option +def create_api_client( + name, + server, + api_client_id, + secret, + disable_ssl_errors, + use_v2_file_events, + debug, +): + """ + Create a profile with Code42 API client authentication. + The first profile created will be the default. + """ + cliprofile.create_profile( + name, + server, + api_client_id, + disable_ssl_errors, + use_v2_file_events, + api_client_auth=True, + ) + _set_pw(name, secret, debug, totp=False, api_client=True) echo(f"Successfully created profile '{name}'.") @profile.command() @name_option() @server_option() +@api_client_id_option() +@secret_option() @username_option() @password_option @totp_option @disable_ssl_option @use_v2_file_events_option +@yes_option(hidden=True) @debug_option def update( name, server, + api_client_id, + secret, username, password, disable_ssl_errors, @@ -152,24 +228,102 @@ def update( """Update an existing profile.""" c42profile = cliprofile.get_profile(name) - if ( - not server - and not username - and not password - and disable_ssl_errors is None - and use_v2_file_events is None - ): - raise click.UsageError( - "Must provide at least one of `--username`, `--server`, `--password`, `--use-v2-file-events` or " - "`--disable-ssl-errors` when updating a profile." - ) - cliprofile.update_profile( - c42profile.name, server, username, disable_ssl_errors, use_v2_file_events - ) - if not password and not c42profile.has_stored_password: - password = _prompt_for_password(c42profile.name) - if password: - _set_pw(name, password, debug, totp=totp) + if c42profile.api_client_auth == "True": + if not any( + [ + server, + api_client_id, + secret, + disable_ssl_errors is not None, + use_v2_file_events is not None, + ] + ): + raise click.UsageError( + "Must provide at least one of `--server`, `--api-client-id`, `--secret`, `--use-v2-file-events` or " + "`--disable-ssl-errors` when updating an API client profile. " + "Provide both `--username` and `--password` options to switch this profile to username/password authentication." + ) + if (username and not password) or (password and not username): + raise click.UsageError( + "This profile currently uses API client authentication. " + "Please provide both the `--username` and `--password` options to update this profile to use username/password authentication." + ) + elif username and password: + if does_user_agree( + "You passed the `--username` and `--password options for a profile currently using Code42 API client authentication. " + "Are you sure you would like to update this profile to use username/password authentication? This will overwrite existing credentials. (y/n): " + ): + cliprofile.update_profile( + c42profile.name, + server, + username, + disable_ssl_errors, + use_v2_file_events, + api_client_auth=False, + ) + _set_pw(c42profile.name, password, debug, api_client=False) + else: + echo(f"Profile '{c42profile.name}` was not updated.") + return + else: + cliprofile.update_profile( + c42profile.name, + server, + api_client_id, + disable_ssl_errors, + use_v2_file_events, + ) + if secret: + _set_pw(c42profile.name, secret, debug, api_client=True) + + else: + if ( + not server + and not username + and not password + and disable_ssl_errors is None + and use_v2_file_events is None + ): + raise click.UsageError( + "Must provide at least one of `--server`, `--username`, `--password`, `--use-v2-file-events` or " + "`--disable-ssl-errors` when updating a username/password authenticated profile. " + "Provide both `--api-client-id` and `--secret` options to switch this profile to Code42 API client authentication." + ) + if (api_client_id and not secret) or (api_client_id and not secret): + raise click.UsageError( + "This profile currently uses username/password authentication. " + "Please provide both the `--api-client-id` and `--secret` options to update this profile to use Code42 API client authentication." + ) + elif api_client_id and secret: + if does_user_agree( + "You passed the `--api-client-id` and `--secret options for a profile currently using username/password authentication. " + "Are you sure you would like to update this profile to use Code42 API client authentication? This will overwrite existing credentials. (y/n): " + ): + cliprofile.update_profile( + c42profile.name, + server, + api_client_id, + disable_ssl_errors, + use_v2_file_events, + api_client_auth=True, + ) + _set_pw(c42profile.name, secret, debug, api_client=True) + else: + echo(f"Profile '{name}` was not updated.") + return + else: + cliprofile.update_profile( + c42profile.name, + server, + username, + disable_ssl_errors, + use_v2_file_events, + ) + if not password and not c42profile.has_stored_password: + password = _prompt_for_password() + + if password: + _set_pw(c42profile.name, password, debug, totp=totp) echo(f"Profile '{c42profile.name}' has been updated.") @@ -251,16 +405,22 @@ def delete_all(): echo("\nNo profiles exist. Nothing to delete.") -def _prompt_for_password(profile_name): +def _prompt_for_password(): if does_user_agree("Would you like to set a password? (y/n): "): password = getpass() return password -def _set_pw(profile_name, password, debug, totp=None): +def _set_pw(profile_name, password, debug, totp=None, api_client=False): c42profile = cliprofile.get_profile(profile_name) try: - create_sdk(c42profile, is_debug_mode=debug, password=password, totp=totp) + create_sdk( + c42profile, + is_debug_mode=debug, + password=password, + totp=totp, + api_client=api_client, + ) except Exception: secho("Password not stored!", bold=True) raise diff --git a/src/code42cli/config.py b/src/code42cli/config.py index 769cdbad..f989d8f2 100644 --- a/src/code42cli/config.py +++ b/src/code42cli/config.py @@ -20,6 +20,7 @@ class ConfigAccessor: USERNAME_KEY = "c42_username" IGNORE_SSL_ERRORS_KEY = "ignore-ssl-errors" USE_V2_FILE_EVENTS_KEY = "use-v2-file-events" + API_CLIENT_AUTH_KEY = "api-client-auth" DEFAULT_PROFILE = "default_profile" _INTERNAL_SECTION = "Internal" @@ -39,10 +40,10 @@ def get_profile(self, name=None): If the name does not exist or there is no existing profile, it will throw an exception. """ name = name or self._default_profile_name - if name not in self._get_sections() or name == self.DEFAULT_VALUE: + if name not in self.parser.sections() or name == self.DEFAULT_VALUE: name = name if name != self.DEFAULT_VALUE else None raise NoConfigProfileError(name) - return self._get_profile(name) + return self.parser[name] def get_all_profiles(self): """Returns all the available profiles.""" @@ -53,7 +54,13 @@ def get_all_profiles(self): return profiles def create_profile( - self, name, server, username, ignore_ssl_errors, use_v2_file_events + self, + name, + server, + username, + ignore_ssl_errors, + use_v2_file_events, + api_client_auth, ): """Creates a new profile if one does not already exist for that name.""" try: @@ -66,7 +73,12 @@ def create_profile( profile = self.get_profile(name) self.update_profile( - profile.name, server, username, ignore_ssl_errors, use_v2_file_events + profile.name, + server, + username, + ignore_ssl_errors, + use_v2_file_events, + api_client_auth, ) self._try_complete_setup(profile) @@ -77,16 +89,19 @@ def update_profile( username=None, ignore_ssl_errors=None, use_v2_file_events=None, + api_client_auth=None, ): profile = self.get_profile(name) if server: - self._set_authority_url(server, profile) + profile[self.AUTHORITY_KEY] = server.strip() if username: - self._set_username(username, profile) + profile[self.USERNAME_KEY] = username.strip() if ignore_ssl_errors is not None: - self._set_ignore_ssl_errors(ignore_ssl_errors, profile) + profile[self.IGNORE_SSL_ERRORS_KEY] = str(ignore_ssl_errors) if use_v2_file_events is not None: - self._set_use_v2_file_events(use_v2_file_events, profile) + profile[self.USE_V2_FILE_EVENTS_KEY] = str(use_v2_file_events) + if api_client_auth is not None: + profile[self.API_CLIENT_AUTH_KEY] = str(api_client_auth) self._save() def switch_default_profile(self, new_default_name): @@ -105,24 +120,6 @@ def delete_profile(self, name): self._internal[self.DEFAULT_PROFILE] = self.DEFAULT_VALUE self._save() - def _set_authority_url(self, new_value, profile): - profile[self.AUTHORITY_KEY] = new_value.strip() - - def _set_username(self, new_value, profile): - profile[self.USERNAME_KEY] = new_value.strip() - - def _set_ignore_ssl_errors(self, new_value, profile): - profile[self.IGNORE_SSL_ERRORS_KEY] = str(new_value) - - def _set_use_v2_file_events(self, new_value, profile): - profile[self.USE_V2_FILE_EVENTS_KEY] = str(new_value) - - def _get_sections(self): - return self.parser.sections() - - def _get_profile(self, name): - return self.parser[name] - @property def _internal(self): return self.parser[self._INTERNAL_SECTION] @@ -132,7 +129,7 @@ def _default_profile_name(self): return self._internal[self.DEFAULT_PROFILE] def _get_profile_names(self): - names = list(self._get_sections()) + names = list(self.parser.sections()) names.remove(self._INTERNAL_SECTION) return names @@ -148,6 +145,7 @@ def _create_profile_section(self, name): self.parser[name][self.USERNAME_KEY] = self.DEFAULT_VALUE self.parser[name][self.IGNORE_SSL_ERRORS_KEY] = str(False) self.parser[name][self.USE_V2_FILE_EVENTS_KEY] = str(False) + self.parser[name][self.API_CLIENT_AUTH_KEY] = str(False) def _save(self): with open(self.path, "w+", encoding="utf-8") as file: diff --git a/src/code42cli/options.py b/src/code42cli/options.py index 0724b075..7247d9b1 100644 --- a/src/code42cli/options.py +++ b/src/code42cli/options.py @@ -61,7 +61,12 @@ def profile(self, value): @property def sdk(self): if self._sdk is None: - self._sdk = create_sdk(self.profile, self.debug, totp=self.totp) + self._sdk = create_sdk( + self.profile, + self.debug, + totp=self.totp, + api_client=self.profile.api_client_auth == "True", + ) return self._sdk def set_assume_yes(self, param): diff --git a/src/code42cli/profile.py b/src/code42cli/profile.py index b3621d66..8a191e66 100644 --- a/src/code42cli/profile.py +++ b/src/code42cli/profile.py @@ -32,6 +32,10 @@ def ignore_ssl_errors(self): def use_v2_file_events(self): return self._profile.get(ConfigAccessor.USE_V2_FILE_EVENTS_KEY) + @property + def api_client_auth(self): + return self._profile.get(ConfigAccessor.API_CLIENT_AUTH_KEY) + @property def has_stored_password(self): stored_password = password.get_stored_password(self) @@ -103,11 +107,13 @@ def switch_default_profile(profile_name): config_accessor.switch_default_profile(profile.name) -def create_profile(name, server, username, ignore_ssl_errors, use_v2_file_events): +def create_profile( + name, server, username, ignore_ssl_errors, use_v2_file_events, api_client_auth +): if profile_exists(name): raise Code42CLIError(f"A profile named '{name}' already exists.") config_accessor.create_profile( - name, server, username, ignore_ssl_errors, use_v2_file_events + name, server, username, ignore_ssl_errors, use_v2_file_events, api_client_auth ) @@ -122,9 +128,11 @@ def delete_profile(profile_name): config_accessor.delete_profile(profile_name) -def update_profile(name, server, username, ignore_ssl_errors, use_v2_file_events): +def update_profile( + name, server, username, ignore_ssl_errors, use_v2_file_events, api_client_auth=None +): config_accessor.update_profile( - name, server, username, ignore_ssl_errors, use_v2_file_events + name, server, username, ignore_ssl_errors, use_v2_file_events, api_client_auth ) @@ -145,13 +153,25 @@ def set_password(new_password, profile_name=None): password.set_password(profile, new_password) -CREATE_PROFILE_HELP = "\nTo add a profile, use:\n{}".format( - style( - "\tcode42 profile create " - "--name " - "--server " - "--username \n", - bold=True, +CREATE_PROFILE_HELP = ( + "\nTo add a profile with username/password authentication, use:\n{}".format( + style( + "\tcode42 profile create " + "--name " + "--server " + "--username \n", + bold=True, + ) + ) + + "\nOr to add a profile with API client authentication, use:\n{}".format( + style( + "\tcode42 profile create-api-client " + "--name " + "--server " + "--api-client-id " + "--secret \n", + bold=True, + ) ) ) diff --git a/src/code42cli/sdk_client.py b/src/code42cli/sdk_client.py index aa3d7aef..7fd2b579 100644 --- a/src/code42cli/sdk_client.py +++ b/src/code42cli/sdk_client.py @@ -20,7 +20,7 @@ logger = get_main_cli_logger() -def create_sdk(profile, is_debug_mode, password=None, totp=None): +def create_sdk(profile, is_debug_mode, password=None, totp=None, api_client=False): proxy = environ.get("HTTPS_PROXY") or environ.get("https_proxy") if proxy: py42.settings.proxies = {"https": proxy} @@ -38,11 +38,17 @@ def create_sdk(profile, is_debug_mode, password=None, totp=None): ) py42.settings.verify_ssl_certs = False password = password or profile.get_password() - return _validate_connection(profile.authority_url, profile.username, password, totp) + return _validate_connection( + profile.authority_url, profile.username, password, totp, api_client + ) -def _validate_connection(authority_url, username, password, totp=None): +def _validate_connection( + authority_url, username, password, totp=None, api_client=False +): try: + if api_client: + return py42.sdk.from_api_client(authority_url, username, password) return py42.sdk.from_local_account(authority_url, username, password, totp=totp) except SSLError as err: logger.log_error(err) diff --git a/tests/cmds/test_profile.py b/tests/cmds/test_profile.py index 474a7609..859345d4 100644 --- a/tests/cmds/test_profile.py +++ b/tests/cmds/test_profile.py @@ -102,7 +102,7 @@ def test_create_profile_if_user_sets_password_is_created( ], ) mock_cliprofile_namespace.create_profile.assert_called_once_with( - "foo", "bar", "baz", True, None + "foo", "bar", "baz", True, None, api_client_auth=False ) @@ -128,7 +128,7 @@ def test_create_profile_if_user_does_not_set_password_is_created( ], ) mock_cliprofile_namespace.create_profile.assert_called_once_with( - "foo", "bar", "baz", True, True + "foo", "bar", "baz", True, True, api_client_auth=False ) @@ -247,6 +247,34 @@ def test_create_profile_outputs_confirmation( assert "Successfully created profile 'foo'." in result.output +def test_create_api_client_profile_with_api_client_id_and_secret_creates_profile( + runner, mock_cliprofile_namespace, valid_connection, profile +): + mock_cliprofile_namespace.profile_exists.return_value = False + mock_cliprofile_namespace.get_profile.return_value = profile + result = runner.invoke( + cli, + [ + "profile", + "create-api-client", + "-n", + "foo", + "-s", + "bar", + "--api-client-id", + "baz", + "--secret", + "fob", + "--disable-ssl-errors", + "True", + ], + ) + mock_cliprofile_namespace.create_profile.assert_called_once_with( + "foo", "bar", "baz", True, None, api_client_auth=True + ) + assert "Successfully created profile 'foo'." in result.output + + def test_update_profile_updates_existing_profile( runner, mock_cliprofile_namespace, user_agreement, valid_connection, profile ): @@ -401,13 +429,119 @@ def test_update_profile_when_given_zero_args_prints_error_message( mock_cliprofile_namespace.get_profile.return_value = profile result = runner.invoke(cli, ["profile", "update"]) expected = ( - "Must provide at least one of `--username`, `--server`, `--password`, " - "`--use-v2-file-events` or `--disable-ssl-errors` when updating a profile." + "Must provide at least one of `--server`, `--username`, `--password`, " + "`--use-v2-file-events` or `--disable-ssl-errors` when updating a username/password authenticated profile." ) assert "Profile 'foo' has been updated" not in result.output assert expected in result.output +def test_update_profile_when_api_client_authentication_and_is_given_zero_args_prints_error_message( + runner, mock_cliprofile_namespace, profile +): + name = "foo" + profile.name = name + profile.api_client_auth = "True" + mock_cliprofile_namespace.get_profile.return_value = profile + result = runner.invoke(cli, ["profile", "update"]) + expected = ( + "Must provide at least one of `--server`, `--api-client-id`, `--secret`, `--use-v2-file-events` or " + "`--disable-ssl-errors` when updating an API client profile." + ) + assert "Profile 'foo' has been updated" not in result.output + assert expected in result.output + + +def test_update_profile_when_api_client_authentication_updates_existing_profile( + runner, mock_cliprofile_namespace, profile +): + name = "foo" + profile.name = name + profile.api_client_auth = "True" + mock_cliprofile_namespace.get_profile.return_value = profile + result = runner.invoke( + cli, + [ + "profile", + "update", + "-n", + name, + "-s", + "bar", + "--api-client-id", + "baz", + "--use-v2-file-events", + "True", + ], + ) + mock_cliprofile_namespace.update_profile.assert_called_once_with( + name, "bar", "baz", None, True + ) + assert "Profile 'foo' has been updated" in result.output + + +def test_update_profile_when_updating_auth_profile_to_api_client_updates_existing_profile( + runner, valid_connection, mock_cliprofile_namespace, profile +): + name = "foo" + profile.name = name + profile.api_client_auth = "False" + mock_cliprofile_namespace.get_profile.return_value = profile + result = runner.invoke( + cli, + [ + "profile", + "update", + "-n", + name, + "-s", + "bar", + "--api-client-id", + "baz", + "--secret", + "fob", + "--use-v2-file-events", + "True", + "-y", + ], + ) + mock_cliprofile_namespace.update_profile.assert_called_once_with( + name, "bar", "baz", None, True, api_client_auth=True + ) + assert "Profile 'foo' has been updated" in result.output + + +def test_update_profile_when_updating_api_client_profile_to_user_credentails_updates_existing_profile( + runner, mock_cliprofile_namespace, profile, valid_connection +): + name = "foo" + profile.name = name + profile.api_client_auth = "True" + mock_cliprofile_namespace.get_profile.return_value = profile + result = runner.invoke( + cli, + [ + "profile", + "update", + "-n", + name, + "-s", + "bar", + "-u", + "baz", + "--password", + "fob", + "--use-v2-file-events", + "True", + "-y", + ], + ) + mock_cliprofile_namespace.update_profile.assert_called_once_with( + name, "bar", "baz", None, True, api_client_auth=False + ) + assert "Profile 'foo' has been updated" in result.output + + def test_delete_profile_warns_if_deleting_default(runner, mock_cliprofile_namespace): mock_cliprofile_namespace.is_default_profile.return_value = True result = runner.invoke(cli, ["profile", "delete", "mockdefault"]) diff --git a/tests/conftest.py b/tests/conftest.py index 7e809eb8..b609cacb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -87,13 +87,18 @@ def alert_namespace(): def create_profile_values_dict( - authority=None, username=None, ignore_ssl=False, use_v2_file_events=False + authority=None, + username=None, + ignore_ssl=False, + use_v2_file_events=False, + api_client_auth="False", ): return { ConfigAccessor.AUTHORITY_KEY: "example.com", ConfigAccessor.USERNAME_KEY: "foo", - ConfigAccessor.IGNORE_SSL_ERRORS_KEY: True, - ConfigAccessor.USE_V2_FILE_EVENTS_KEY: False, + ConfigAccessor.IGNORE_SSL_ERRORS_KEY: "True", + ConfigAccessor.USE_V2_FILE_EVENTS_KEY: "False", + ConfigAccessor.API_CLIENT_AUTH_KEY: "False", } diff --git a/tests/test_config.py b/tests/test_config.py index 89ee271e..2213e4e6 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -153,7 +153,7 @@ def test_create_profile_when_given_default_name_does_not_create( accessor = ConfigAccessor(config_parser_for_create) with pytest.raises(Exception): accessor.create_profile( - ConfigAccessor.DEFAULT_VALUE, "foo", "bar", False, False + ConfigAccessor.DEFAULT_VALUE, "foo", "bar", False, False, False ) def test_create_profile_when_no_default_profile_sets_default( @@ -165,7 +165,9 @@ def test_create_profile_when_no_default_profile_sets_default( accessor = ConfigAccessor(config_parser_for_create) accessor.switch_default_profile = mocker.MagicMock() - accessor.create_profile(_TEST_PROFILE_NAME, "example.com", "bar", None, None) + accessor.create_profile( + _TEST_PROFILE_NAME, "example.com", "bar", None, None, None + ) assert accessor.switch_default_profile.call_count == 1 def test_create_profile_when_has_default_profile_does_not_set_default( @@ -177,7 +179,9 @@ def test_create_profile_when_has_default_profile_does_not_set_default( accessor = ConfigAccessor(config_parser_for_create) accessor.switch_default_profile = mocker.MagicMock() - accessor.create_profile(_TEST_PROFILE_NAME, "example.com", "bar", None, None) + accessor.create_profile( + _TEST_PROFILE_NAME, "example.com", "bar", None, None, None + ) assert not accessor.switch_default_profile.call_count def test_create_profile_when_not_existing_saves( @@ -188,7 +192,9 @@ def test_create_profile_when_not_existing_saves( setup_parser_one_profile(mock_internal, mock_internal, config_parser_for_create) accessor = ConfigAccessor(config_parser_for_create) - accessor.create_profile(_TEST_PROFILE_NAME, "example.com", "bar", None, None) + accessor.create_profile( + _TEST_PROFILE_NAME, "example.com", "bar", None, None, None + ) assert mock_saver.call_count def test_update_profile_when_no_profile_exists_raises_exception( diff --git a/tests/test_profile.py b/tests/test_profile.py index 95164d48..d8481391 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -64,7 +64,15 @@ def test_username_returns_expected_value(self): def test_ignore_ssl_errors_returns_expected_value(self): mock_profile = create_mock_profile() - assert mock_profile.ignore_ssl_errors + assert mock_profile.ignore_ssl_errors == "True" + + def test_use_v2_file_events_returns_expected_value(self): + mock_profile = create_mock_profile() + assert mock_profile.use_v2_file_events == "False" + + def test_api_client_auth_returns_expected_value(self): + mock_profile = create_mock_profile() + assert mock_profile.api_client_auth == "False" def test_get_profile_returns_expected_profile(config_accessor): @@ -132,17 +140,33 @@ def test_switch_default_profile_switches_to_expected_profile(config_accessor): config_accessor.switch_default_profile.assert_called_once_with("switchtome") -def test_create_profile_uses_expected_profile_values(config_accessor): +def test_create_profile_when_user_credentials_uses_expected_profile_values( + config_accessor, +): config_accessor.get_profile.side_effect = NoConfigProfileError() profile_name = "profilename" server = "server" username = "username" ssl_errors_disabled = True cliprofile.create_profile( - profile_name, server, username, ssl_errors_disabled, False + profile_name, server, username, ssl_errors_disabled, False, False + ) + config_accessor.create_profile.assert_called_once_with( + profile_name, server, username, ssl_errors_disabled, False, False + ) + + +def test_create_profile_when_api_client_uses_expected_profile_values(config_accessor): + config_accessor.get_profile.side_effect = NoConfigProfileError() + profile_name = "profilename" + server = "server" + api_client_id = "key-42" + ssl_errors_disabled = True + cliprofile.create_profile( + profile_name, server, api_client_id, ssl_errors_disabled, False, True ) config_accessor.create_profile.assert_called_once_with( - profile_name, server, username, ssl_errors_disabled, False + profile_name, server, api_client_id, ssl_errors_disabled, False, True ) @@ -151,7 +175,7 @@ def test_create_profile_if_profile_exists_exits( ): config_accessor.get_profile.return_value = mocker.MagicMock() with pytest.raises(Code42CLIError): - cliprofile.create_profile("foo", "bar", "baz", True, False) + cliprofile.create_profile("foo", "bar", "baz", True, False, False) def test_get_all_profiles_returns_expected_profile_list(config_accessor): diff --git a/tests/test_sdk_client.py b/tests/test_sdk_client.py index d297e960..07f795a1 100644 --- a/tests/test_sdk_client.py +++ b/tests/test_sdk_client.py @@ -27,6 +27,11 @@ def mock_sdk_factory(mocker): return mocker.patch("py42.sdk.from_local_account") +@pytest.fixture +def mock_api_client_sdk_factory(mocker): + return mocker.patch("py42.sdk.from_api_client") + + @pytest.fixture def mock_profile_with_password(): profile = create_mock_profile() @@ -154,6 +159,20 @@ def test_create_sdk_connection_when_mfa_token_invalid_raises_expected_cli_error( assert str(err.value) == "Invalid credentials or TOTP token for user foo." +def test_create_sdk_connection_when_using_api_client_credentials_uses_api_client_function( + mock_api_client_sdk_factory, mock_profile_with_password +): + create_sdk( + mock_profile_with_password, + False, + password="api-client-secret-42", + api_client=True, + ) + mock_api_client_sdk_factory.assert_called_once_with( + "example.com", "foo", "api-client-secret-42" + ) + + def test_totp_option_when_passed_is_passed_to_sdk_initialization( mocker, profile, runner ):