diff --git a/.github/workflows/Build.yml b/.github/workflows/Build.yml index 231afbb..d2a64c7 100644 --- a/.github/workflows/Build.yml +++ b/.github/workflows/Build.yml @@ -21,7 +21,7 @@ jobs: - name: Setup Python uses: actions/setup-python@v5 with: - python-version: '3.11' + python-version: '3.x' - name: Install Poetry uses: snok/install-poetry@v1 diff --git a/.github/workflows/Release.yml b/.github/workflows/Release.yml index 0bb922c..21eff1e 100644 --- a/.github/workflows/Release.yml +++ b/.github/workflows/Release.yml @@ -12,7 +12,7 @@ jobs: name: Get Release Version runs-on: ubuntu-latest outputs: - version: ${{ steps.get_version.outputs.version }} + version: ${{ steps.get_version.outputs.version }} steps: - name: Determine version from release tag diff --git a/.github/workflows/Test.yml b/.github/workflows/Test.yml index 139fb02..9dce94a 100644 --- a/.github/workflows/Test.yml +++ b/.github/workflows/Test.yml @@ -8,10 +8,16 @@ jobs: name: Unit Tests runs-on: ubuntu-latest + services: + api: + image: ghcr.io/pitt-crc/keystone-api:latest + ports: + - 8000:8000 + strategy: fail-fast: false matrix: - python-version: [ "3.8", "3.9", "3.10", "3.11", "3.12" ] + python-version: [ "3.9", "3.10", "3.11", "3.12" ] steps: - name: Checkout repository diff --git a/README.md b/README.md index a133843..5e3748b 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![Codacy Badge](https://app.codacy.com/project/badge/Grade/e627ebe242104565bd932665703357ca)](https://app.codacy.com/gh/pitt-crc/keystone-python-client/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade) -A light-weight Python client for wrapping the Keystone API. +Official Python client for ineracting with the Keystone API. ## Resources diff --git a/keystone_client/__init__.py b/keystone_client/__init__.py index 421945b..02bad32 100644 --- a/keystone_client/__init__.py +++ b/keystone_client/__init__.py @@ -1 +1,3 @@ -from .client import * +"""A light-weight Python client for wrapping the Keystone API.""" + +from .client import KeystoneClient diff --git a/keystone_client/authentication.py b/keystone_client/authentication.py new file mode 100644 index 0000000..ab11bb8 --- /dev/null +++ b/keystone_client/authentication.py @@ -0,0 +1,173 @@ +"""User authentication and credential management.""" + +from __future__ import annotations + +from datetime import datetime +from warnings import warn + +import jwt +import requests + +from keystone_client.schema import Schema + + +class JWT: + """JWT authentication tokens""" + + def __init__(self, access: str, refresh: str, algorithm='HS256') -> None: + """Initialize a new pair of JWT tokens + + Args: + access: The access token + refresh: The refresh token + algorithm: The algorithm used for encoding the JWT + """ + + self.algorithm = algorithm + self.access = access + self.refresh = refresh + + def _date_from_token(self, token: str) -> datetime: + """Return a token's expiration datetime""" + + token_data = jwt.decode(token, options={"verify_signature": False}, algorithms=self.algorithm) + exp = datetime.fromtimestamp(token_data["exp"]) + return exp + + @property + def access_expiration(self) -> datetime: + """Return the expiration datetime of the JWT access token""" + + return self._date_from_token(self.access) + + @property + def refresh_expiration(self) -> datetime: + """Return the expiration datetime of the JWT refresh token""" + + return self._date_from_token(self.refresh) + + +class AuthenticationManager: + """User authentication and JWT token manager""" + + def __init__(self, url: str, schema: Schema = Schema()) -> None: + """Initialize the class + + Args: + url: Base URL for the authentication API + schema: Schema defining API endpoints for fetching/managing JWTs + """ + + self.jwt: JWT | None = None + self.auth_url = schema.auth.new.join_url(url) + self.refresh_url = schema.auth.refresh.join_url(url) + self.blacklist_url = schema.auth.blacklist.join_url(url) + + def is_authenticated(self) -> bool: + """Return whether the client instance has active credentials""" + + if self.jwt is None: + return False + + now = datetime.now() + access_token_valid = self.jwt.access_expiration > now + access_token_refreshable = self.jwt.refresh_expiration > now + return access_token_valid or access_token_refreshable + + def get_auth_headers(self, refresh: bool = True, timeout: int = None) -> dict[str, str]: + """Return headers data for authenticating API requests + + The returned dictionary is empty when not authenticated. + + Args: + refresh: Automatically refresh the JWT credentials if necessary + timeout: Seconds before the token refresh request times out + + Returns: + A dictionary with header ata for JWT authentication + """ + + if refresh: + self.refresh(timeout=timeout) + + if not self.is_authenticated(): + return dict() + + return {"Authorization": f"Bearer {self.jwt.access}"} + + def login(self, username: str, password: str, timeout: int = None) -> None: + """Log in to the Keystone API and cache the returned credentials + + Args: + username: The authentication username + password: The authentication password + timeout: Seconds before the request times out + + Raises: + requests.HTTPError: If the login request fails + """ + + response = requests.post( + self.auth_url, + json={"username": username, "password": password}, + timeout=timeout + ) + + response.raise_for_status() + response_data = response.json() + self.jwt = JWT(response_data.get("access"), response_data.get("refresh")) + + def logout(self, timeout: int = None) -> None: + """Log out of the current session and blacklist any current credentials + + Args: + timeout: Seconds before the request times out + """ + + # Tell the API to blacklist the current token + if self.jwt is not None: + response = requests.post( + self.blacklist_url, + data={"refresh": self.jwt.refresh}, + timeout=timeout + ) + + try: + response.raise_for_status() + + except Exception as error: + warn(f"Token blacklist request failed: {error}") + + self.jwt = None + + def refresh(self, force: bool = False, timeout: int = None) -> None: + """Refresh the current session credetials if necessary + + This method will do nothing and exit silently if the current session + has not been authenticated. + + Args: + timeout: Seconds before the request times out + force: Refresh the access token even if it has not expired yet + """ + + if self.jwt is None: + return + + # Don't refresh the token if it's not necessary + now = datetime.now() + if self.jwt.access_expiration > now and not force: + return + + # Alert the user when a refresh is not possible + if self.jwt.refresh_expiration > now: + raise RuntimeError("Refresh token has expired. Login again to continue.") + + response = requests.post( + self.refresh_url, + data={"refresh": self.jwt.refresh}, + timeout=timeout + ) + + response.raise_for_status() + self.jwt.refresh = response.json().get("refresh") diff --git a/keystone_client/client.py b/keystone_client/client.py index 66cb0e1..efec9a5 100644 --- a/keystone_client/client.py +++ b/keystone_client/client.py @@ -7,50 +7,23 @@ from __future__ import annotations -from collections import namedtuple -from datetime import datetime -from functools import partial -from typing import * -from warnings import warn +from functools import cached_property +from typing import Literal, Union +from urllib.parse import urljoin -import jwt import requests -__all__ = ["KeystoneClient"] +from keystone_client.authentication import AuthenticationManager +from keystone_client.schema import Endpoint, Schema -# Custom types -ContentType = Literal["json", "text", "content"] -ResponseContent = Union[Dict[str, Any], str, bytes] -QueryResult = Union[None, dict, List[dict]] +DEFAULT_TIMEOUT = 15 HTTPMethod = Literal["get", "post", "put", "patch", "delete"] -# API schema mapping human-readable, python-friendly names to API endpoints -Schema = namedtuple("Schema", [ - "allocations", - "clusters", - "requests", - "research_groups", - "users", -]) +class HTTPClient: + """Low level API client for sending standard HTTP operations""" -class KeystoneClient: - """Client class for submitting requests to the Keystone API""" - - # Default API behavior - default_timeout = 15 - - # API endpoints - authentication_new = "authentication/new/" - authentication_blacklist = "authentication/blacklist/" - authentication_refresh = "authentication/refresh/" - schema = Schema( - allocations="allocations/allocations/", - clusters="allocations/clusters", - requests="allocations/requests/", - research_groups="users/researchgroups/", - users="users/users/", - ) + schema = Schema() def __init__(self, url: str) -> None: """Initialize the class @@ -59,89 +32,44 @@ def __init__(self, url: str) -> None: url: The base URL for a running Keystone API server """ - self._url = url.rstrip('/') - self._api_version: Optional[str] = None - self._access_token: Optional[str] = None - self._access_expiration: Optional[datetime] = None - self._refresh_token: Optional[str] = None - self._refresh_expiration: Optional[datetime] = None - - def __new__(cls, *args, **kwargs) -> KeystoneClient: - """Dynamically create CRUD methods for each endpoint in the API schema - - Dynamic method are only generated of they do not already implemented - in the class definition. - """ - - instance: KeystoneClient = super().__new__(cls) - for key, endpoint in zip(cls.schema._fields, cls.schema): - - # Create a retrieve method - retrieve_name = f"retrieve_{key}" - if not hasattr(instance, retrieve_name): - retrieve_method = partial(instance._retrieve_records, _endpoint=endpoint) - setattr(instance, f"retrieve_{key}", retrieve_method) + self._url = url.rstrip('/') + '/' + self._auth = AuthenticationManager(url, self.schema) - return instance + @property + def url(self) -> str: + """Return the server URL""" - def _retrieve_records( - self, - _endpoint: str, - pk: Optional[int] = None, - filters: Optional[dict] = None, - timeout=default_timeout - ) -> QueryResult: - """Retrieve data from the specified endpoint with optional primary key and filters + return self._url - A single record is returned when specifying a primary key, otherwise the returned - object is a list of records. In either case, the return value is `None` when no data - is available for the query. + def login(self, username: str, password: str, timeout: int = DEFAULT_TIMEOUT) -> None: + """Authenticate a new user session Args: - pk: Optional primary key to fetch a specific record - filters: Optional query parameters to include in the request + username: The authentication username + password: The authentication password timeout: Seconds before the request times out - Returns: - The response from the API in JSON format + Raises: + requests.HTTPError: If the login request fails """ - if pk is not None: - _endpoint = f"{_endpoint}/{pk}/" + self._auth.login(username, password, timeout) # pragma: nocover - try: - response = self.http_get(_endpoint, params=filters, timeout=timeout) - response.raise_for_status() - return response.json() - - except requests.HTTPError as exception: - if exception.response.status_code == 404: - return None + def logout(self, timeout: int = DEFAULT_TIMEOUT) -> None: + """Log out and blacklist any active credentials - raise - - def _get_headers(self) -> Dict[str, str]: - """Return header data for API requests - - Returns: - A dictionary with header data + Args: + timeout: Seconds before the blacklist request times out """ - if not self._access_token: - return dict() + self._auth.logout(timeout) # pragma: nocover - return { - "Authorization": f"Bearer {self._access_token}", - "Content-Type": "application/json" - } + def is_authenticated(self) -> bool: + """Return whether the client instance has active credentials""" - def _send_request( - self, - method: HTTPMethod, - endpoint: str, - timeout: int = default_timeout, - **kwargs - ) -> requests.Response: + return self._auth.is_authenticated() # pragma: nocover + + def _send_request(self, method: HTTPMethod, endpoint: str, **kwargs) -> requests.Response: """Send an HTTP request Args: @@ -153,24 +81,16 @@ def _send_request( An HTTP response """ - self._refresh_tokens(force=False, timeout=timeout) - - url = f'{self.url}/{endpoint}' + url = urljoin(self.url, endpoint) response = requests.request(method, url, **kwargs) response.raise_for_status() return response - @property - def url(self) -> str: - """Return the server URL""" - - return self._url - def http_get( self, endpoint: str, - params: Optional[Dict[str, Any]] = None, - timeout: int = default_timeout + params: dict[str, any] | None = None, + timeout: int = DEFAULT_TIMEOUT ) -> requests.Response: """Send a GET request to an API endpoint @@ -186,13 +106,19 @@ def http_get( requests.HTTPError: If the request returns an error code """ - return self._send_request("get", endpoint, params=params, timeout=timeout) + return self._send_request( + "get", + endpoint, + params=params, + headers=self._auth.get_auth_headers(), + timeout=timeout + ) def http_post( self, endpoint: str, - data: Optional[Dict[str, Any]] = None, - timeout: int = default_timeout + data: dict[str, any] | None = None, + timeout: int = DEFAULT_TIMEOUT ) -> requests.Response: """Send a POST request to an API endpoint @@ -208,13 +134,19 @@ def http_post( requests.HTTPError: If the request returns an error code """ - return self._send_request("post", endpoint, data=data, timeout=timeout) + return self._send_request( + "post", + endpoint, + data=data, + headers=self._auth.get_auth_headers(), + timeout=timeout + ) def http_patch( self, endpoint: str, - data: Optional[Dict[str, Any]] = None, - timeout: int = default_timeout + data: dict[str, any] | None = None, + timeout: int = DEFAULT_TIMEOUT ) -> requests.Response: """Send a PATCH request to an API endpoint @@ -230,13 +162,19 @@ def http_patch( requests.HTTPError: If the request returns an error code """ - return self._send_request("patch", endpoint, data=data, timeout=timeout) + return self._send_request( + "patch", + endpoint, + data=data, + headers=self._auth.get_auth_headers(), + timeout=timeout + ) def http_put( self, endpoint: str, - data: Optional[Dict[str, Any]] = None, - timeout: int = default_timeout + data: dict[str, any] | None = None, + timeout: int = DEFAULT_TIMEOUT ) -> requests.Response: """Send a PUT request to an endpoint @@ -252,12 +190,18 @@ def http_put( requests.HTTPError: If the request returns an error code """ - return self._send_request("put", endpoint, data=data, timeout=timeout) + return self._send_request( + "put", + endpoint, + data=data, + headers=self._auth.get_auth_headers(), + timeout=timeout + ) def http_delete( self, endpoint: str, - timeout: int = default_timeout + timeout: int = DEFAULT_TIMEOUT ) -> requests.Response: """Send a DELETE request to an endpoint @@ -272,111 +216,152 @@ def http_delete( requests.HTTPError: If the request returns an error code """ - return self._send_request("delete", endpoint, timeout=timeout) + return self._send_request( + "delete", + endpoint, + headers=self._auth.get_auth_headers(), + timeout=timeout + ) - @property - def is_authenticated(self) -> None: - """Return whether the client instance has been authenticated""" - now = datetime.now() - has_token = self._refresh_token is not None - access_token_valid = self._access_expiration is not None and self._access_expiration > now - access_token_refreshable = self._refresh_expiration is not None and self._refresh_expiration > now - return has_token and (access_token_valid or access_token_refreshable) +class KeystoneClient(HTTPClient): + """Client class for submitting requests to the Keystone API""" - @property + @cached_property def api_version(self) -> str: """Return the version number of the API server""" - if self._api_version is None: - response = self.http_get("version") - response.raise_for_status() - self._api_version = response.text + response = self.http_get("version") + response.raise_for_status() + return response.text - return self._api_version + def __new__(cls, *args, **kwargs) -> KeystoneClient: + """Dynamically create CRUD methods for each data endpoint in the API schema""" - def login(self, username: str, password: str, timeout: int = default_timeout) -> None: - """Log in to the Keystone API and cache the returned JWT + new: KeystoneClient = super().__new__(cls) - Args: - username: The authentication username - password: The authentication password - timeout: Seconds before the request times out + new.create_allocation = new._create_factory(cls.schema.data.allocations) + new.retrieve_allocation = new._retrieve_factory(cls.schema.data.allocations) + new.update_allocation = new._update_factory(cls.schema.data.allocations) + new.delete_allocation = new._delete_factory(cls.schema.data.allocations) - Raises: - requests.HTTPError: If the login request fails - """ + new.create_request = new._create_factory(cls.schema.data.requests) + new.retrieve_request = new._retrieve_factory(cls.schema.data.requests) + new.update_request = new._update_factory(cls.schema.data.requests) + new.delete_request = new._delete_factory(cls.schema.data.requests) - response = requests.post( - f"{self.url}/{self.authentication_new}", - json={"username": username, "password": password}, - timeout=timeout - ) + new.create_research_group = new._create_factory(cls.schema.data.research_groups) + new.retrieve_research_group = new._retrieve_factory(cls.schema.data.research_groups) + new.update_research_group = new._update_factory(cls.schema.data.research_groups) + new.delete_research_group = new._delete_factory(cls.schema.data.research_groups) - response.raise_for_status() + new.create_user = new._create_factory(cls.schema.data.users) + new.retrieve_user = new._retrieve_factory(cls.schema.data.users) + new.update_user = new._update_factory(cls.schema.data.users) + new.delete_user = new._delete_factory(cls.schema.data.users) - # Parse data from the refresh token - refresh_payload = jwt.decode(self._refresh_token) - self._refresh_token = response.json().get("refresh") - self._refresh_expiration = datetime.fromtimestamp(refresh_payload["exp"]) + return new - # Parse data from the access token - access_payload = jwt.decode(self._access_token) - self._access_token = response.json().get("access") - self._access_expiration = datetime.fromtimestamp(access_payload["exp"]) + def _create_factory(self, endpoint: Endpoint) -> callable: + """Factory function for data creation methods""" - def logout(self, timeout: int = default_timeout) -> None: - """Log out and blacklist any active JWTs + def create_record(**data) -> None: + """Create an API record - Args: - timeout: Seconds before the request times out - """ + Args: + **data: New record values + + Returns: + A copy of the updated record + """ + + url = endpoint.join_url(self.url) + response = self.http_post(url, data=data) + response.raise_for_status() + return response.json() + + return create_record - if self._refresh_token is not None: - response = requests.post( - f"{self.url}/{self.authentication_blacklist}", - data={"refresh": self._refresh_token}, - timeout=timeout - ) + def _retrieve_factory(self, endpoint: Endpoint) -> callable: + """Factory function for data retrieval methods""" + + def retrieve_record( + pk: int | None = None, + filters: dict | None = None, + timeout=DEFAULT_TIMEOUT + ) -> Union[None, dict, list[dict]]: + """Retrieve one or more API records + + A single record is returned when specifying a primary key, otherwise the returned + object is a list of records. In either case, the return value is `None` when no data + is available for the query. + + Args: + pk: Optional primary key to fetch a specific record + filters: Optional query parameters to include in the request + timeout: Seconds before the request times out + + Returns: + The data record(s) or None + """ + + url = endpoint.join_url(self.url, pk) try: + response = self.http_get(url, params=filters, timeout=timeout) response.raise_for_status() + return response.json() - except Exception as exception: - warn(str(exception)) + except requests.HTTPError as exception: + if exception.response.status_code == 404: + return None - self._refresh_token = None - self._refresh_expiration = None - self._access_token = None - self._access_expiration = None + raise - def _refresh_tokens(self, force: bool = True, timeout: int = default_timeout) -> None: - """Refresh the JWT access token + return retrieve_record - Args: - timeout: Seconds before the request times out - force: Refresh the access token even if it has not expired yet - """ + def _update_factory(self, endpoint: Endpoint) -> callable: + """Factory function for data update methods""" - if not self.is_authenticated: - return + def update_record(pk: int, data) -> dict: + """Update an API record - # Don't refresh the token if it's not necessary - now = datetime.now() - if self._access_expiration > now and not force: - return + Args: + pk: Primary key of the record to update + data: New record values - # Alert the user when a refresh is not possible - if self._refresh_expiration > now: - raise RuntimeError("Refresh token has expired. Login again to continue.") + Returns: + A copy of the updated record + """ - response = requests.post( - f"{self.url}/{self.authentication_refresh}", - data={"refresh": self._refresh_token}, - timeout=timeout - ) + url = endpoint.join_url(self.url, pk) + response = self.http_patch(url, data=data) + response.raise_for_status() + return response.json() - response.raise_for_status() - refresh_payload = jwt.decode(self._refresh_token) - self._refresh_token = response.json().get("refresh") - self._refresh_expiration = datetime.fromtimestamp(refresh_payload["exp"]) + return update_record + + def _delete_factory(self, endpoint: Endpoint) -> callable: + """Factory function for data deletion methods""" + + def delete_record(pk: int, raise_not_exists: bool = False) -> None: + """Delete an API record + + Args: + pk: Primary key of the record to delete + raise_not_exists: Raise an error if the record does not exist + """ + + url = endpoint.join_url(self.url, pk) + + try: + response = self.http_delete(url) + response.raise_for_status() + + except requests.HTTPError as exception: + if exception.response.status_code == 404 and not raise_not_exists: + return + + raise + + return delete_record diff --git a/keystone_client/schema.py b/keystone_client/schema.py new file mode 100644 index 0000000..93c2de7 --- /dev/null +++ b/keystone_client/schema.py @@ -0,0 +1,56 @@ +"""Schema objects used to define available API endpoints.""" + +import os.path +from dataclasses import dataclass, field +from os import path + + +class Endpoint(str): + """API endpoint agnostic th to baseAPI URL""" + + def join_url(self, base: str, *append) -> str: + """Join the endpoint with a base URL + + This method returns URLs in a format that avoids trailing slash + redirects from the Keystone API. + + Args: + base: The base URL + *append: Partial paths to append onto the url + + Returns: + The base URL join with the endpoint + """ + + url = os.path.join(base, self) + for partial_path in filter(lambda x: x is not None, append): + url = path.join(url, str(partial_path)) + + return url.rstrip('/') + '/' + + +@dataclass +class AuthSchema: + """Schema defining API endpoints used for JWT authentication""" + + new: Endpoint = Endpoint("authentication/new") + refresh: Endpoint = Endpoint("authentication/refresh") + blacklist: Endpoint = Endpoint("authentication/blacklist") + + +@dataclass +class DataSchema: + """Schema defining API endpoints for data access""" + + allocations: Endpoint = Endpoint("allocations/allocations") + requests: Endpoint = Endpoint("allocations/requests") + research_groups: Endpoint = Endpoint("users/researchgroups") + users: Endpoint = Endpoint("users/users") + + +@dataclass +class Schema: + """Schema defining the complete set of API endpoints""" + + auth: AuthSchema = field(default_factory=AuthSchema) + data: DataSchema = field(default_factory=DataSchema) diff --git a/poetry.lock b/poetry.lock index 567dfa0..8625b36 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. [[package]] name = "certifi" @@ -112,63 +112,63 @@ files = [ [[package]] name = "coverage" -version = "7.5.4" +version = "7.6.0" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.8" files = [ - {file = "coverage-7.5.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6cfb5a4f556bb51aba274588200a46e4dd6b505fb1a5f8c5ae408222eb416f99"}, - {file = "coverage-7.5.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2174e7c23e0a454ffe12267a10732c273243b4f2d50d07544a91198f05c48f47"}, - {file = "coverage-7.5.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2214ee920787d85db1b6a0bd9da5f8503ccc8fcd5814d90796c2f2493a2f4d2e"}, - {file = "coverage-7.5.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1137f46adb28e3813dec8c01fefadcb8c614f33576f672962e323b5128d9a68d"}, - {file = "coverage-7.5.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b385d49609f8e9efc885790a5a0e89f2e3ae042cdf12958b6034cc442de428d3"}, - {file = "coverage-7.5.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b4a474f799456e0eb46d78ab07303286a84a3140e9700b9e154cfebc8f527016"}, - {file = "coverage-7.5.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5cd64adedf3be66f8ccee418473c2916492d53cbafbfcff851cbec5a8454b136"}, - {file = "coverage-7.5.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e564c2cf45d2f44a9da56f4e3a26b2236504a496eb4cb0ca7221cd4cc7a9aca9"}, - {file = "coverage-7.5.4-cp310-cp310-win32.whl", hash = "sha256:7076b4b3a5f6d2b5d7f1185fde25b1e54eb66e647a1dfef0e2c2bfaf9b4c88c8"}, - {file = "coverage-7.5.4-cp310-cp310-win_amd64.whl", hash = "sha256:018a12985185038a5b2bcafab04ab833a9a0f2c59995b3cec07e10074c78635f"}, - {file = "coverage-7.5.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:db14f552ac38f10758ad14dd7b983dbab424e731588d300c7db25b6f89e335b5"}, - {file = "coverage-7.5.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3257fdd8e574805f27bb5342b77bc65578e98cbc004a92232106344053f319ba"}, - {file = "coverage-7.5.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3a6612c99081d8d6134005b1354191e103ec9705d7ba2754e848211ac8cacc6b"}, - {file = "coverage-7.5.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d45d3cbd94159c468b9b8c5a556e3f6b81a8d1af2a92b77320e887c3e7a5d080"}, - {file = "coverage-7.5.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed550e7442f278af76d9d65af48069f1fb84c9f745ae249c1a183c1e9d1b025c"}, - {file = "coverage-7.5.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7a892be37ca35eb5019ec85402c3371b0f7cda5ab5056023a7f13da0961e60da"}, - {file = "coverage-7.5.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8192794d120167e2a64721d88dbd688584675e86e15d0569599257566dec9bf0"}, - {file = "coverage-7.5.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:820bc841faa502e727a48311948e0461132a9c8baa42f6b2b84a29ced24cc078"}, - {file = "coverage-7.5.4-cp311-cp311-win32.whl", hash = "sha256:6aae5cce399a0f065da65c7bb1e8abd5c7a3043da9dceb429ebe1b289bc07806"}, - {file = "coverage-7.5.4-cp311-cp311-win_amd64.whl", hash = "sha256:d2e344d6adc8ef81c5a233d3a57b3c7d5181f40e79e05e1c143da143ccb6377d"}, - {file = "coverage-7.5.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:54317c2b806354cbb2dc7ac27e2b93f97096912cc16b18289c5d4e44fc663233"}, - {file = "coverage-7.5.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:042183de01f8b6d531e10c197f7f0315a61e8d805ab29c5f7b51a01d62782747"}, - {file = "coverage-7.5.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a6bb74ed465d5fb204b2ec41d79bcd28afccf817de721e8a807d5141c3426638"}, - {file = "coverage-7.5.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3d45ff86efb129c599a3b287ae2e44c1e281ae0f9a9bad0edc202179bcc3a2e"}, - {file = "coverage-7.5.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5013ed890dc917cef2c9f765c4c6a8ae9df983cd60dbb635df8ed9f4ebc9f555"}, - {file = "coverage-7.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1014fbf665fef86cdfd6cb5b7371496ce35e4d2a00cda501cf9f5b9e6fced69f"}, - {file = "coverage-7.5.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3684bc2ff328f935981847082ba4fdc950d58906a40eafa93510d1b54c08a66c"}, - {file = "coverage-7.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:581ea96f92bf71a5ec0974001f900db495488434a6928a2ca7f01eee20c23805"}, - {file = "coverage-7.5.4-cp312-cp312-win32.whl", hash = "sha256:73ca8fbc5bc622e54627314c1a6f1dfdd8db69788f3443e752c215f29fa87a0b"}, - {file = "coverage-7.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:cef4649ec906ea7ea5e9e796e68b987f83fa9a718514fe147f538cfeda76d7a7"}, - {file = "coverage-7.5.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cdd31315fc20868c194130de9ee6bfd99755cc9565edff98ecc12585b90be882"}, - {file = "coverage-7.5.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:02ff6e898197cc1e9fa375581382b72498eb2e6d5fc0b53f03e496cfee3fac6d"}, - {file = "coverage-7.5.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d05c16cf4b4c2fc880cb12ba4c9b526e9e5d5bb1d81313d4d732a5b9fe2b9d53"}, - {file = "coverage-7.5.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c5986ee7ea0795a4095ac4d113cbb3448601efca7f158ec7f7087a6c705304e4"}, - {file = "coverage-7.5.4-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5df54843b88901fdc2f598ac06737f03d71168fd1175728054c8f5a2739ac3e4"}, - {file = "coverage-7.5.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:ab73b35e8d109bffbda9a3e91c64e29fe26e03e49addf5b43d85fc426dde11f9"}, - {file = "coverage-7.5.4-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:aea072a941b033813f5e4814541fc265a5c12ed9720daef11ca516aeacd3bd7f"}, - {file = "coverage-7.5.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:16852febd96acd953b0d55fc842ce2dac1710f26729b31c80b940b9afcd9896f"}, - {file = "coverage-7.5.4-cp38-cp38-win32.whl", hash = "sha256:8f894208794b164e6bd4bba61fc98bf6b06be4d390cf2daacfa6eca0a6d2bb4f"}, - {file = "coverage-7.5.4-cp38-cp38-win_amd64.whl", hash = "sha256:e2afe743289273209c992075a5a4913e8d007d569a406ffed0bd080ea02b0633"}, - {file = "coverage-7.5.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b95c3a8cb0463ba9f77383d0fa8c9194cf91f64445a63fc26fb2327e1e1eb088"}, - {file = "coverage-7.5.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3d7564cc09dd91b5a6001754a5b3c6ecc4aba6323baf33a12bd751036c998be4"}, - {file = "coverage-7.5.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:44da56a2589b684813f86d07597fdf8a9c6ce77f58976727329272f5a01f99f7"}, - {file = "coverage-7.5.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e16f3d6b491c48c5ae726308e6ab1e18ee830b4cdd6913f2d7f77354b33f91c8"}, - {file = "coverage-7.5.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dbc5958cb471e5a5af41b0ddaea96a37e74ed289535e8deca404811f6cb0bc3d"}, - {file = "coverage-7.5.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a04e990a2a41740b02d6182b498ee9796cf60eefe40cf859b016650147908029"}, - {file = "coverage-7.5.4-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:ddbd2f9713a79e8e7242d7c51f1929611e991d855f414ca9996c20e44a895f7c"}, - {file = "coverage-7.5.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b1ccf5e728ccf83acd313c89f07c22d70d6c375a9c6f339233dcf792094bcbf7"}, - {file = "coverage-7.5.4-cp39-cp39-win32.whl", hash = "sha256:56b4eafa21c6c175b3ede004ca12c653a88b6f922494b023aeb1e836df953ace"}, - {file = "coverage-7.5.4-cp39-cp39-win_amd64.whl", hash = "sha256:65e528e2e921ba8fd67d9055e6b9f9e34b21ebd6768ae1c1723f4ea6ace1234d"}, - {file = "coverage-7.5.4-pp38.pp39.pp310-none-any.whl", hash = "sha256:79b356f3dd5b26f3ad23b35c75dbdaf1f9e2450b6bcefc6d0825ea0aa3f86ca5"}, - {file = "coverage-7.5.4.tar.gz", hash = "sha256:a44963520b069e12789d0faea4e9fdb1e410cdc4aab89d94f7f55cbb7fef0353"}, + {file = "coverage-7.6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dff044f661f59dace805eedb4a7404c573b6ff0cdba4a524141bc63d7be5c7fd"}, + {file = "coverage-7.6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a8659fd33ee9e6ca03950cfdcdf271d645cf681609153f218826dd9805ab585c"}, + {file = "coverage-7.6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7792f0ab20df8071d669d929c75c97fecfa6bcab82c10ee4adb91c7a54055463"}, + {file = "coverage-7.6.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d4b3cd1ca7cd73d229487fa5caca9e4bc1f0bca96526b922d61053ea751fe791"}, + {file = "coverage-7.6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7e128f85c0b419907d1f38e616c4f1e9f1d1b37a7949f44df9a73d5da5cd53c"}, + {file = "coverage-7.6.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a94925102c89247530ae1dab7dc02c690942566f22e189cbd53579b0693c0783"}, + {file = "coverage-7.6.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:dcd070b5b585b50e6617e8972f3fbbee786afca71b1936ac06257f7e178f00f6"}, + {file = "coverage-7.6.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d50a252b23b9b4dfeefc1f663c568a221092cbaded20a05a11665d0dbec9b8fb"}, + {file = "coverage-7.6.0-cp310-cp310-win32.whl", hash = "sha256:0e7b27d04131c46e6894f23a4ae186a6a2207209a05df5b6ad4caee6d54a222c"}, + {file = "coverage-7.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:54dece71673b3187c86226c3ca793c5f891f9fc3d8aa183f2e3653da18566169"}, + {file = "coverage-7.6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c7b525ab52ce18c57ae232ba6f7010297a87ced82a2383b1afd238849c1ff933"}, + {file = "coverage-7.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bea27c4269234e06f621f3fac3925f56ff34bc14521484b8f66a580aacc2e7d"}, + {file = "coverage-7.6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed8d1d1821ba5fc88d4a4f45387b65de52382fa3ef1f0115a4f7a20cdfab0e94"}, + {file = "coverage-7.6.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01c322ef2bbe15057bc4bf132b525b7e3f7206f071799eb8aa6ad1940bcf5fb1"}, + {file = "coverage-7.6.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:03cafe82c1b32b770a29fd6de923625ccac3185a54a5e66606da26d105f37dac"}, + {file = "coverage-7.6.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0d1b923fc4a40c5832be4f35a5dab0e5ff89cddf83bb4174499e02ea089daf57"}, + {file = "coverage-7.6.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4b03741e70fb811d1a9a1d75355cf391f274ed85847f4b78e35459899f57af4d"}, + {file = "coverage-7.6.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a73d18625f6a8a1cbb11eadc1d03929f9510f4131879288e3f7922097a429f63"}, + {file = "coverage-7.6.0-cp311-cp311-win32.whl", hash = "sha256:65fa405b837060db569a61ec368b74688f429b32fa47a8929a7a2f9b47183713"}, + {file = "coverage-7.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:6379688fb4cfa921ae349c76eb1a9ab26b65f32b03d46bb0eed841fd4cb6afb1"}, + {file = "coverage-7.6.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f7db0b6ae1f96ae41afe626095149ecd1b212b424626175a6633c2999eaad45b"}, + {file = "coverage-7.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bbdf9a72403110a3bdae77948b8011f644571311c2fb35ee15f0f10a8fc082e8"}, + {file = "coverage-7.6.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc44bf0315268e253bf563f3560e6c004efe38f76db03a1558274a6e04bf5d5"}, + {file = "coverage-7.6.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:da8549d17489cd52f85a9829d0e1d91059359b3c54a26f28bec2c5d369524807"}, + {file = "coverage-7.6.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0086cd4fc71b7d485ac93ca4239c8f75732c2ae3ba83f6be1c9be59d9e2c6382"}, + {file = "coverage-7.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1fad32ee9b27350687035cb5fdf9145bc9cf0a094a9577d43e909948ebcfa27b"}, + {file = "coverage-7.6.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:044a0985a4f25b335882b0966625270a8d9db3d3409ddc49a4eb00b0ef5e8cee"}, + {file = "coverage-7.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:76d5f82213aa78098b9b964ea89de4617e70e0d43e97900c2778a50856dac605"}, + {file = "coverage-7.6.0-cp312-cp312-win32.whl", hash = "sha256:3c59105f8d58ce500f348c5b56163a4113a440dad6daa2294b5052a10db866da"}, + {file = "coverage-7.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:ca5d79cfdae420a1d52bf177de4bc2289c321d6c961ae321503b2ca59c17ae67"}, + {file = "coverage-7.6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d39bd10f0ae453554798b125d2f39884290c480f56e8a02ba7a6ed552005243b"}, + {file = "coverage-7.6.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:beb08e8508e53a568811016e59f3234d29c2583f6b6e28572f0954a6b4f7e03d"}, + {file = "coverage-7.6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2e16f4cd2bc4d88ba30ca2d3bbf2f21f00f382cf4e1ce3b1ddc96c634bc48ca"}, + {file = "coverage-7.6.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6616d1c9bf1e3faea78711ee42a8b972367d82ceae233ec0ac61cc7fec09fa6b"}, + {file = "coverage-7.6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad4567d6c334c46046d1c4c20024de2a1c3abc626817ae21ae3da600f5779b44"}, + {file = "coverage-7.6.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d17c6a415d68cfe1091d3296ba5749d3d8696e42c37fca5d4860c5bf7b729f03"}, + {file = "coverage-7.6.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:9146579352d7b5f6412735d0f203bbd8d00113a680b66565e205bc605ef81bc6"}, + {file = "coverage-7.6.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:cdab02a0a941af190df8782aafc591ef3ad08824f97850b015c8c6a8b3877b0b"}, + {file = "coverage-7.6.0-cp38-cp38-win32.whl", hash = "sha256:df423f351b162a702c053d5dddc0fc0ef9a9e27ea3f449781ace5f906b664428"}, + {file = "coverage-7.6.0-cp38-cp38-win_amd64.whl", hash = "sha256:f2501d60d7497fd55e391f423f965bbe9e650e9ffc3c627d5f0ac516026000b8"}, + {file = "coverage-7.6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7221f9ac9dad9492cecab6f676b3eaf9185141539d5c9689d13fd6b0d7de840c"}, + {file = "coverage-7.6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ddaaa91bfc4477d2871442bbf30a125e8fe6b05da8a0015507bfbf4718228ab2"}, + {file = "coverage-7.6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c4cbe651f3904e28f3a55d6f371203049034b4ddbce65a54527a3f189ca3b390"}, + {file = "coverage-7.6.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:831b476d79408ab6ccfadaaf199906c833f02fdb32c9ab907b1d4aa0713cfa3b"}, + {file = "coverage-7.6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46c3d091059ad0b9c59d1034de74a7f36dcfa7f6d3bde782c49deb42438f2450"}, + {file = "coverage-7.6.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4d5fae0a22dc86259dee66f2cc6c1d3e490c4a1214d7daa2a93d07491c5c04b6"}, + {file = "coverage-7.6.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:07ed352205574aad067482e53dd606926afebcb5590653121063fbf4e2175166"}, + {file = "coverage-7.6.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:49c76cdfa13015c4560702574bad67f0e15ca5a2872c6a125f6327ead2b731dd"}, + {file = "coverage-7.6.0-cp39-cp39-win32.whl", hash = "sha256:482855914928c8175735a2a59c8dc5806cf7d8f032e4820d52e845d1f731dca2"}, + {file = "coverage-7.6.0-cp39-cp39-win_amd64.whl", hash = "sha256:543ef9179bc55edfd895154a51792b01c017c87af0ebaae092720152e19e42ca"}, + {file = "coverage-7.6.0-pp38.pp39.pp310-none-any.whl", hash = "sha256:6fe885135c8a479d3e37a7aae61cbd3a0fb2deccb4dda3c25f92a49189f766d6"}, + {file = "coverage-7.6.0.tar.gz", hash = "sha256:289cc803fa1dc901f84701ac10c9ee873619320f2f9aff38794db4a4a0268d51"}, ] [package.extras] @@ -242,5 +242,5 @@ zstd = ["zstandard (>=0.18.0)"] [metadata] lock-version = "2.0" -python-versions = "^3.8" -content-hash = "00fed5119bf605d032b31f0444c6ef09839307b5e2addf38e6c5924c1c1f7a4f" +python-versions = "^3.9" +content-hash = "d32e6a60b7a691c9f1e554fae2f6a5bbb1fc408b6d74f19c4abdb1cc55551950" diff --git a/pyproject.toml b/pyproject.toml index 85eb855..b31a319 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,9 +20,9 @@ classifiers = [ ] [tool.poetry.dependencies] -python = "^3.8" -requests = "^2.32.3" +python = "^3.9" pyjwt = "^2.8.0" +requests = "^2.32.3" [tool.poetry.group.tests] optional = true diff --git a/tests/__init__.py b/tests/__init__.py index e69de29..e34b2ee 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1,5 @@ +import os + +API_HOST = os.environ.get('TEST_API_HOST', 'http://localhost:8000') +API_USER = os.environ.get('TEST_API_USER', 'admin') +API_PASSWORD = os.environ.get('TEST_API_PASSWORD', 'quickstart') diff --git a/tests/authentication/__init__.py b/tests/authentication/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/authentication/test_authentication_manager.py b/tests/authentication/test_authentication_manager.py new file mode 100644 index 0000000..a9a1c6c --- /dev/null +++ b/tests/authentication/test_authentication_manager.py @@ -0,0 +1,118 @@ +"""Tests for the `AuthenticationManager` class.""" + +from datetime import datetime, timedelta +from unittest import TestCase + +import jwt +from requests.exceptions import HTTPError + +from keystone_client.authentication import AuthenticationManager, JWT +from tests import API_HOST, API_PASSWORD, API_USER + + +def create_token(access_expires: datetime, refresh_expires: datetime) -> JWT: + """Create a JWT token + + Args: + access_expires: The expiration datetime for the access token + refresh_expires: The expiration datetime for the refresh token + + Returns: + A JWT instance with the given expiration dates + """ + + return JWT( + access=jwt.encode({'exp': access_expires.timestamp()}, 'secret'), + refresh=jwt.encode({'exp': refresh_expires.timestamp()}, 'secret') + ) + + +class IsAuthenticated(TestCase): + """Tests for the `is_authenticated` method""" + + def test_not_authenticated(self) -> None: + """Test the return value is `false` when the manager has no JWT data""" + + manager = AuthenticationManager(API_HOST) + self.assertIsNone(manager.jwt) + self.assertFalse(manager.is_authenticated()) + + def test_valid_jwt(self) -> None: + """Test the return value is `True` when the JWT token is not expired""" + + manager = AuthenticationManager(API_HOST) + manager.jwt = create_token( + access_expires=datetime.now() + timedelta(hours=1), + refresh_expires=datetime.now() + timedelta(days=1) + ) + + self.assertTrue(manager.is_authenticated()) + + def test_refreshable_jwt(self) -> None: + """Test the return value is `True` when the JWT token expired but refreshable""" + + manager = AuthenticationManager(API_HOST) + manager.jwt = create_token( + access_expires=datetime.now() - timedelta(hours=1), + refresh_expires=datetime.now() + timedelta(days=1) + ) + + self.assertTrue(manager.is_authenticated()) + + def test_expired_jwt(self) -> None: + """Test the return value is `False` when the JWT token is expired""" + + manager = AuthenticationManager(API_HOST) + manager.jwt = create_token( + access_expires=datetime.now() - timedelta(days=1), + refresh_expires=datetime.now() - timedelta(hours=1) + ) + + self.assertFalse(manager.is_authenticated()) + + +class GetAuthHeaders(TestCase): + """Tests for the `get_auth_headers` method""" + + def test_not_authenticated(self) -> None: + """Test the returned headers are empty when not authenticated""" + + manager = AuthenticationManager(API_HOST) + headers = manager.get_auth_headers() + self.assertEqual(dict(), headers) + + def test_headers_match_jwt(self) -> None: + """Test the returned data matches the JWT token""" + + manager = AuthenticationManager(API_HOST) + manager.jwt = create_token( + access_expires=datetime.now() + timedelta(hours=1), + refresh_expires=datetime.now() + timedelta(days=1) + ) + + headers = manager.get_auth_headers() + self.assertEqual(f"Bearer {manager.jwt.access}", headers["Authorization"]) + + +class LoginLogout(TestCase): + """Test the logging in/out of users""" + + def test_correct_credentials(self) -> None: + """Test users are successfully logged in/out when providing correct credentials""" + + manager = AuthenticationManager(API_HOST) + self.assertFalse(manager.is_authenticated()) + + manager.login(API_USER, API_PASSWORD) + self.assertTrue(manager.is_authenticated()) + + manager.logout() + self.assertFalse(manager.is_authenticated()) + + def test_incorrect_credentials(self) -> None: + """Test an error is raised when authenticating with invalid credentials""" + + manager = AuthenticationManager(API_HOST) + with self.assertRaises(HTTPError) as error: + manager.login('foo', 'bar') + self.assertEqual(401, error.response.status_code) diff --git a/tests/authentication/test_jwt.py b/tests/authentication/test_jwt.py new file mode 100644 index 0000000..618881d --- /dev/null +++ b/tests/authentication/test_jwt.py @@ -0,0 +1,45 @@ +"""Tests for the `JWT` class.""" + +from datetime import datetime, timedelta +from unittest import TestCase + +import jwt + +from keystone_client.authentication import JWT + + +class BaseParsingTests: + """Base class containing reusable tests for token parsing""" + + algorithm: str + + @classmethod + def setUpClass(cls) -> None: + """Test the parsing of JWT data""" + + # Build a JWT + cls.access_expiration = datetime.now() + timedelta(hours=1) + cls.access_token = jwt.encode({'exp': cls.access_expiration.timestamp()}, 'secret', algorithm=cls.algorithm) + + cls.refresh_expiration = datetime.now() + timedelta(days=1) + cls.refresh_token = jwt.encode({'exp': cls.refresh_expiration.timestamp()}, 'secret', algorithm=cls.algorithm) + + cls.jwt = JWT(cls.access_token, cls.refresh_token, cls.algorithm) + + def test_access_token(self) -> None: + """Test the access token is parsed correctly""" + + self.assertEqual(self.access_token, self.jwt.access) + self.assertEqual(self.access_expiration, self.jwt.access_expiration) + + def test_refresh_token(self) -> None: + """Test the refresh token is parsed correctly""" + + self.assertEqual(self.refresh_token, self.jwt.refresh) + self.assertEqual(self.refresh_expiration, self.jwt.refresh_expiration) + + +class HS256Parsing(BaseParsingTests, TestCase): + """Test JWT token parsing using the HS256 algorithm.""" + + algorithm = 'HS256' diff --git a/tests/client/__init__.py b/tests/client/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/client/test_http_client.py b/tests/client/test_http_client.py new file mode 100644 index 0000000..8c08443 --- /dev/null +++ b/tests/client/test_http_client.py @@ -0,0 +1,20 @@ +"""Tests for the `HTTPClient` class.""" + +from unittest import TestCase + +from keystone_client.client import HTTPClient + + +class Url(TestCase): + """Tests for the `url` property""" + + def test_trailing_slash_removed(self): + """Test extra trailing slashes are removed from URLs provided at init""" + + base_url = 'https://test.domain.com' + expected_url = base_url + '/' + + # Test for various numbers of trailing slashes provided at init + self.assertEqual(expected_url, HTTPClient(base_url).url) + self.assertEqual(expected_url, HTTPClient(base_url + '/').url) + self.assertEqual(expected_url, HTTPClient(base_url + '////').url) diff --git a/tests/client/test_keystone_client.py b/tests/client/test_keystone_client.py new file mode 100644 index 0000000..0c1592b --- /dev/null +++ b/tests/client/test_keystone_client.py @@ -0,0 +1,29 @@ +"""Tests for CRUD operations.""" + +import re +from unittest import TestCase + +from keystone_client import KeystoneClient +from tests import API_HOST + + +class APIVersion(TestCase): + """Tests for the `api_version` method""" + + def test_version_is_returned(self) -> None: + """Test a version number is returned""" + + # Simplified version identification from PEP 440 + version_regex = re.compile(r""" + ^ + (?P[0-9]+)\. # Major version number + (?P[0-9]+)\. # Minor version number + (?P[0-9]+) # Patch version number + (?:\. # Optional dot + (?P[a-zA-Z0-9]+) # Optional suffix (letters or numbers) + )? # Make the entire suffix part optional + $ + """, re.VERBOSE) + + client = KeystoneClient(API_HOST) + self.assertRegex(client.api_version, version_regex) diff --git a/tests/schema/__init__.py b/tests/schema/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/schema/test_endpoint.py b/tests/schema/test_endpoint.py new file mode 100644 index 0000000..7e20518 --- /dev/null +++ b/tests/schema/test_endpoint.py @@ -0,0 +1,83 @@ +"""Tests for the `Endpoint` class.""" + +from unittest import TestCase + +from keystone_client.schema import Endpoint + + +class JoinUrl(TestCase): + """Tests for the `join_url` method""" + + def test_with_trailing_slash(self) -> None: + """Test `join_url` with a base URL that has a trailing slash""" + + endpoint = Endpoint("authentication/new") + base_url = "https://api.example.com/" + expected_result = "https://api.example.com/authentication/new/" + self.assertEqual(expected_result, endpoint.join_url(base_url)) + + def test_without_trailing_slash(self) -> None: + """Test `join_url` with a base URL that does not have a trailing slash""" + + endpoint = Endpoint("authentication/new") + base_url = "https://api.example.com" + expected_result = "https://api.example.com/authentication/new/" + self.assertEqual(expected_result, endpoint.join_url(base_url)) + + def test_with_endpoint_trailing_slash(self) -> None: + """Test `join_url` with an endpoint that has a trailing slash""" + + endpoint = Endpoint("authentication/new/") + base_url = "https://api.example.com" + expected_result = "https://api.example.com/authentication/new/" + self.assertEqual(expected_result, endpoint.join_url(base_url)) + + def test_without_endpoint_trailing_slash(self) -> None: + """Test `join_url` with an endpoint that does not have a trailing slash""" + + endpoint = Endpoint("authentication/new") + base_url = "https://api.example.com" + expected_result = "https://api.example.com/authentication/new/" + self.assertEqual(expected_result, endpoint.join_url(base_url)) + + def test_with_append_trailing_slash(self) -> None: + endpoint = Endpoint("authentication") + base_url = "https://api.example.com" + append_path = "new/" + expected_result = "https://api.example.com/authentication/new/" + self.assertEqual(expected_result, endpoint.join_url(base_url, append_path)) + + def test_without_append_trailing_slash(self) -> None: + endpoint = Endpoint("authentication") + base_url = "https://api.example.com" + append_path = "new" + expected_result = "https://api.example.com/authentication/new/" + self.assertEqual(expected_result, endpoint.join_url(base_url, append_path)) + + def test_with_mixed_trailing_slash_in_append(self) -> None: + """Test `join_url` with mixed trailing slashes in append arguments""" + + endpoint = Endpoint("authentication") + base_url = "https://api.example.com" + append_path1 = "new/" + append_path2 = "extra" + expected_result = "https://api.example.com/authentication/new/extra/" + self.assertEqual(expected_result, endpoint.join_url(base_url, append_path1, append_path2)) + + def test_int_append_argument(self) -> None: + """Test `join_url` with an `int` append argument""" + + endpoint = Endpoint("authentication") + base_url = "https://api.example.com" + append_path = 123 + expected_result = "https://api.example.com/authentication/123/" + self.assertEqual(expected_result, endpoint.join_url(base_url, str(append_path))) + + def test_none_append_argument(self) -> None: + """Test `join_url` with a `None` append argument""" + + endpoint = Endpoint("authentication") + base_url = "https://api.example.com" + append_path = None + expected_result = "https://api.example.com/authentication/" + self.assertEqual(expected_result, endpoint.join_url(base_url, append_path)) diff --git a/tests/test_crud_methods.py b/tests/test_crud_methods.py deleted file mode 100644 index e3920ba..0000000 --- a/tests/test_crud_methods.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Tests for CRUD operations.""" - -from unittest import TestCase - -from keystone_client import KeystoneClient - - -class Retrieve(TestCase): - """Tests for retrieve methods""" - - def test_methods_exist(self) -> None: - """Test a method exists for each endpoint in the class schema""" - - client = KeystoneClient('http://test.domain.com') - for endpoint in KeystoneClient.schema._fields: - method_name = f'retrieve_{endpoint}' - self.assertTrue(hasattr(client, method_name), f'Method does not exist {method_name}') diff --git a/tests/test_url_handling.py b/tests/test_url_handling.py deleted file mode 100644 index a3464b9..0000000 --- a/tests/test_url_handling.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Tests for the handling to the base API url.""" - -from unittest import TestCase - -from keystone_client import KeystoneClient - - -class TestUrl(TestCase): - """Tests for the `url` property""" - - def test_trailing_slash_removed(self): - """Test trailing slashes are removed from URLs provided at init""" - - # Test for various numbers of trailing slashes - url = 'http://test.domain.com' - self.assertEqual(url, KeystoneClient(url).url) - self.assertEqual(url, KeystoneClient(url + '/').url) - self.assertEqual(url, KeystoneClient(url + '////').url)