Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add plugin scope to oauth token request. and tests #42

Merged
merged 1 commit into from
May 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
223 changes: 142 additions & 81 deletions canvas_cli/apps/auth/tests.py
Original file line number Diff line number Diff line change
@@ -1,81 +1,142 @@
# def test_get_api_token_without_existing_host_or_client_credentials_raises_exception() -> None:
# """Test getting an api token with no default host or client credentials."""

# runner.invoke(app, "auth remove-api-client-credentials http://george.com")

# result_without_host = runner.invoke(app, "auth get-api-token")
# assert result_without_host.exit_code == 2
# assert (
# "Invalid value: Please specify a host or set a default via the `auth` command"
# in result_without_host.stdout
# )

# result_without_client_id = runner.invoke(app, "auth get-api-token --host http://george.com")
# assert result_without_client_id.exit_code == 2
# print(result_without_client_id.stdout)
# assert (
# "Invalid value: Please specify a client_id and client_secret or add them via"
# in result_without_client_id.stdout
# )

# result_without_client_secret = runner.invoke(
# app, "auth get-api-token --host http://george.com --client-id mock-client-id"
# )
# assert result_without_client_secret.exit_code == 2
# assert (
# "Invalid value: Please specify a client_id and client_secret or add them via"
# in result_without_client_secret.stdout
# )


# @patch("requests.post")
# def test_get_api_token_requests_token_from_the_host_if_not_stored_in_context(
# mock_post: MagicMock,
# ) -> None:
# class FakeResponse:
# status_code = 200

# def json(self) -> dict:
# return {"access_token": "a-valid-api-token", "expires_in": 3600}

# mock_post.return_value = FakeResponse()

# result = runner.invoke(
# app,
# "auth get-api-token --host http://george.com --client-id mock-client-id --client-secret mock-client-secret",
# )
# mock_post.assert_called_once()
# assert result.exit_code == 0
# assert '{"success": true, "token": "a-valid-api-token"}' in result.stdout
# assert context.token_expiration_date is not None
# assert datetime.fromisoformat(context.token_expiration_date) > datetime.now()


# @patch("keyring.get_password")
# @patch("requests.post")
# def test_get_api_token_uses_token_stored_in_context_first(
# mock_post: MagicMock,
# mock_get_password: MagicMock,
# ) -> None:
# mock_get_password.return_value = "a-valid-api-token"
# result = runner.invoke(
# app,
# "auth get-api-token --host http://george.com --client-id mock-client-id --client-secret mock-client-secret",
# )
# assert result.exit_code == 0
# mock_get_password.assert_called_once_with(
# "canvas_cli.apps.auth.utils", "http://george.com|token"
# )
# mock_post.assert_not_called()


# def test_get_api_token_uses_credentials_stored_in_context() -> None:
# runner.invoke(
# app,
# "auth add-api-client-credentials --host http://george.com --client-id mock-client-id --client-secret mock-client-secret --is-default",
# )
# assert context.default_host == "http://george.com"

# result = runner.invoke(app, "auth get-api-token")
# assert result.exit_code == 0
# assert '{"success": true, "token": "a-valid-api-token"}' in result.stdout
from typing import Any
from unittest.mock import MagicMock, patch

import pytest

from canvas_cli.apps.auth import get_or_request_api_token


@pytest.fixture
def valid_token_response() -> Any:
class TokenResponse:
status_code = 200

def json(self) -> dict:
return {"access_token": "a-valid-api-token", "expires_in": 3600}

return TokenResponse()


@pytest.fixture
def error_token_response() -> Any:
class TokenResponse:
status_code = 500

return TokenResponse()


@pytest.fixture
def expired_token_response() -> Any:
class TokenResponse:
status_code = 200

def json(self) -> dict:
return {"access_token": "a-valid-api-token", "expires_in": -1}

return TokenResponse()


@patch("keyring.get_password")
@patch("requests.Session.post")
@patch("canvas_cli.apps.auth.utils.is_token_valid")
def test_get_or_request_api_token_uses_stored_token(
mock_is_token_valid: MagicMock,
mock_post: MagicMock,
mock_get_password: MagicMock,
valid_token_response: Any,
) -> None:
mock_is_token_valid.return_value = True
mock_get_password.return_value = "a-stored-valid-token"
mock_post.return_value = valid_token_response

token = get_or_request_api_token("http://localhost:8000")

assert token == "a-stored-valid-token"
mock_post.assert_not_called()


@patch("keyring.set_password")
@patch("keyring.get_password")
@patch("requests.Session.post")
@patch("canvas_cli.apps.auth.utils.get_api_client_credentials")
def test_get_or_request_api_token_requests_token_if_none_stored(
mock_client_credentials: MagicMock,
mock_post: MagicMock,
mock_get_password: MagicMock,
mock_set_password: MagicMock,
valid_token_response: Any,
) -> None:
mock_client_credentials.return_value = "client_id=id&client_secret=secret"
mock_get_password.return_value = None
mock_post.return_value = valid_token_response

token = get_or_request_api_token("http://localhost:8000")

assert token == "a-valid-api-token"
mock_post.assert_called_once_with(
"http://localhost:8000/auth/token/",
headers={"Content-Type": "application/x-www-form-urlencoded"},
json=None,
data="grant_type=client_credentials&scope=system/Plugins.*&client_id=id&client_secret=secret",
)
mock_set_password.assert_called_with(
"canvas_cli.apps.auth.utils",
username="http://localhost:8000|token",
password="a-valid-api-token",
)


@patch("keyring.get_password")
@patch("requests.Session.post")
@patch("canvas_cli.apps.auth.utils.get_api_client_credentials")
def test_get_or_request_api_token_raises_exception_if_error_token_response(
mock_client_credentials: MagicMock,
mock_post: MagicMock,
mock_get_password: MagicMock,
error_token_response: Any,
) -> None:
mock_client_credentials.return_value = "client_id=id&client_secret=secret"
mock_get_password.return_value = None
mock_post.return_value = error_token_response

with pytest.raises(Exception) as e:
get_or_request_api_token("http://localhost:8000")

assert "Unable to get a valid access token from the given host 'http://localhost:8000'" in repr(
e
)

mock_post.assert_called_once_with(
"http://localhost:8000/auth/token/",
headers={"Content-Type": "application/x-www-form-urlencoded"},
json=None,
data="grant_type=client_credentials&scope=system/Plugins.*&client_id=id&client_secret=secret",
)


@patch("keyring.get_password")
@patch("requests.Session.post")
@patch("canvas_cli.apps.auth.utils.get_api_client_credentials")
def test_get_or_request_api_token_raises_exception_if_expired_token(
mock_client_credentials: MagicMock,
mock_post: MagicMock,
mock_get_password: MagicMock,
expired_token_response: Any,
) -> None:
mock_client_credentials.return_value = "client_id=id&client_secret=secret"
mock_get_password.return_value = None
mock_post.return_value = expired_token_response

with pytest.raises(Exception) as e:
get_or_request_api_token("http://localhost:8000")

assert (
"A valid token could not be acquired from the given host 'http://localhost:8000'" in repr(e)
)

mock_post.assert_called_once_with(
"http://localhost:8000/auth/token/",
headers={"Content-Type": "application/x-www-form-urlencoded"},
json=None,
data="grant_type=client_credentials&scope=system/Plugins.*&client_id=id&client_secret=secret",
)
22 changes: 9 additions & 13 deletions canvas_cli/apps/auth/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,11 @@ def get_config() -> configparser.ConfigParser:
raise Exception(
f"""Please add your configuration file at '{CONFIG_PATH}' with the following format:

[my-canvas-instance]
[my-canvas-subdomain]
client_id=myclientid
client_secret=myclientsecret

[my-dev-canvas-instance]
[my-dev-canvas-subdomain]
client_id=devclientid
client_secret=devclientsecret
is_default=true
Expand Down Expand Up @@ -91,16 +91,17 @@ def get_default_host(host: str | None = None) -> str:

def request_api_token(host: str, api_client_credentials: str) -> dict:
"""Request an api token using the provided client_id and client_secret."""
grant_type = "grant_type=client_credentials"
scope = "scope=system/Plugins.*"

http = Http()
token_response = http.post(
f"{host}/auth/token/",
headers={"Content-Type": "application/x-www-form-urlencoded"},
data=f"grant_type=client_credentials&{api_client_credentials}",
data=f"{grant_type}&{scope}&{api_client_credentials}",
)
if token_response.status_code != requests.codes.ok:
raise Exception(
"Unable to get a valid access token. Please check your host, client_id, and client_secret"
)
raise Exception(f"Unable to get a valid access token from the given host '{host}'")
return token_response.json()


Expand Down Expand Up @@ -130,22 +131,17 @@ def get_or_request_api_token(host: str | None = None) -> str:

host_token_key = f"{host}|token"
token = get_password(host_token_key)

if token and is_token_valid(host_token_key):
return token

api_client_credentials = get_api_client_credentials(host)

if not (token_response := request_api_token(host, api_client_credentials)):
raise Exception(
"A token could not be acquired from the given host, client_id, and client_secret"
)
raise Exception(f"A token could not be acquired from the given host '{host}'")

token_expiration_date = datetime.now() + timedelta(seconds=token_response["expires_in"])
if not is_token_valid(host_token_key, token_expiration_date):
raise Exception(
"A valid token could not be acquired from the given host, client_id, and client_secret"
)
raise Exception(f"A valid token could not be acquired from the given host '{host}'")

new_token = token_response["access_token"]
set_password(host_token_key, new_token)
Expand Down
Loading