diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..f3515fd3 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +tab_width = 4 +indent_style = space + +[*.py] +end_of_line = lf +indent_size = 4 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.{yml,yaml}] +end_of_line = lf +indent_size = 2 +trim_trailing_whitespace = true +insert_final_newline = true diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 44a0542e..82399a9a 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -47,7 +47,7 @@ jobs: uses: pre-commit/action@v3.0.0 - name: Run tests - run: poetry run pytest -vvv --exitfirst --cov=app --cov-report=term --cov-report=xml --cov-fail-under=23 + run: poetry run pytest -vvv --exitfirst --cov=app --cov-report=term --cov-report=xml --cov-fail-under=54 - name: Coverage report comment uses: mishakav/pytest-coverage-comment@v1.1.42 diff --git a/app/commands/user.py b/app/commands/user.py index 7f068383..d7cfca56 100644 --- a/app/commands/user.py +++ b/app/commands/user.py @@ -2,11 +2,15 @@ # # Contact Indoc Research for any questions regarding the use of this source code. +from typing import Union + import click import app.services.output_manager.help_page as user_help import app.services.output_manager.message_handler as mhandler +from app.models.enums import LoginMethod from app.services.user_authentication.decorator import require_login_session +from app.services.user_authentication.user_login_logout import login_using_api_key from app.services.user_authentication.user_login_logout import user_device_id_login from app.services.user_authentication.user_login_logout import user_logout from app.services.user_authentication.user_login_logout import validate_user_device_login @@ -20,22 +24,36 @@ def cli(): @click.command() +@click.option( + '--api-key', + envvar='PILOT_API_KEY', + help=(user_help.user_help_page(user_help.UserHELP.USER_LOGIN_API_KEY)), +) @doc(user_help.user_help_page(user_help.UserHELP.USER_LOGIN)) -def login(): - device_login = user_device_id_login() - if device_login: - mhandler.SrvOutPutHandler.login_input_device_code(device_login['verification_uri_complete']) - mhandler.SrvOutPutHandler.login_device_code_qrcode(device_login['verification_uri_complete']) +def login(api_key: Union[str, None]): + if api_key: + mhandler.SrvOutPutHandler.login_using_method(LoginMethod.API_KEY) + is_valid = login_using_api_key(api_key) + if is_valid: + mhandler.SrvOutPutHandler.login_success() + else: + mhandler.SrvOutPutHandler.login_using_api_key_failed_error() else: - mhandler.SrvOutPutHandler.login_input_device_error() + mhandler.SrvOutPutHandler.login_using_method(LoginMethod.DEVICE_CODE) + device_login = user_device_id_login() + if device_login: + mhandler.SrvOutPutHandler.login_input_device_code(device_login['verification_uri_complete']) + mhandler.SrvOutPutHandler.login_device_code_qrcode(device_login['verification_uri_complete']) + else: + mhandler.SrvOutPutHandler.login_input_device_error() - is_validated = validate_user_device_login( - device_login['device_code'], device_login['expires'], device_login['interval'] - ) - if is_validated: - mhandler.SrvOutPutHandler.login_success() - else: - mhandler.SrvOutPutHandler.validation_login_input_device_error() + is_validated = validate_user_device_login( + device_login['device_code'], device_login['expires'], device_login['interval'] + ) + if is_validated: + mhandler.SrvOutPutHandler.login_success() + else: + mhandler.SrvOutPutHandler.validation_login_input_device_error() @click.command() diff --git a/app/configs/app_config.py b/app/configs/app_config.py index e2ba4b12..964c7f8c 100644 --- a/app/configs/app_config.py +++ b/app/configs/app_config.py @@ -5,8 +5,8 @@ from env import ConfigClass -class AppConfig(object): - class Env(object): +class AppConfig: + class Env: section = 'environment' project = ConfigClass.project user_config_path = ConfigClass.config_path @@ -34,8 +34,9 @@ class Env(object): greenroom_bucket_prefix = 'gr' keycloak_device_client_id = ConfigClass.keycloak_device_client_id + keycloak_api_key_audience = ConfigClass.keycloak_api_key_audience - class Connections(object): + class Connections: section = 'connections' url_harbor = ConfigClass.url_harbor url_authn = ConfigClass.url_authn @@ -53,6 +54,7 @@ class Connections(object): url_validation = ConfigClass.url_validation url_keycloak = ConfigClass.url_keycloak url_keycloak_token = f'{ConfigClass.url_keycloak}/token' + url_keycloak_realm = ConfigClass.url_keycloak.rstrip('/').replace('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/protocol/openid-connect', '') url_bff = ConfigClass.url_bff # add url_base to check if value exist url_base = ConfigClass.base_url diff --git a/app/configs/user_config.py b/app/configs/user_config.py index 718f0704..6ecd0d6b 100644 --- a/app/configs/user_config.py +++ b/app/configs/user_config.py @@ -6,13 +6,12 @@ import os import time +from app.configs.app_config import AppConfig from app.models.singleton import Singleton from app.services.crypto.crypto import decryption from app.services.crypto.crypto import encryption from app.services.crypto.crypto import generate_secret -from .app_config import AppConfig - class UserConfig(metaclass=Singleton): """The class to maintain the user access/fresh token Note here: the base class is Singleton, meaning no matter how @@ -32,6 +31,7 @@ def __init__(self): self.config['USER'] = { 'username': '', 'password': '', + 'api_key': '', 'access_token': '', 'refresh_token': '', 'secret': generate_secret(), @@ -49,6 +49,7 @@ def clear(self): self.config['USER'] = { 'username': '', 'password': '', + 'api_key': '', 'access_token': '', 'refresh_token': '', 'hpc_token': '', @@ -59,7 +60,7 @@ def clear(self): self.save() def is_logged_in(self) -> bool: - return bool(self.access_token and self.refresh_token) + return bool(self.api_key or (self.access_token and self.refresh_token)) @property def username(self): @@ -77,6 +78,14 @@ def password(self): def password(self, val): self.config['USER']['password'] = encryption(val, self.secret) + @property + def api_key(self): + return decryption(self.config['USER']['api_key'], self.secret) + + @api_key.setter + def api_key(self, val): + self.config['USER']['api_key'] = encryption(val, self.secret) + @property def access_token(self): return decryption(self.config['USER']['access_token'], self.secret) diff --git a/app/models/enums.py b/app/models/enums.py new file mode 100644 index 00000000..ee51e7ba --- /dev/null +++ b/app/models/enums.py @@ -0,0 +1,12 @@ +# Copyright (C) 2023 Indoc Research +# +# Contact Indoc Research for any questions regarding the use of this source code. + +from enum import Enum + + +class LoginMethod(str, Enum): + """Available login methods.""" + + API_KEY = 'api-key' + DEVICE_CODE = 'device-code' diff --git a/app/resources/custom_help.py b/app/resources/custom_help.py index 08f8e69d..5906ad1b 100644 --- a/app/resources/custom_help.py +++ b/app/resources/custom_help.py @@ -27,6 +27,7 @@ class HelpPage: ), 'USER_LOGIN_USERNAME': 'Specify username for login.', 'USER_LOGIN_PASSWORD': 'Specify password for login.', + 'USER_LOGIN_API_KEY': 'Specify API Key for login.', }, 'file': { 'FILE_ATTRIBUTE_LIST': 'List attribute templates of a given Project.', diff --git a/app/services/output_manager/help_page.py b/app/services/output_manager/help_page.py index 177e1753..68102f70 100644 --- a/app/services/output_manager/help_page.py +++ b/app/services/output_manager/help_page.py @@ -53,6 +53,7 @@ class UserHELP(enum.Enum): USER_LOGOUT_CONFIRM = 'USER_LOGOUT_CONFIRM' USER_LOGIN_USERNAME = 'USER_LOGIN_USERNAME' USER_LOGIN_PASSWORD = 'USER_LOGIN_PASSWORD' + USER_LOGIN_API_KEY = 'USER_LOGIN_API_KEY' def user_help_page(UserHELP: UserHELP): diff --git a/app/services/output_manager/message_handler.py b/app/services/output_manager/message_handler.py index 9be7252c..833d8033 100644 --- a/app/services/output_manager/message_handler.py +++ b/app/services/output_manager/message_handler.py @@ -7,10 +7,23 @@ import qrcode import app.services.logger_services.log_functions as logger +from app.models.enums import LoginMethod from app.models.service_meta_class import MetaService class SrvOutPutHandler(metaclass=MetaService): + @staticmethod + def login_using_method(method: LoginMethod): + """Selected login method message.""" + return logger.info(f'Trying to log in using "{method.value}" method.') + + @staticmethod + def login_using_api_key_failed_error(): + """Error when logging in with the API Key!""" + return logger.error( + f'Failed to log in using "{LoginMethod.API_KEY.value}" method, please make sure you are using a valid key!' + ) + @staticmethod def login_device_code_qrcode(url: str): """Print QRCode with login url!""" diff --git a/app/services/user_authentication/token_manager.py b/app/services/user_authentication/token_manager.py index b0029433..90283f9b 100644 --- a/app/services/user_authentication/token_manager.py +++ b/app/services/user_authentication/token_manager.py @@ -9,8 +9,10 @@ from app.configs.app_config import AppConfig from app.configs.user_config import UserConfig +from app.models.enums import LoginMethod from app.models.service_meta_class import MetaService from app.services.output_manager.error_handler import SrvErrorHandler +from app.services.user_authentication.user_login_logout import exchange_api_key class SrvTokenManager(metaclass=MetaService): @@ -37,6 +39,13 @@ def decode_refresh_token(self): tokens = self.get_token() return jwt.decode(tokens[1], verify=False) + def is_api_key(self) -> bool: + token = self.decode_access_token() + audience = token['aud'] + if isinstance(audience, str): + audience = [audience] + return AppConfig.Env.keycloak_api_key_audience.issubset(set(audience)) + def check_valid(self, required_azp): """ check token validation @@ -49,20 +58,26 @@ def check_valid(self, required_azp): now = time.time() diff = expiry_at - now - # TODO: check why here will need enforce the token refresh when - # azp is not `kong`` - # ``kong`` is hardcoded in the decorator definition as default value. - azp_token_condition = decoded_access_token['azp'] not in [required_azp, AppConfig.Env.keycloak_device_client_id] + if not self.is_api_key(): + # TODO: check why here will need enforce the token refresh when + # azp is not `kong`` + # ``kong`` is hardcoded in the decorator definition as default value. + azp_token_condition = decoded_access_token['azp'] not in [ + required_azp, + AppConfig.Env.keycloak_device_client_id, + ] + + if azp_token_condition or expiry_at <= now: + return 2 - if azp_token_condition or expiry_at <= now: - return 2 - # print(expiry_at, now) - # print(diff, AppConfig.Env.token_warn_need_refresh) if diff <= AppConfig.Env.token_warn_need_refresh: return 1 return 0 - def refresh(self, azp: str): + def refresh(self, azp: str) -> None: + if self.is_api_key(): + return self.refresh_api_key() + url = AppConfig.Connections.url_keycloak_token payload = { 'grant_type': 'refresh_token', @@ -79,4 +94,12 @@ def refresh(self, azp: str): self.update_token(response.json()['access_token'], response.json()['refresh_token']) else: SrvErrorHandler.default_handle(response.content) - return response.json() + + def refresh_api_key(self) -> None: + access_token = exchange_api_key(self.config.api_key) + if access_token is None: + return SrvErrorHandler.default_handle( + f'Unable to get access token using "{LoginMethod.API_KEY.value}" method. Unable to proceed.', True + ) + + self.update_token(access_token, '') diff --git a/app/services/user_authentication/user_login_logout.py b/app/services/user_authentication/user_login_logout.py index 0d28f106..b6e8882f 100644 --- a/app/services/user_authentication/user_login_logout.py +++ b/app/services/user_authentication/user_login_logout.py @@ -5,10 +5,12 @@ import time from typing import Any from typing import Dict +from typing import Union from uuid import uuid4 import jwt import requests +from requests import RequestException from app.configs.app_config import AppConfig from app.configs.user_config import UserConfig @@ -17,6 +19,42 @@ from app.services.output_manager.message_handler import SrvOutPutHandler +def exchange_api_key(api_key: str) -> Union[str, None]: + """Exchange API Key with JWT token using Keycloak.""" + + url = f'{AppConfig.Connections.url_keycloak_realm}/api-key/{api_key}' + try: + response = requests.get(url, timeout=5) + response.raise_for_status() + except RequestException: + return None + + return response.json()['access_token'] + + +def login_using_api_key(api_key: str) -> bool: + """Try to log in using API Key and store results in user config.""" + + access_token = exchange_api_key(api_key) + if access_token is None: + return False + + decoded_token = jwt.decode(access_token, verify=False) + username = decoded_token['preferred_username'] + + user_config = UserConfig() + user_config.api_key = api_key + user_config.access_token = access_token + user_config.refresh_token = '' + user_config.username = username + user_config.last_active = str(int(time.time())) + user_config.hpc_token = '' + user_config.session_id = 'cli-' + str(uuid4()) + user_config.save() + + return True + + def user_device_id_login() -> Dict[str, Any]: """Get device code URL for user login.""" @@ -64,6 +102,7 @@ def validate_user_device_login(device_code: str, expires: int, interval: int) -> resp_dict = resp.json() decode_token = jwt.decode(resp_dict['access_token'], verify=False) user_config = UserConfig() + user_config.api_key = '' user_config.access_token = resp_dict['access_token'] user_config.refresh_token = resp_dict['refresh_token'] user_config.username = decode_token['preferred_username'] diff --git a/env.py b/env.py index ae8cd7ae..90f67c74 100644 --- a/env.py +++ b/env.py @@ -3,6 +3,7 @@ # Contact Indoc Research for any questions regarding the use of this source code. import os +from typing import Set from dotenv import load_dotenv from pydantic import BaseSettings @@ -25,6 +26,7 @@ class Settings(BaseSettings): url_keycloak: str = '' keycloak_device_client_id: str = 'cli_test2' + keycloak_api_key_audience: Set[str] = {'api-key'} VM_INFO: str = '' diff --git a/poetry.lock b/poetry.lock index cd690ee6..6c6eb6a7 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry and should not be changed by hand. +# This file is automatically @generated by Poetry 1.4.2 and should not be changed by hand. [[package]] name = "aioboto3" @@ -591,6 +591,22 @@ files = [ {file = "distlib-0.3.6.tar.gz", hash = "sha256:14bad2d9b04d3a36127ac97f30b12a19268f211063d8f8ee4f47108896e11b46"}, ] +[[package]] +name = "faker" +version = "18.9.0" +description = "Faker is a Python package that generates fake data for you." +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "Faker-18.9.0-py3-none-any.whl", hash = "sha256:defe9ed618a67ebf0f3eb1895e198c2355a7128a09087a6dce342ef2253263ea"}, + {file = "Faker-18.9.0.tar.gz", hash = "sha256:80a5ea1464556c06b98bf47ea3adc7f33811a1182518d847860b1874080bd3c9"}, +] + +[package.dependencies] +python-dateutil = ">=2.4" +typing-extensions = {version = ">=3.10.0.1", markers = "python_version < \"3.8\""} + [[package]] name = "filelock" version = "3.9.0" @@ -1894,4 +1910,4 @@ testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools" [metadata] lock-version = "2.0" python-versions = ">=3.7,<3.11" -content-hash = "1ac69682539aa9934b4fd61e9f6e0eda8d4ac96b389f51ea3f8f98189f423e76" +content-hash = "893dd116283c6b9fd4d470d5aea6cb8dbe5c8d0194172f6db67f6639b50d85d9" diff --git a/pyproject.toml b/pyproject.toml index 1c2037b0..8ee25627 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ pytest-cov = "^3.0.0" pytest-mock = "^3.7.0" pytest-httpx = "^0.21.2" requests-mock = "^1.9.3" +faker = "18.9.0" [tool.poetry.scripts] pilotcli = "app.pilotcli:cli" diff --git a/tests/app/commands/test_user.py b/tests/app/commands/test_user.py new file mode 100644 index 00000000..dfda5a55 --- /dev/null +++ b/tests/app/commands/test_user.py @@ -0,0 +1,64 @@ +# Copyright (C) 2023 Indoc Research +# +# Contact Indoc Research for any questions regarding the use of this source code. + +import jwt + +from app.commands.user import login +from app.configs.app_config import AppConfig +from app.configs.user_config import UserConfig + + +def test_login_command_with_api_key_option_calls_keycloak_and_stores_response_in_user_config( + requests_mock, cli_runner, fake +): + username = fake.user_name() + api_key = fake.pystr(20) + access_token = jwt.encode({'preferred_username': username}, key='').decode() + requests_mock.get( + f'{AppConfig.Connections.url_keycloak_realm}/api-key/{api_key}', json={'access_token': access_token} + ) + + result = cli_runner.invoke(login, ['--api-key', api_key]) + + assert result.exit_code == 0 + assert 'Trying to log in using "api-key" method.' in result.output + + user = UserConfig() + assert user.access_token == access_token + assert user.username == username + + +def test_login_command_without_api_key_option_takes_value_from_environment_variable( + monkeypatch, mocker, cli_runner, fake +): + api_key = fake.pystr(20) + monkeypatch.setenv('PILOT_API_KEY', api_key) + login_using_api_key_mock = mocker.patch('app.commands.user.login_using_api_key', return_value=True) + + result = cli_runner.invoke(login) + + assert result.exit_code == 0 + + login_using_api_key_mock.assert_called_once_with(api_key) + + +def test_login_command_without_api_key_option_falls_back_to_device_code_method(mocker, cli_runner, fake): + device_login = { + 'expires': fake.pyint(), + 'interval': fake.pyint(), + 'device_code': fake.pystr(), + 'verification_uri_complete': fake.url(), + } + user_device_id_login_mock = mocker.patch('app.commands.user.user_device_id_login', return_value=device_login) + validate_user_device_login_mock = mocker.patch('app.commands.user.validate_user_device_login', return_value=True) + + result = cli_runner.invoke(login) + + assert result.exit_code == 0 + assert 'Trying to log in using "device-code" method.' in result.output + + user_device_id_login_mock.assert_called_once() + validate_user_device_login_mock.assert_called_once_with( + device_login['device_code'], device_login['expires'], device_login['interval'] + ) diff --git a/tests/app/services/hpc_manager/test_hpc_cluster.py b/tests/app/services/hpc_manager/test_hpc_cluster.py index fa4935bc..986c7efc 100644 --- a/tests/app/services/hpc_manager/test_hpc_cluster.py +++ b/tests/app/services/hpc_manager/test_hpc_cluster.py @@ -16,6 +16,9 @@ def test_hpc_list_partitions(httpx_mock, mocker): 'app.services.user_authentication.token_manager.SrvTokenManager.decode_access_token', return_value=decoded_token(), ) + user_config = UserConfig() + user_config.username = 'test-user' + user_config.hpc_token = 'test-hpc-token' httpx_mock.add_response( method='GET', url='http://bff_cli/v1/hpc/partitions?host=test_host&username=test-user&token=test-hpc-token', @@ -38,12 +41,13 @@ def test_hpc_list_partitions(httpx_mock, mocker): assert partion == expected_partitions -def test_hpc_list_partitions_no_token(mocker, capsys, monkeypatch): +def test_hpc_list_partitions_no_token(mocker, capsys): mocker.patch( 'app.services.user_authentication.token_manager.SrvTokenManager.decode_access_token', return_value=decoded_token(), ) - monkeypatch.setattr(UserConfig, 'hpc_token', '') + user_config = UserConfig() + user_config.hpc_token = '' with pytest.raises(SystemExit): hpc_mgr = HPCPartitionManager() _ = hpc_mgr.list_partitions('test_host') diff --git a/tests/app/services/user_authentication/test_token_manager.py b/tests/app/services/user_authentication/test_token_manager.py new file mode 100644 index 00000000..1b68e8ac --- /dev/null +++ b/tests/app/services/user_authentication/test_token_manager.py @@ -0,0 +1,41 @@ +# Copyright (C) 2023 Indoc Research +# +# Contact Indoc Research for any questions regarding the use of this source code. + +import jwt + +from app.configs.app_config import AppConfig +from app.configs.user_config import UserConfig +from app.services.user_authentication.token_manager import SrvTokenManager + + +class TestSrvTokenManager: + def test_is_api_key_returns_true_when_audience_has_api_key_entry(self): + user_config = UserConfig() + user_config.access_token = jwt.encode({'aud': 'api-key'}, key='').decode() + manager = SrvTokenManager() + + assert manager.is_api_key() is True + + def test_refresh_calls_refresh_api_key_method_when_is_api_key_method_returns_true(self, mocker, fake): + user_config = UserConfig() + user_config.access_token = jwt.encode({'aud': 'api-key'}, key='').decode() + manager = SrvTokenManager() + refresh_api_key_mock = mocker.patch.object(manager, 'refresh_api_key') + + manager.refresh(fake.pystr()) + + refresh_api_key_mock.assert_called_once() + + def test_refresh_api_key_calls_keycloak_and_stores_access_token_in_config(self, requests_mock): + manager = SrvTokenManager() + access_token = jwt.encode({}, key='').decode() + requests_mock.get( + f'{AppConfig.Connections.url_keycloak_realm}/api-key/{manager.config.api_key}', + json={'access_token': access_token}, + ) + + manager.refresh_api_key() + + assert manager.config.access_token == access_token + assert manager.config.refresh_token == '' diff --git a/tests/conftest.py b/tests/conftest.py index e0f7bb59..ca3f66de 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -17,7 +17,7 @@ def reset_singletons(): @pytest.fixture(autouse=True) -def mock_settings(monkeypatch): +def mock_settings(monkeypatch, mocker): monkeypatch.setattr(AppConfig.Connections, 'url_authn', 'http://service_auth') monkeypatch.setattr(AppConfig.Connections, 'url_bff', 'http://bff_cli') monkeypatch.setattr(AppConfig.Connections, 'url_dataset', 'http://url_dataset') @@ -25,11 +25,14 @@ def mock_settings(monkeypatch): monkeypatch.setattr(AppConfig.Connections, 'url_download_core', 'http://url_dataset_download_core') monkeypatch.setattr(AppConfig.Connections, 'url_upload_greenroom', 'http://upload_gr') monkeypatch.setattr(AppConfig.Connections, 'url_upload_core', 'http://upload_core') + monkeypatch.setattr(AppConfig.Connections, 'url_keycloak_realm', 'http://keycloak_realm') monkeypatch.setattr(UserConfig, 'username', 'test-user') monkeypatch.setattr(UserConfig, 'password', 'test-password') + monkeypatch.setattr(UserConfig, 'api_key', 'test-api-key') monkeypatch.setattr(UserConfig, 'access_token', 'test-access-token') monkeypatch.setattr(UserConfig, 'refresh_token', 'test-refresh-token') monkeypatch.setattr(UserConfig, 'hpc_token', 'test-hpc-token') + mocker.patch('app.configs.user_config.UserConfig.save') # Do not save config when running tests def decoded_token(): @@ -61,3 +64,8 @@ def decoded_token(): 'group': ['sample-group'], 'policy': ['project-admin', 'uma_authorization', 'test'], } + + +pytest_plugins = [ + 'tests.fixtures.fake', +] diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py new file mode 100644 index 00000000..950d1b40 --- /dev/null +++ b/tests/fixtures/__init__.py @@ -0,0 +1,3 @@ +# Copyright (C) 2023 Indoc Research +# +# Contact Indoc Research for any questions regarding the use of this source code. diff --git a/tests/fixtures/fake.py b/tests/fixtures/fake.py new file mode 100644 index 00000000..d4bc9933 --- /dev/null +++ b/tests/fixtures/fake.py @@ -0,0 +1,15 @@ +# Copyright (C) 2023 Indoc Research +# +# Contact Indoc Research for any questions regarding the use of this source code. + +import faker +import pytest + + +class Faker(faker.Faker): + pass + + +@pytest.fixture +def fake() -> Faker: + yield Faker()