Skip to content

Commit

Permalink
Add the OAuthHelper class
Browse files Browse the repository at this point in the history
- Add the `reddit` package
- Add the `OAuthHelper` class - providing the methods for the Reddit
  authorization
- Add the `http_server` configuration to the `config`
- Adjust the `isort` configuration to indent multi-line imports
  correctly
  • Loading branch information
vaclav-2012 committed May 30, 2020
1 parent d545829 commit 70740ab
Show file tree
Hide file tree
Showing 9 changed files with 185 additions and 0 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ We follow [Semantic Versions](https://semver.org/).
- Add `flask` to dependencies
- Add the `http_server` module - handling the OAuth callback
- Add `praw` to dependencies
- Add the `OAuthHelper` class - providing the methods for the Reddit authorization


## Version 0.1.3
Expand Down
1 change: 1 addition & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ default_section = FIRSTPARTY
# See https://github.com/timothycrosley/isort#multi-line-output-modes
multi_line_output = 3
line_length = 80
indent=" "


[darglint]
Expand Down
5 changes: 5 additions & 0 deletions slow_start_rewatch/config_default.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ scheduled_post_file: "${home_dir}${ps}slow_start_rewatch${ps}scheduled_post.yml"
# Temporary configuration of the Reddit username:
username: cute_tester

# Local HTTP server used for the OAuth2 callback:
http_server:
hostname: "127.0.0.1"
port: 65000

# Timer configuration:
timer:
refresh_interval: 200 # milliseconds
4 changes: 4 additions & 0 deletions slow_start_rewatch/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,7 @@ def __init__(self):

class AuthorizationError(SlowStartRewatchException):
"""Indicates an error during the authorization."""


class RedditError(SlowStartRewatchException):
"""Indicates error when accessing Reddit API."""
1 change: 1 addition & 0 deletions slow_start_rewatch/reddit/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# -*- coding: utf-8 -*-
74 changes: 74 additions & 0 deletions slow_start_rewatch/reddit/oauth_helper.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# -*- coding: utf-8 -*-

import uuid
import webbrowser

import click
from praw import Reddit
from prawcore.exceptions import PrawcoreException
from structlog import get_logger

from slow_start_rewatch.config import Config
from slow_start_rewatch.exceptions import AuthorizationError, RedditError
from slow_start_rewatch.http_server import http_server

BAD_REQUEST_ERROR = 400

log = get_logger()


class OAuthHelper(object):
"""Provides methods for Reddit authorization."""

def __init__(self, config: Config, reddit: Reddit) -> None:
"""Initialize OAuthHelper."""
self.config = config
self.reddit = reddit

def authorize(self) -> None:
"""
Authorize via OAuth.
Open a background browser (e.g. firefox) which is non-blocking.
The server will block until it responds to its first request. Then the
callback params are checked.
"""
state = uuid.uuid4().hex
authorize_url = self.reddit.auth.url(
scopes=self.config["reddit.oauth_scope"],
state=state,
)

click.echo(
"Opening a web browser for authorization:\n" +
"- You will be asked to allow the Slow Start Rewatch app to " +
"connect with your Reddit account so that it can submit and " +
"edit posts on your behalf.\n" +
"- The app cannot access your password.\n" +
"- Press Ctrl+C if you would like to quit before completing " +
"the authorization.",
)
log.debug("webbrowser_open", url=authorize_url)
webbrowser.open_new(authorize_url)

code = http_server.run(
state=state,
hostname=self.config["http_server.hostname"],
port=self.config["http_server.port"],
)

log.info("oauth_authorize", code=code)
try:
refresh_token = self.reddit.auth.authorize(code)
except PrawcoreException as exception:
log.exception("oauth_authorize_failed")
raise RedditError(
"Failed to retrieve the Refresh Token.",
) from exception

if not refresh_token:
log.error("oauth_authorize_missing_token")
raise AuthorizationError(
"Reddit hasn't provided the Refresh Token.",
)
7 changes: 7 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import os
from datetime import datetime
from unittest import mock

import pytest
from dotty_dict import dotty
Expand Down Expand Up @@ -63,3 +64,9 @@ def post():
title="Slow Start - Episode 1 Discussion",
body=body,
)


@pytest.fixture()
def reddit():
"""Return mock Reddit class."""
return mock.Mock()
4 changes: 4 additions & 0 deletions tests/test_config/config_example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,9 @@ scheduled_post_file: "${home_dir}${ps}slow_start_rewatch${ps}scheduled_post.yml"

username: cute_tester

http_server:
hostname: "127.0.0.1"
port: 65000

timer:
refresh_interval: 200 # milliseconds
88 changes: 88 additions & 0 deletions tests/test_oauth_helper.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# -*- coding: utf-8 -*-

from unittest.mock import call, patch

import pytest
from prawcore.exceptions import PrawcoreException

from slow_start_rewatch.exceptions import AuthorizationError, RedditError
from slow_start_rewatch.http_server import http_server
from slow_start_rewatch.reddit.oauth_helper import OAuthHelper
from tests.conftest import (
HTTP_SERVER_HOSTNAME,
HTTP_SERVER_PORT,
OAUTH_CODE,
MockConfig,
)


@patch.object(http_server, "run")
@patch("webbrowser.open_new")
def test_successful_authorization(
webbrowser_open_new,
http_server_run,
oauth_helper_config,
reddit,
):
"""Test successful OAuth authorization."""
http_server_run.return_value = OAUTH_CODE

oauth_helper = OAuthHelper(oauth_helper_config, reddit)

oauth_helper.authorize()

assert webbrowser_open_new.call_count == 1
assert reddit.auth.authorize.call_args == call(OAUTH_CODE)


@patch.object(http_server, "run")
@patch("webbrowser.open_new")
def test_failed_authorization(
webbrowser_open_new,
http_server_run,
oauth_helper_config,
reddit,
):
"""Test an error during OAuth authorization."""
http_server_run.side_effect = AuthorizationError("Tsundere response")

oauth_helper = OAuthHelper(oauth_helper_config, reddit)

with pytest.raises(AuthorizationError):
oauth_helper.authorize()


@patch.object(http_server, "run")
@patch("webbrowser.open_new")
def test_failed_token_retrieval(
webbrowser_open_new,
http_server_run,
oauth_helper_config,
reddit,
):
"""Test errors during the refresh token retrieval."""
http_server_run.return_value = OAUTH_CODE
reddit.auth.authorize.side_effect = [
PrawcoreException,
None,
]

oauth_helper = OAuthHelper(oauth_helper_config, reddit)

with pytest.raises(RedditError):
oauth_helper.authorize()

with pytest.raises(AuthorizationError):
oauth_helper.authorize()


@pytest.fixture()
def oauth_helper_config():
"""Return mock Config for testing OAuthHelper."""
return MockConfig({
"reddit": {"oauth_scope": ["headpat", "hug"]},
"http_server": {
"hostname": HTTP_SERVER_HOSTNAME,
"port": HTTP_SERVER_PORT,
},
})

0 comments on commit 70740ab

Please sign in to comment.