From 3c64b6eda936ee287d6c062f3cfa3d9e837963a0 Mon Sep 17 00:00:00 2001 From: Kegan Maher Date: Tue, 19 Mar 2024 16:34:54 +0000 Subject: [PATCH 1/5] feat(commands): reset a user's password with optional notification to an email address --- compiler_admin/commands/reset_password.py | 38 ++++++++++++++ compiler_admin/main.py | 6 +++ tests/commands/test_reset_password.py | 60 +++++++++++++++++++++++ tests/conftest.py | 6 +++ tests/test_main.py | 27 ++++++++++ 5 files changed, 137 insertions(+) create mode 100644 compiler_admin/commands/reset_password.py create mode 100644 tests/commands/test_reset_password.py diff --git a/compiler_admin/commands/reset_password.py b/compiler_admin/commands/reset_password.py new file mode 100644 index 0000000..611edb5 --- /dev/null +++ b/compiler_admin/commands/reset_password.py @@ -0,0 +1,38 @@ +from argparse import Namespace + +from compiler_admin.commands import RESULT_SUCCESS, RESULT_FAILURE +from compiler_admin.services.google import 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") + + notify = getattr(args, "notify", None) + if notify: + command += ("notify", notify, "from", "hello@compiler.la") + + print(f"User exists, resetting password: {account}") + + res = CallGAMCommand(command) + + 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/tests/commands/test_reset_password.py b/tests/commands/test_reset_password.py new file mode 100644 index 0000000..0611d4c --- /dev/null +++ b/tests/commands/test_reset_password.py @@ -0,0 +1,60 @@ +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 + + +@pytest.fixture +def mock_google_user_exists(mock_google_user_exists): + return mock_google_user_exists(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_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" in call_args + + +def test_reset_password_notify(mock_google_user_exists, mock_google_CallGAMCommand): + 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" in call_args + assert "notify notification@example.com from hello@compiler.la" in call_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"]) From 4c5ec4abe77072a14bb253dbf9032fa31969e062 Mon Sep 17 00:00:00 2001 From: Kegan Maher Date: Tue, 19 Mar 2024 16:42:40 +0000 Subject: [PATCH 2/5] refactor(google): constant for hello account --- compiler_admin/commands/reset_password.py | 4 ++-- compiler_admin/services/google.py | 3 +++ tests/commands/test_reset_password.py | 3 ++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/compiler_admin/commands/reset_password.py b/compiler_admin/commands/reset_password.py index 611edb5..7dce3dd 100644 --- a/compiler_admin/commands/reset_password.py +++ b/compiler_admin/commands/reset_password.py @@ -1,7 +1,7 @@ from argparse import Namespace from compiler_admin.commands import RESULT_SUCCESS, RESULT_FAILURE -from compiler_admin.services.google import CallGAMCommand, user_account_name, user_exists +from compiler_admin.services.google import USER_HELLO, CallGAMCommand, user_account_name, user_exists def reset_password(args: Namespace) -> int: @@ -29,7 +29,7 @@ def reset_password(args: Namespace) -> int: notify = getattr(args, "notify", None) if notify: - command += ("notify", notify, "from", "hello@compiler.la") + command += ("notify", notify, "from", USER_HELLO) print(f"User exists, resetting password: {account}") 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 index 0611d4c..65a3642 100644 --- a/tests/commands/test_reset_password.py +++ b/tests/commands/test_reset_password.py @@ -3,6 +3,7 @@ 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 @@ -57,4 +58,4 @@ def test_reset_password_notify(mock_google_user_exists, mock_google_CallGAMComma call_args = " ".join(mock_google_CallGAMCommand.call_args[0][0]) assert "update user" in call_args assert "password random" in call_args - assert "notify notification@example.com from hello@compiler.la" in call_args + assert f"notify notification@example.com from {USER_HELLO}" in call_args From 6c70ebc8349a32fecd23dd27bb235be5afb4acbb Mon Sep 17 00:00:00 2001 From: Kegan Maher Date: Tue, 19 Mar 2024 16:43:25 +0000 Subject: [PATCH 3/5] feat(commands/reset_password): force password change --- compiler_admin/commands/reset_password.py | 2 +- tests/commands/test_reset_password.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/compiler_admin/commands/reset_password.py b/compiler_admin/commands/reset_password.py index 7dce3dd..81cdb7a 100644 --- a/compiler_admin/commands/reset_password.py +++ b/compiler_admin/commands/reset_password.py @@ -25,7 +25,7 @@ def reset_password(args: Namespace) -> int: print(f"User does not exist: {account}") return RESULT_FAILURE - command = ("update", "user", account, "password", "random") + command = ("update", "user", account, "password", "random", "changepassword") notify = getattr(args, "notify", None) if notify: diff --git a/tests/commands/test_reset_password.py b/tests/commands/test_reset_password.py index 65a3642..02b1b70 100644 --- a/tests/commands/test_reset_password.py +++ b/tests/commands/test_reset_password.py @@ -43,7 +43,7 @@ def test_reset_password_user_exists(mock_google_user_exists, mock_google_CallGAM 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" in call_args + assert "password random changepassword" in call_args def test_reset_password_notify(mock_google_user_exists, mock_google_CallGAMCommand): @@ -57,5 +57,5 @@ def test_reset_password_notify(mock_google_user_exists, mock_google_CallGAMComma 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" in call_args + assert "password random changepassword" in call_args assert f"notify notification@example.com from {USER_HELLO}" in call_args From da8cbcccaf351b5b73a69f58d9701afdb527a929 Mon Sep 17 00:00:00 2001 From: Kegan Maher Date: Tue, 19 Mar 2024 16:46:34 +0000 Subject: [PATCH 4/5] feat(commands/reset_password): signout user from active sessions --- compiler_admin/commands/reset_password.py | 2 ++ tests/commands/test_reset_password.py | 13 +++++++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/compiler_admin/commands/reset_password.py b/compiler_admin/commands/reset_password.py index 81cdb7a..610d38f 100644 --- a/compiler_admin/commands/reset_password.py +++ b/compiler_admin/commands/reset_password.py @@ -1,6 +1,7 @@ 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 @@ -34,5 +35,6 @@ def reset_password(args: Namespace) -> int: 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/tests/commands/test_reset_password.py b/tests/commands/test_reset_password.py index 02b1b70..909215d 100644 --- a/tests/commands/test_reset_password.py +++ b/tests/commands/test_reset_password.py @@ -11,6 +11,11 @@ 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) @@ -32,7 +37,7 @@ def test_reset_password_user_does_not_exist(mock_google_user_exists): assert res == RESULT_FAILURE -def test_reset_password_user_exists(mock_google_user_exists, mock_google_CallGAMCommand): +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") @@ -45,8 +50,10 @@ def test_reset_password_user_exists(mock_google_user_exists, mock_google_CallGAM 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): + +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") @@ -59,3 +66,5 @@ def test_reset_password_notify(mock_google_user_exists, mock_google_CallGAMComma 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) From 567632a6623c99458a78ce45e5184371b60b0936 Mon Sep 17 00:00:00 2001 From: Kegan Maher Date: Tue, 19 Mar 2024 17:16:09 +0000 Subject: [PATCH 5/5] docs(usage): update with reset-password --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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.