diff --git a/Makefile b/Makefile index 438912b..4b09396 100644 --- a/Makefile +++ b/Makefile @@ -15,7 +15,7 @@ test: coverage: coverage erase coverage run - coverage html + coverage html --omit toot/tui/* coverage report clean : diff --git a/changelog.yaml b/changelog.yaml index 20961c2..93aed2c 100644 --- a/changelog.yaml +++ b/changelog.yaml @@ -1,3 +1,9 @@ +0.40.0: + date: TBA + changes: + - "Migrated to `click` for commandline arguments. BC should be mostly preserved, please report any issues." + - "Removed the deprecated `--disable-https` option for `login` and `login_cli`, pass the base URL instead" + 0.39.0: date: 2023-11-23 changes: diff --git a/tests/integration/test_auth.py b/tests/integration/test_auth.py new file mode 100644 index 0000000..446f8ad --- /dev/null +++ b/tests/integration/test_auth.py @@ -0,0 +1,217 @@ +from typing import Any, Dict +from unittest import mock +from unittest.mock import MagicMock + +from toot import User, cli +from toot.cli.base import Run + +# TODO: figure out how to test login + + +EMPTY_CONFIG: Dict[Any, Any] = { + "apps": {}, + "users": {}, + "active_user": None +} + +SAMPLE_CONFIG = { + "active_user": "frank@foo.social", + "apps": { + "foo.social": { + "base_url": "http://foo.social", + "client_id": "123", + "client_secret": "123", + "instance": "foo.social" + }, + "bar.social": { + "base_url": "http://bar.social", + "client_id": "123", + "client_secret": "123", + "instance": "bar.social" + }, + }, + "users": { + "frank@foo.social": { + "access_token": "123", + "instance": "foo.social", + "username": "frank" + }, + "frank@bar.social": { + "access_token": "123", + "instance": "bar.social", + "username": "frank" + }, + } +} + + +def test_env(run: Run): + result = run(cli.env) + assert result.exit_code == 0 + assert "toot" in result.stdout + assert "Python" in result.stdout + + +@mock.patch("toot.config.load_config") +def test_auth_empty(load_config: MagicMock, run: Run): + load_config.return_value = EMPTY_CONFIG + result = run(cli.auth) + assert result.exit_code == 0 + assert result.stdout.strip() == "You are not logged in to any accounts" + + +@mock.patch("toot.config.load_config") +def test_auth_full(load_config: MagicMock, run: Run): + load_config.return_value = SAMPLE_CONFIG + result = run(cli.auth) + assert result.exit_code == 0 + assert result.stdout.strip().startswith("Authenticated accounts:") + assert "frank@foo.social" in result.stdout + assert "frank@bar.social" in result.stdout + + +# Saving config is mocked so we don't mess up our local config +# TODO: could this be implemented using an auto-use fixture so we have it always +# mocked? +@mock.patch("toot.config.load_app") +@mock.patch("toot.config.save_app") +@mock.patch("toot.config.save_user") +def test_login_cli( + save_user: MagicMock, + save_app: MagicMock, + load_app: MagicMock, + user: User, + run: Run, +): + load_app.return_value = None + + result = run( + cli.login_cli, + "--instance", "http://localhost:3000", + "--email", f"{user.username}@example.com", + "--password", "password", + ) + assert result.exit_code == 0 + assert "✓ Successfully logged in." in result.stdout + + save_app.assert_called_once() + (app,) = save_app.call_args.args + assert app.instance == "localhost:3000" + assert app.base_url == "http://localhost:3000" + assert app.client_id + assert app.client_secret + + save_user.assert_called_once() + (new_user,) = save_user.call_args.args + assert new_user.instance == "localhost:3000" + assert new_user.username == user.username + # access token will be different since this is a new login + assert new_user.access_token and new_user.access_token != user.access_token + assert save_user.call_args.kwargs == {"activate": True} + + +@mock.patch("toot.config.load_app") +@mock.patch("toot.config.save_app") +@mock.patch("toot.config.save_user") +def test_login_cli_wrong_password( + save_user: MagicMock, + save_app: MagicMock, + load_app: MagicMock, + user: User, + run: Run, +): + load_app.return_value = None + + result = run( + cli.login_cli, + "--instance", "http://localhost:3000", + "--email", f"{user.username}@example.com", + "--password", "wrong password", + ) + assert result.exit_code == 1 + assert result.stderr.strip() == "Error: Login failed" + + save_app.assert_called_once() + (app,) = save_app.call_args.args + assert app.instance == "localhost:3000" + assert app.base_url == "http://localhost:3000" + assert app.client_id + assert app.client_secret + + save_user.assert_not_called() + + +@mock.patch("toot.config.load_config") +@mock.patch("toot.config.delete_user") +def test_logout(delete_user: MagicMock, load_config: MagicMock, run: Run): + load_config.return_value = SAMPLE_CONFIG + + result = run(cli.logout, "frank@foo.social") + assert result.exit_code == 0 + assert result.stdout.strip() == "✓ Account frank@foo.social logged out" + delete_user.assert_called_once_with(User("foo.social", "frank", "123")) + + +@mock.patch("toot.config.load_config") +def test_logout_not_logged_in(load_config: MagicMock, run: Run): + load_config.return_value = EMPTY_CONFIG + + result = run(cli.logout) + assert result.exit_code == 1 + assert result.stderr.strip() == "Error: You're not logged into any accounts" + + +@mock.patch("toot.config.load_config") +def test_logout_account_not_specified(load_config: MagicMock, run: Run): + load_config.return_value = SAMPLE_CONFIG + + result = run(cli.logout) + assert result.exit_code == 1 + assert result.stderr.startswith("Error: Specify account to log out") + + +@mock.patch("toot.config.load_config") +def test_logout_account_does_not_exist(load_config: MagicMock, run: Run): + load_config.return_value = SAMPLE_CONFIG + + result = run(cli.logout, "banana") + assert result.exit_code == 1 + assert result.stderr.startswith("Error: Account not found") + + +@mock.patch("toot.config.load_config") +@mock.patch("toot.config.activate_user") +def test_activate(activate_user: MagicMock, load_config: MagicMock, run: Run): + load_config.return_value = SAMPLE_CONFIG + + result = run(cli.activate, "frank@foo.social") + assert result.exit_code == 0 + assert result.stdout.strip() == "✓ Account frank@foo.social activated" + activate_user.assert_called_once_with(User("foo.social", "frank", "123")) + + +@mock.patch("toot.config.load_config") +def test_activate_not_logged_in(load_config: MagicMock, run: Run): + load_config.return_value = EMPTY_CONFIG + + result = run(cli.activate) + assert result.exit_code == 1 + assert result.stderr.strip() == "Error: You're not logged into any accounts" + + +@mock.patch("toot.config.load_config") +def test_activate_account_not_given(load_config: MagicMock, run: Run): + load_config.return_value = SAMPLE_CONFIG + + result = run(cli.activate) + assert result.exit_code == 1 + assert result.stderr.startswith("Error: Specify account to activate") + + +@mock.patch("toot.config.load_config") +def test_activate_invalid_Account(load_config: MagicMock, run: Run): + load_config.return_value = SAMPLE_CONFIG + + result = run(cli.activate, "banana") + assert result.exit_code == 1 + assert result.stderr.startswith("Error: Account not found") diff --git a/tests/test_utils.py b/tests/test_utils.py index 9dbb579..906a351 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,7 +1,7 @@ -from argparse import ArgumentTypeError +import click import pytest -from toot.console import duration +from toot.cli.validators import validate_duration from toot.wcstring import wc_wrap, trunc, pad, fit_text from toot.utils import urlencode_url @@ -163,6 +163,9 @@ def test_wc_wrap_indented(): def test_duration(): + def duration(value): + return validate_duration(None, None, value) + # Long hand assert duration("1 second") == 1 assert duration("1 seconds") == 1 @@ -190,17 +193,17 @@ def test_duration(): assert duration("5d 10h 3m 1s") == 5 * 86400 + 10 * 3600 + 3 * 60 + 1 assert duration("5d10h3m1s") == 5 * 86400 + 10 * 3600 + 3 * 60 + 1 - with pytest.raises(ArgumentTypeError): + with pytest.raises(click.BadParameter): duration("") - with pytest.raises(ArgumentTypeError): + with pytest.raises(click.BadParameter): duration("100") # Wrong order - with pytest.raises(ArgumentTypeError): + with pytest.raises(click.BadParameter): duration("1m1d") - with pytest.raises(ArgumentTypeError): + with pytest.raises(click.BadParameter): duration("banana") diff --git a/toot/api.py b/toot/api.py index b2e82b7..b7136d6 100644 --- a/toot/api.py +++ b/toot/api.py @@ -140,7 +140,7 @@ def fetch_app_token(app): return http.anon_post(f"{app.base_url}/oauth/token", json=json).json() -def login(app, username, password): +def login(app: App, username: str, password: str): url = app.base_url + '/oauth/token' data = { @@ -152,16 +152,10 @@ def login(app, username, password): 'scope': SCOPES, } - response = http.anon_post(url, data=data, allow_redirects=False) - - # If auth fails, it redirects to the login page - if response.is_redirect: - raise AuthenticationError() - - return response.json() + return http.anon_post(url, data=data).json() -def get_browser_login_url(app): +def get_browser_login_url(app: App) -> str: """Returns the URL for manual log in via browser""" return "{}/oauth/authorize/?{}".format(app.base_url, urlencode({ "response_type": "code", @@ -171,7 +165,7 @@ def get_browser_login_url(app): })) -def request_access_token(app, authorization_code): +def request_access_token(app: App, authorization_code: str): url = app.base_url + '/oauth/token' data = { diff --git a/toot/auth.py b/toot/auth.py index b9a0597..ef84652 100644 --- a/toot/auth.py +++ b/toot/auth.py @@ -1,18 +1,19 @@ -import sys -import webbrowser - -from builtins import input -from getpass import getpass - -from toot import api, config, DEFAULT_INSTANCE, User, App +from toot import api, config, User, App +from toot.entities import from_dict, Instance from toot.exceptions import ApiError, ConsoleError -from toot.output import print_out from urllib.parse import urlparse -def register_app(domain, base_url): +def find_instance(base_url: str) -> Instance: + try: + instance = api.get_instance(base_url).json() + return from_dict(Instance, instance) + except Exception: + raise ConsoleError(f"Instance not found at {base_url}") + + +def register_app(domain: str, base_url: str) -> App: try: - print_out("Registering application...") response = api.create_app(base_url) except ApiError: raise ConsoleError("Registration failed.") @@ -20,109 +21,54 @@ def register_app(domain, base_url): app = App(domain, base_url, response['client_id'], response['client_secret']) config.save_app(app) - print_out("Application tokens saved.") - return app -def create_app_interactive(base_url): - if not base_url: - print_out(f"Enter instance URL [{DEFAULT_INSTANCE}]: ", end="") - base_url = input() - if not base_url: - base_url = DEFAULT_INSTANCE - - domain = get_instance_domain(base_url) - +def get_or_create_app(base_url: str) -> App: + instance = find_instance(base_url) + domain = _get_instance_domain(instance) return config.load_app(domain) or register_app(domain, base_url) -def get_instance_domain(base_url): - print_out("Looking up instance info...") - - instance = api.get_instance(base_url).json() - - print_out( - f"Found instance {instance['title']} " - f"running Mastodon version {instance['version']}" - ) - - # Pleroma and its forks return an actual URI here, rather than a - # domain name like Mastodon. This is contrary to the spec.¯ - # in that case, parse out the domain and return it. - uri = instance["uri"] - if uri.startswith("http"): - return urlparse(uri).netloc - - return uri - # NB: when updating to v2 instance endpoint, this field has been renamed to `domain` - - -def create_user(app, access_token): +def create_user(app: App, access_token: str) -> User: # Username is not yet known at this point, so fetch it from Mastodon user = User(app.instance, None, access_token) creds = api.verify_credentials(app, user).json() - user = User(app.instance, creds['username'], access_token) + user = User(app.instance, creds["username"], access_token) config.save_user(user, activate=True) - print_out("Access token saved to config at: {}".format( - config.get_config_file_path())) - return user -def login_interactive(app, email=None): - print_out("Log in to {}".format(app.instance)) - - if email: - print_out("Email: {}".format(email)) - - while not email: - email = input('Email: ') - - # Accept password piped from stdin, useful for testing purposes but not - # documented so people won't get ideas. Otherwise prompt for password. - if sys.stdin.isatty(): - password = getpass('Password: ') - else: - password = sys.stdin.read().strip() - print_out("Password: read from stdin") - +def login_username_password(app: App, email: str, password: str) -> User: try: - print_out("Authenticating...") response = api.login(app, email, password) - except ApiError: + except Exception: raise ConsoleError("Login failed") - return create_user(app, response['access_token']) + return create_user(app, response["access_token"]) -BROWSER_LOGIN_EXPLANATION = """ -This authentication method requires you to log into your Mastodon instance -in your browser, where you will be asked to authorize toot to access -your account. When you do, you will be given an authorization code -which you need to paste here. -""" +def login_auth_code(app: App, authorization_code: str) -> User: + try: + response = api.request_access_token(app, authorization_code) + except Exception: + raise ConsoleError("Login failed") + + return create_user(app, response["access_token"]) -def login_browser_interactive(app): - url = api.get_browser_login_url(app) - print_out(BROWSER_LOGIN_EXPLANATION) +def _get_instance_domain(instance: Instance) -> str: + """Extracts the instance domain name. - print_out("This is the login URL:") - print_out(url) - print_out("") + Pleroma and its forks return an actual URI here, rather than a domain name + like Mastodon. This is contrary to the spec.¯ in that case, parse out the + domain and return it. - yesno = input("Open link in default browser? [Y/n]") - if not yesno or yesno.lower() == 'y': - webbrowser.open(url) - - authorization_code = "" - while not authorization_code: - authorization_code = input("Authorization code: ") - - print_out("\nRequesting access token...") - response = api.request_access_token(app, authorization_code) - - return create_user(app, response['access_token']) + TODO: when updating to v2 instance endpoint, this field has been renamed to + `domain` + """ + if instance.uri.startswith("http"): + return urlparse(instance.uri).netloc + return instance.uri diff --git a/toot/cli/__init__.py b/toot/cli/__init__.py index c1d4a3b..2e6451c 100644 --- a/toot/cli/__init__.py +++ b/toot/cli/__init__.py @@ -1,5 +1,6 @@ from toot.cli.base import cli, Context # noqa +from toot.cli.auth import * from toot.cli.accounts import * from toot.cli.lists import * from toot.cli.post import * diff --git a/toot/cli/accounts.py b/toot/cli/accounts.py index a8c63c1..5cb66b9 100644 --- a/toot/cli/accounts.py +++ b/toot/cli/accounts.py @@ -4,9 +4,8 @@ import json as pyjson from typing import BinaryIO, Optional from toot import api -from toot.cli.base import cli, json_option, Context, pass_context +from toot.cli.base import PRIVACY_CHOICES, cli, json_option, Context, pass_context from toot.cli.validators import validate_language -from toot.console import PRIVACY_CHOICES from toot.output import print_acct_list diff --git a/toot/cli/auth.py b/toot/cli/auth.py new file mode 100644 index 0000000..12d1a74 --- /dev/null +++ b/toot/cli/auth.py @@ -0,0 +1,143 @@ +import click +import platform +import sys +import webbrowser + +from toot import api, config, __version__ +from toot.auth import get_or_create_app, login_auth_code, login_username_password +from toot.cli.base import cli +from toot.cli.validators import validate_instance + + +instance_option = click.option( + "--instance", "-i", "base_url", + prompt="Enter instance URL", + default="https://mastodon.social", + callback=validate_instance, + help="""Domain or base URL of the instance to log into, + e.g. 'mastodon.social' or 'https://mastodon.social'""", +) + + +@cli.command() +def auth(): + """Show logged in accounts and instances""" + config_data = config.load_config() + + if not config_data["users"]: + click.echo("You are not logged in to any accounts") + return + + active_user = config_data["active_user"] + + click.echo("Authenticated accounts:") + for uid, u in config_data["users"].items(): + active_label = "ACTIVE" if active_user == uid else "" + uid = click.style(uid, fg="green") + active_label = click.style(active_label, fg="yellow") + click.echo(f"* {uid} {active_label}") + + path = config.get_config_file_path() + path = click.style(path, "blue") + click.echo(f"\nAuth tokens are stored in: {path}") + + +@cli.command() +def env(): + """Print environment information for inclusion in bug reports.""" + click.echo(f"toot {__version__}") + click.echo(f"Python {sys.version}") + click.echo(platform.platform()) + + +@cli.command(name="login_cli") +@instance_option +@click.option("--email", "-e", help="Email address to log in with", prompt=True) +@click.option("--password", "-p", hidden=True, prompt=True, hide_input=True) +def login_cli(base_url: str, email: str, password: str): + """ + Log into an instance from the console (not recommended) + + Does NOT support two factor authentication, may not work on instances + other than Mastodon, mostly useful for scripting. + """ + app = get_or_create_app(base_url) + login_username_password(app, email, password) + + click.secho("✓ Successfully logged in.", fg="green") + click.echo("Access token saved to config at: ", nl=False) + click.secho(config.get_config_file_path(), fg="green") + + +LOGIN_EXPLANATION = """This authentication method requires you to log into your +Mastodon instance in your browser, where you will be asked to authorize toot to +access your account. When you do, you will be given an authorization code which +you need to paste here.""".replace("\n", " ") + + +@cli.command() +@instance_option +def login(base_url: str): + """Log into an instance using your browser (recommended)""" + app = get_or_create_app(base_url) + url = api.get_browser_login_url(app) + + click.echo(click.wrap_text(LOGIN_EXPLANATION)) + click.echo("\nLogin URL:") + click.echo(url) + + yesno = click.prompt("Open link in default browser? [Y/n]", default="Y", show_default=False) + if not yesno or yesno.lower() == 'y': + webbrowser.open(url) + + authorization_code = "" + while not authorization_code: + authorization_code = click.prompt("Authorization code") + + login_auth_code(app, authorization_code) + + click.echo() + click.secho("✓ Successfully logged in.", fg="green") + + +@cli.command() +@click.argument("account", required=False) +def logout(account: str): + """Log out of ACCOUNT, delete stored access keys""" + accounts = _get_accounts_list() + + if not account: + raise click.ClickException(f"Specify account to log out:\n{accounts}") + + user = config.load_user(account) + + if not user: + raise click.ClickException(f"Account not found. Logged in accounts:\n{accounts}") + + config.delete_user(user) + click.secho(f"✓ Account {account} logged out", fg="green") + + +@cli.command() +@click.argument("account", required=False) +def activate(account: str): + """Switch to logged in ACCOUNT.""" + accounts = _get_accounts_list() + + if not account: + raise click.ClickException(f"Specify account to activate:\n{accounts}") + + user = config.load_user(account) + + if not user: + raise click.ClickException(f"Account not found. Logged in accounts:\n{accounts}") + + config.activate_user(user) + click.secho(f"✓ Account {account} activated", fg="green") + + +def _get_accounts_list() -> str: + accounts = config.load_config()["users"].keys() + if not accounts: + raise click.ClickException("You're not logged into any accounts") + return "\n".join([f"* {acct}" for acct in accounts]) diff --git a/toot/cli/base.py b/toot/cli/base.py index b26f3e3..c86b531 100644 --- a/toot/cli/base.py +++ b/toot/cli/base.py @@ -1,12 +1,29 @@ -import logging -import sys import click +import logging +import os +import sys +from click.testing import Result from functools import wraps from toot import App, User, config, __version__ from typing import Callable, Concatenate, NamedTuple, Optional, ParamSpec, TypeVar +PRIVACY_CHOICES = ["public", "unlisted", "private"] +VISIBILITY_CHOICES = ["public", "unlisted", "private", "direct"] + +DURATION_EXAMPLES = """e.g. "1 day", "2 hours 30 minutes", "5 minutes 30 +seconds" or any combination of above. Shorthand: "1d", "2h30m", "5m30s\"""" + + +# Type alias for run commands +Run = Callable[..., Result] + + +def get_default_visibility() -> str: + return os.getenv("TOOT_POST_VISIBILITY", "public") + + # Tweak the Click context # https://click.palletsprojects.com/en/8.1.x/api/#context CONTEXT = dict( diff --git a/toot/cli/post.py b/toot/cli/post.py index 92b839e..d19fe41 100644 --- a/toot/cli/post.py +++ b/toot/cli/post.py @@ -8,8 +8,8 @@ from typing import Optional, Tuple from toot import api from toot.cli.base import cli, json_option, pass_context, Context +from toot.cli.base import DURATION_EXAMPLES, VISIBILITY_CHOICES, get_default_visibility from toot.cli.validators import validate_duration, validate_language -from toot.console import DURATION_EXAMPLES, VISIBILITY_CHOICES, get_default_visibility from toot.utils import EOF_KEY, delete_tmp_status_file, editor_input, multiline_input from toot.utils.datetime import parse_datetime diff --git a/toot/cli/statuses.py b/toot/cli/statuses.py index d675439..1cc755b 100644 --- a/toot/cli/statuses.py +++ b/toot/cli/statuses.py @@ -1,8 +1,8 @@ import click from toot import api -from toot.console import VISIBILITY_CHOICES, get_default_visibility from toot.cli.base import cli, json_option, Context, pass_context +from toot.cli.base import VISIBILITY_CHOICES, get_default_visibility from toot.output import print_table diff --git a/toot/cli/validators.py b/toot/cli/validators.py index 5d52d20..cfdd097 100644 --- a/toot/cli/validators.py +++ b/toot/cli/validators.py @@ -1,8 +1,11 @@ import click import re +from click import Context +from typing import Optional -def validate_language(ctx, param, value): + +def validate_language(ctx: Context, param: str, value: Optional[str]): if value is None: return None @@ -13,7 +16,7 @@ def validate_language(ctx, param, value): raise click.BadParameter("Language should be a two letter abbreviation.") -def validate_duration(ctx, param, value: str) -> int: +def validate_duration(ctx: Context, param: str, value: Optional[str]) -> Optional[int]: if value is None: return None @@ -43,3 +46,15 @@ def validate_duration(ctx, param, value: str) -> int: raise click.BadParameter("Empty duration") return duration + + +def validate_instance(ctx: click.Context, param: str, value: Optional[str]): + """ + Instance can be given either as a base URL or the domain name. + Return the base URL. + """ + if not value: + return None + + value = value.rstrip("/") + return value if value.startswith("http") else f"https://{value}" diff --git a/toot/config.py b/toot/config.py index 077e098..98ee6d8 100644 --- a/toot/config.py +++ b/toot/config.py @@ -3,6 +3,7 @@ import os from functools import wraps from os.path import dirname, join +from typing import Optional from toot import User, App, get_config_dir from toot.exceptions import ConsoleError @@ -85,7 +86,7 @@ def get_user_app(user_id): return extract_user_app(load_config(), user_id) -def load_app(instance): +def load_app(instance: str) -> Optional[App]: config = load_config() if instance in config['apps']: return App(**config['apps'][instance])