diff --git a/README.md b/README.md index 402ea82..523b3eb 100644 --- a/README.md +++ b/README.md @@ -10,16 +10,17 @@ Built on top of [GAMADV-XTD3](https://github.com/taers232c/GAMADV-XTD3) and [GYB ```bash $ compiler-admin -h -usage: compiler-admin [-h] [-v] {info,init,create,convert,delete,offboard,restore,signout} ... +usage: compiler-admin [-h] [-v] {info,init,create,convert,delete,offboard,reset-password,restore,signout} ... positional arguments: - {info,init,create,convert,delete,offboard,restore,signout} + {info,init,create,convert,delete,offboard,reset-password,restore,signout} info Print configuration and debugging information. init Initialize a new admin project. This command should be run once before any others. create Create a new user in the Compiler domain. convert Convert a user account to a new type. delete Delete a user account. offboard Offboard a user account. + reset-password Reset a user's password to a randomly generated string. restore Restore an email backup from a prior offboarding. signout Signs a user out from all active sessions. diff --git a/compiler_admin/commands/reset_password.py b/compiler_admin/commands/reset_password.py new file mode 100644 index 0000000..610d38f --- /dev/null +++ b/compiler_admin/commands/reset_password.py @@ -0,0 +1,40 @@ +from argparse import Namespace + +from compiler_admin.commands import RESULT_SUCCESS, RESULT_FAILURE +from compiler_admin.commands.signout import signout +from compiler_admin.services.google import USER_HELLO, CallGAMCommand, user_account_name, user_exists + + +def reset_password(args: Namespace) -> int: + """Reset a user's password. + + Optionally notify an email address with the new randomly generated password. + + Args: + username (str): the user account to reset. + + notify (str): an email address to send the new password notification. + Returns: + A value indicating if the operation succeeded or failed. + """ + if not hasattr(args, "username"): + raise ValueError("username is required") + + account = user_account_name(args.username) + + if not user_exists(account): + print(f"User does not exist: {account}") + return RESULT_FAILURE + + command = ("update", "user", account, "password", "random", "changepassword") + + notify = getattr(args, "notify", None) + if notify: + command += ("notify", notify, "from", USER_HELLO) + + print(f"User exists, resetting password: {account}") + + res = CallGAMCommand(command) + res += signout(args) + + return RESULT_SUCCESS if res == RESULT_SUCCESS else RESULT_FAILURE diff --git a/compiler_admin/main.py b/compiler_admin/main.py index 760090f..7ed0383 100644 --- a/compiler_admin/main.py +++ b/compiler_admin/main.py @@ -8,6 +8,7 @@ from compiler_admin.commands.info import info from compiler_admin.commands.init import init from compiler_admin.commands.offboard import offboard +from compiler_admin.commands.reset_password import reset_password from compiler_admin.commands.restore import restore from compiler_admin.commands.signout import signout @@ -60,6 +61,9 @@ def _subcmd(name, help, add_username_arg=True) -> argparse.ArgumentParser: "--force", action="store_true", default=False, help="Don't ask for confirmation before offboarding." ) + reset_parser = _subcmd("reset-password", help="Reset a user's password to a randomly generated string.") + reset_parser.add_argument("--notify", help="An email address to send the newly generated password.") + _subcmd("restore", help="Restore an email backup from a prior offboarding.") signout_parser = _subcmd("signout", help="Signs a user out from all active sessions.") @@ -86,6 +90,8 @@ def _subcmd(name, help, add_username_arg=True) -> argparse.ArgumentParser: return offboard(args) elif args.command == "restore": return restore(args) + elif args.command == "reset-password": + return reset_password(args) elif args.command == "signout": return signout(args) diff --git a/compiler_admin/services/google.py b/compiler_admin/services/google.py index 8edaac6..d93f713 100644 --- a/compiler_admin/services/google.py +++ b/compiler_admin/services/google.py @@ -38,6 +38,9 @@ def user_account_name(username: str) -> str: # Archive account USER_ARCHIVE = user_account_name("archive") +# Hello account +USER_HELLO = user_account_name("hello") + # Groups GROUP_PARTNERS = user_account_name("partners") GROUP_STAFF = user_account_name("staff") diff --git a/tests/commands/test_reset_password.py b/tests/commands/test_reset_password.py new file mode 100644 index 0000000..909215d --- /dev/null +++ b/tests/commands/test_reset_password.py @@ -0,0 +1,70 @@ +from argparse import Namespace +import pytest + +from compiler_admin.commands import RESULT_FAILURE, RESULT_SUCCESS +from compiler_admin.commands.reset_password import reset_password, __name__ as MODULE +from compiler_admin.services.google import USER_HELLO + + +@pytest.fixture +def mock_google_user_exists(mock_google_user_exists): + return mock_google_user_exists(MODULE) + + +@pytest.fixture +def mock_commands_signout(mock_commands_signout): + return mock_commands_signout(MODULE) + + +@pytest.fixture +def mock_google_CallGAMCommand(mock_google_CallGAMCommand): + return mock_google_CallGAMCommand(MODULE) + + +def test_reset_password_user_username_required(): + args = Namespace() + + with pytest.raises(ValueError, match="username is required"): + reset_password(args) + + +def test_reset_password_user_does_not_exist(mock_google_user_exists): + mock_google_user_exists.return_value = False + + args = Namespace(username="username") + res = reset_password(args) + + assert res == RESULT_FAILURE + + +def test_reset_password_user_exists(mock_google_user_exists, mock_google_CallGAMCommand, mock_commands_signout): + mock_google_user_exists.return_value = True + + args = Namespace(username="username") + res = reset_password(args) + + assert res == RESULT_SUCCESS + + mock_google_CallGAMCommand.assert_called_once() + call_args = " ".join(mock_google_CallGAMCommand.call_args[0][0]) + assert "update user" in call_args + assert "password random changepassword" in call_args + + mock_commands_signout.assert_called_once_with(args) + + +def test_reset_password_notify(mock_google_user_exists, mock_google_CallGAMCommand, mock_commands_signout): + mock_google_user_exists.return_value = True + + args = Namespace(username="username", notify="notification@example.com") + res = reset_password(args) + + assert res == RESULT_SUCCESS + + mock_google_CallGAMCommand.assert_called_once() + call_args = " ".join(mock_google_CallGAMCommand.call_args[0][0]) + assert "update user" in call_args + assert "password random changepassword" in call_args + assert f"notify notification@example.com from {USER_HELLO}" in call_args + + mock_commands_signout.assert_called_once_with(args) diff --git a/tests/conftest.py b/tests/conftest.py index 2e27b45..d57416c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -64,6 +64,12 @@ def mock_commands_offboard(mock_module_name): return mock_module_name("offboard") +@pytest.fixture +def mock_commands_reset_password(mock_module_name): + """Fixture returns a function that patches commands.reset_password in a given module.""" + return mock_module_name("reset_password") + + @pytest.fixture def mock_commands_restore(mock_module_name): """Fixture returns a function that patches commands.restore in a given module.""" diff --git a/tests/test_main.py b/tests/test_main.py index 94c4b2e..30cf411 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -39,6 +39,11 @@ def mock_commands_offboard(mock_commands_offboard): return mock_commands_offboard(MODULE) +@pytest.fixture +def mock_commands_reset_password(mock_commands_reset_password): + return mock_commands_reset_password(MODULE) + + @pytest.fixture def mock_commands_restore(mock_commands_restore): return mock_commands_restore(MODULE) @@ -177,6 +182,28 @@ def test_main_offboard_no_username(mock_commands_offboard): assert mock_commands_offboard.call_count == 0 +def test_main_reset_password(mock_commands_reset_password): + main(argv=["reset-password", "username"]) + + mock_commands_reset_password.assert_called_once() + call_args = mock_commands_reset_password.call_args.args + assert Namespace(command="reset-password", username="username", notify=None) in call_args + + +def test_main_reset_password_notify(mock_commands_reset_password): + main(argv=["reset-password", "username", "--notify", "notification"]) + + mock_commands_reset_password.assert_called_once() + call_args = mock_commands_reset_password.call_args.args + assert Namespace(command="reset-password", username="username", notify="notification") in call_args + + +def test_main_reset_password_no_username(mock_commands_reset_password): + with pytest.raises(SystemExit): + main(argv=["reset-password"]) + assert mock_commands_reset_password.call_count == 0 + + def test_main_restore(mock_commands_restore): main(argv=["restore", "username"])