diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index a3230d4e7c3cc3b0d5089e6143cc2cdd5ad768ad..95892b16153fd35f5996f9e5489ef22b26d508b9 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -247,7 +247,7 @@ test-lib: script: - "pip install pipenv" - "pipenv install gunicorn && pipenv install --dev --system --deploy" - - cd ./lib/python/ && coverage run -m pytest tests/test_database.py --junitxml=report.xml && coverage html --omit="test/*" && coverage report --omit="test/*" > ./coverage.txt + - cd ./lib/python/ && coverage run -m pytest tests/test_unit_analyse.py tests/test_unit_container.py tests/test_unit_database.py tests/test_unit_identifier.py tests/test_unit_license.py tests/test_unit_query.py tests/test_unit_rest_client.py tests/test_unit_table.py tests/test_unit_user.py tests/test_unit_view.py --junitxml=report.xml && coverage html --omit="test/*" && coverage report --omit="test/*" > ./coverage.txt - "cat ./coverage.txt | grep -o 'TOTAL[^%]*%'" artifacts: when: always diff --git a/dbrepo-ui/composables/toast-instance.ts b/dbrepo-ui/composables/toast-instance.ts index bac439b1cd6c040a28195d54286131cfa4906ad0..bea71258e6dfcdc4b465800e7f56918bcc723aa5 100644 --- a/dbrepo-ui/composables/toast-instance.ts +++ b/dbrepo-ui/composables/toast-instance.ts @@ -21,5 +21,19 @@ export const useToastInstance = () => { } } - return {error, success} + function info(message: string): void { + const toast: ToastPluginApi = useToast(props); + if (document) { + toast.info(message) + } + } + + function warning(message: string): void { + const toast: ToastPluginApi = useToast(props); + if (document) { + toast.warning(message) + } + } + + return {error, success, info, warning} }; diff --git a/dbrepo-ui/pages/login.vue b/dbrepo-ui/pages/login.vue index 71ea9ec604a39994951a5eb02faf04116d45e77f..38c2ce139e10816a89555d7b25fdf658073f5ded 100644 --- a/dbrepo-ui/pages/login.vue +++ b/dbrepo-ui/pages/login.vue @@ -117,7 +117,7 @@ export default { userService.findOne(userId) .then((user) => { const toast = useToastInstance() - toast.info(this.$t('success.user.login')) + toast.success(this.$t('success.user.login')) switch (user.attributes.theme) { case 'dark': this.$vuetify.theme.global.name = 'tuwThemeDark' diff --git a/lib/python/dbrepo/RestClient.py b/lib/python/dbrepo/RestClient.py index 79e6fd1181b73b80f6ed74824cb7981ee914a5c7..c833079e0c89f84d85b5d81feb848b7d413c206e 100644 --- a/lib/python/dbrepo/RestClient.py +++ b/lib/python/dbrepo/RestClient.py @@ -48,7 +48,7 @@ class RestClient: def _wrapper(self, method: str, url: str, params: [(str,)] = None, payload=None, headers: dict = None, force_auth: bool = False, stream: bool = False) -> requests.Response: - if force_auth and (self.username is None or self.password is None): + if force_auth and (self.username is None and self.password is None): raise AuthenticationError(f"Failed to perform request: authentication required") url = f'{self.endpoint}{url}' logging.debug(f'method: {method}') @@ -60,15 +60,20 @@ class RestClient: logging.debug(f'secure: {self.secure}') if headers is not None: logging.debug(f'headers: {headers}') + else: + headers = dict() + logging.debug(f'no headers set') if payload is not None: - logging.debug(f'payload: {payload.model_dump()}') payload = payload.model_dump() - if self.username is not None and self.password is not None: - logging.debug(f'username: {self.username}, password: (hidden)') - return requests.request(method=method, url=url, auth=(self.username, self.password), verify=self.secure, - json=payload, headers=headers, params=params, stream=stream) - return requests.request(method=method, url=url, verify=self.secure, json=payload, - headers=headers, params=params, stream=stream) + auth = None + if self.username is None and self.password is not None: + headers["Authorization"] = f"Bearer {self.password}" + logging.debug(f'configured for oidc/bearer auth') + elif self.username is not None and self.password is not None: + auth = (self.username, self.password) + logging.debug(f'configured for basic auth: username={self.username}, password=(hidden)') + return requests.request(method=method, url=url, auth=auth, verify=self.secure, + json=payload, headers=headers, params=params, stream=stream) def upload(self, file_path: str) -> str: """ @@ -86,6 +91,31 @@ class RestClient: raise UploadError(f'Failed to upload the file to {self.endpoint}') return filename + def get_jwt_auth(self, username: str = None, password: str = None) -> JwtAuth: + """ + Obtains a JWT auth object from the Auth Service containing e.g. the access token and refresh token. + + :param username: The username used to authenticate with the Auth Service. Optional. Default: username from the `RestClient` constructor. + :param password: The password used to authenticate with the Auth Service. Optional. Default: password from the `RestClient` constructor. + + :returns: JWT auth object from the Auth Service, if successful. + + :raises ForbiddenError: If something went wrong with the authentication. + :raises ResponseCodeError: If something went wrong with the authentication. + """ + if username is None: + username = self.username + if password is None: + password = self.password + url = f'{self.endpoint}/api/user/token' + response = requests.post(url=url, json=dict({"username": username, "password": password})) + if response.status_code == 202: + body = response.json() + return JwtAuth.model_validate(body) + if response.status_code == 403: + raise ForbiddenError(f'Failed to get JWT auth') + raise ResponseCodeError(f'Failed to get JWT auth: response code: {response.status_code} is not 202 (ACCEPTED)') + def whoami(self) -> str | None: """ Print the username. @@ -165,12 +195,14 @@ class RestClient: raise ResponseCodeError( f'Failed to create user: response code: {response.status_code} is not 201 (CREATED)') - def update_user(self, user_id: str, firstname: str = None, lastname: str = None, affiliation: str = None, - orcid: str = None) -> User: + def update_user(self, user_id: str, theme: str, language: str, firstname: str = None, lastname: str = None, + affiliation: str = None, orcid: str = None) -> User: """ Updates a user with given user id. :param user_id: The user id of the user that should be updated. + :param theme: The user theme. One of "light", "dark", "light-contrast", "dark-contrast". + :param language: The user language localization. One of "en", "de". :param firstname: The updated given name. Optional. :param lastname: The updated family name. Optional. :param affiliation: The updated affiliation identifier. Optional. @@ -184,8 +216,8 @@ class RestClient: """ url = f'/api/user/{user_id}' response = self._wrapper(method="put", url=url, force_auth=True, - payload=UpdateUser(firstname=firstname, lastname=lastname, affiliation=affiliation, - orcid=orcid)) + payload=UpdateUser(theme=theme, language=language, firstname=firstname, + lastname=lastname, affiliation=affiliation, orcid=orcid)) if response.status_code == 202: body = response.json() return User.model_validate(body) @@ -200,35 +232,6 @@ class RestClient: raise ResponseCodeError( f'Failed to update user: response code: {response.status_code} is not 202 (ACCEPTED)') - def update_user_theme(self, user_id: str, theme: str) -> User: - """ - Updates the theme of a user with given user id. - - :param user_id: The user id of the user that should be updated. - :param theme: The updated user theme name. - - :returns: The user, if successful. - - :raises ResponseCodeError: If something went wrong with the update. - :raises ForbiddenError: If the action is not allowed. - :raises NotExistsError: If theuser does not exist. - """ - url = f'/api/user/{user_id}/theme' - response = self._wrapper(method="put", url=url, force_auth=True, payload=UpdateUserTheme(theme=theme)) - if response.status_code == 202: - body = response.json() - return User.model_validate(body) - if response.status_code == 400: - raise ResponseCodeError(f'Failed to update user theme: invalid values') - if response.status_code == 403: - raise ForbiddenError(f'Failed to update user password: not allowed') - if response.status_code == 404: - raise NotExistsError(f'Failed to update user theme: user not found') - if response.status_code == 405: - raise ResponseCodeError(f'Failed to update user theme: foreign user') - raise ResponseCodeError( - f'Failed to update user theme: response code: {response.status_code} is not 202 (ACCEPTED)') - def update_user_password(self, user_id: str, password: str) -> User: """ Updates the password of a user with given user id. diff --git a/lib/python/dbrepo/api/dto.py b/lib/python/dbrepo/api/dto.py index db84bbbdbc3b886fcabcf840cfbbbba692bd1f43..6f00b545499c213d08ac6fa8c1fc2a0c4256f4a6 100644 --- a/lib/python/dbrepo/api/dto.py +++ b/lib/python/dbrepo/api/dto.py @@ -1,10 +1,11 @@ from __future__ import annotations +import uuid from dataclasses import field from enum import Enum import datetime from typing import List, Optional, Any, Annotated -from pydantic import BaseModel, ConfigDict, PlainSerializer +from pydantic import BaseModel, ConfigDict, PlainSerializer, Field Timestamp = Annotated[ datetime.datetime, PlainSerializer(lambda v: v.strftime('%Y-%m-%dT%H:%M:%S.%f')[:-3] + 'Z', return_type=str) @@ -19,6 +20,18 @@ class ImageDate(BaseModel): created_at: Timestamp +class JwtAuth(BaseModel): + access_token: str + refresh_token: str + id_token: str + expires_in: int + refresh_expires_in: int + not_before_policy: int = Field(alias='not-before-policy') + scope: str + session_state: uuid.UUID + token_type: str + + class Image(BaseModel): id: int registry: str @@ -51,6 +64,8 @@ class CreateUser(BaseModel): class UpdateUser(BaseModel): + theme: str + language: str firstname: Optional[str] = None lastname: Optional[str] = None affiliation: Optional[str] = None diff --git a/lib/python/tests/test_component_user.py b/lib/python/tests/test_component_user.py new file mode 100644 index 0000000000000000000000000000000000000000..bc9961bc7ec6c2f8d6938c50d80d8a041f473ca7 --- /dev/null +++ b/lib/python/tests/test_component_user.py @@ -0,0 +1,52 @@ +import logging +import unittest +import random +import string + +from dbrepo.RestClient import RestClient + + +def rand(size=6, chars=string.ascii_uppercase + string.digits): + return ''.join(random.choice(chars) for _ in range(size)) + + +class UserComponentTest(unittest.TestCase): + + def test_create_user_find_whoami_login_basic_oidc(self): + # params + username = rand(size=8).lower() + password = rand(size=8) + email = rand(size=8) + "@example.com" + print(f"creating user {username} with password {password} with email {email}") + # create user + client = RestClient(endpoint="http://localhost") + response = client.create_user(username=username, password=password, email=email) + self.assertEqual(username, response.username) + self.assertIsNotNone(response.id) + user_id = response.id + # find user + client = RestClient(endpoint="http://localhost", username=username, password=password) + response = client.get_user(user_id=user_id) + self.assertEqual(username, response.username) + self.assertEqual(user_id, response.id) + # whoami + response = client.whoami() + self.assertEqual(username, response) + # login basic + response = client.get_jwt_auth(username=username, password=password) + self.assertIsNotNone(response.id_token) + access_token = response.access_token + self.assertIsNotNone(response.access_token) + self.assertIsNotNone(response.refresh_token) + self.assertEqual(0, response.not_before_policy) + self.assertIsNotNone(response.expires_in) + self.assertIsNotNone(response.refresh_expires_in) + self.assertIsNotNone(response.session_state) + self.assertIsNotNone(response.scope) + # login oidc + client = RestClient(endpoint="http://localhost", password=access_token) + response = client.update_user(user_id=user_id, theme="light", language="en") + + +if __name__ == "__main__": + unittest.main() diff --git a/lib/python/tests/test_analyse.py b/lib/python/tests/test_unit_analyse.py similarity index 94% rename from lib/python/tests/test_analyse.py rename to lib/python/tests/test_unit_analyse.py index a4668fedc500dbd8c6ebebf9edb6f3885156f5eb..a26d7aa8441fd716640ca1d71c65a26f45f154ff 100644 --- a/lib/python/tests/test_analyse.py +++ b/lib/python/tests/test_unit_analyse.py @@ -7,7 +7,7 @@ from dbrepo.RestClient import RestClient from dbrepo.api.dto import KeyAnalysis -class AnalyseTest(unittest.TestCase): +class AnalyseUnitTest(unittest.TestCase): def test_analyse_keys_succeeds(self): with requests_mock.Mocker() as mock: diff --git a/lib/python/tests/test_container.py b/lib/python/tests/test_unit_container.py similarity index 99% rename from lib/python/tests/test_container.py rename to lib/python/tests/test_unit_container.py index e9988f19ca9dd1567d48295c7c4e3184acc30b83..8f3297879ada5fcf416cc8afc508d059cf28c95d 100644 --- a/lib/python/tests/test_container.py +++ b/lib/python/tests/test_unit_container.py @@ -10,7 +10,7 @@ from dbrepo.api.exceptions import ResponseCodeError, NotExistsError from dbrepo.api.dto import ImageDate -class ContainerTest(unittest.TestCase): +class ContainerUnitTest(unittest.TestCase): def test_get_containers_empty_succeeds(self): with requests_mock.Mocker() as mock: diff --git a/lib/python/tests/test_database.py b/lib/python/tests/test_unit_database.py similarity index 99% rename from lib/python/tests/test_database.py rename to lib/python/tests/test_unit_database.py index 1ac768690c5ac6e1ad5bececc35755b666583d5b..cb0bb19a702233cc3b573c028bd4fe1589e54fa3 100644 --- a/lib/python/tests/test_database.py +++ b/lib/python/tests/test_unit_database.py @@ -12,7 +12,7 @@ from dbrepo.api.exceptions import ResponseCodeError, NotExistsError, ForbiddenEr from dbrepo.api.dto import ImageDate -class DatabaseTest(unittest.TestCase): +class DatabaseUnitTest(unittest.TestCase): def test_get_databases_empty_succeeds(self): with requests_mock.Mocker() as mock: diff --git a/lib/python/tests/test_identifier.py b/lib/python/tests/test_unit_identifier.py similarity index 99% rename from lib/python/tests/test_identifier.py rename to lib/python/tests/test_unit_identifier.py index ec63b3c3051d793706e091dedaa59b6e0d16fca7..b64816731d4dcae28d87b38f1e4ef43ae25cf439 100644 --- a/lib/python/tests/test_identifier.py +++ b/lib/python/tests/test_unit_identifier.py @@ -13,7 +13,7 @@ from dbrepo.api.exceptions import MalformedError, ForbiddenError, NotExistsError AuthenticationError -class IdentifierTest(unittest.TestCase): +class IdentifierUnitTest(unittest.TestCase): def test_create_identifier_succeeds(self): with requests_mock.Mocker() as mock: diff --git a/lib/python/tests/test_license.py b/lib/python/tests/test_unit_license.py similarity index 96% rename from lib/python/tests/test_license.py rename to lib/python/tests/test_unit_license.py index bddb99213a0c675afef3937f20ef96f3362d0d32..2efb613c42f66dbac6908cfbc75e1054a16c1268 100644 --- a/lib/python/tests/test_license.py +++ b/lib/python/tests/test_unit_license.py @@ -7,7 +7,7 @@ from dbrepo.RestClient import RestClient from dbrepo.api.dto import License -class DatabaseTest(unittest.TestCase): +class DatabaseUnitTest(unittest.TestCase): def test_get_licenses_empty_succeeds(self): with requests_mock.Mocker() as mock: diff --git a/lib/python/tests/test_query.py b/lib/python/tests/test_unit_query.py similarity index 99% rename from lib/python/tests/test_query.py rename to lib/python/tests/test_unit_query.py index d876401809b37c4aa65cdd90552eb1fc6ac21d13..1a6109dc2b85e00e668eca0691dc36f9205ccc89 100644 --- a/lib/python/tests/test_query.py +++ b/lib/python/tests/test_unit_query.py @@ -13,7 +13,7 @@ from dbrepo.api.exceptions import MalformedError, NotExistsError, ForbiddenError MetadataConsistencyError, AuthenticationError -class QueryTest(unittest.TestCase): +class QueryUnitTest(unittest.TestCase): def test_execute_query_succeeds(self): with requests_mock.Mocker() as mock: diff --git a/lib/python/tests/test_rest_client.py b/lib/python/tests/test_unit_rest_client.py similarity index 97% rename from lib/python/tests/test_rest_client.py rename to lib/python/tests/test_unit_rest_client.py index 64dd3d0032877244b2d5d927b87740945d909d60..ca425683816d4a76a1dff452420e38c364750111 100644 --- a/lib/python/tests/test_rest_client.py +++ b/lib/python/tests/test_unit_rest_client.py @@ -4,7 +4,7 @@ from unittest import TestCase, mock, main from dbrepo.RestClient import RestClient -class DatabaseTest(TestCase): +class DatabaseUnitTest(TestCase): def test_constructor_succeeds(self): # test diff --git a/lib/python/tests/test_table.py b/lib/python/tests/test_unit_table.py similarity index 99% rename from lib/python/tests/test_table.py rename to lib/python/tests/test_unit_table.py index 4839f4ffe152bd4f61f8a572c987b8bb123f980b..1a8e424ffc625caa93d6fbd2ac04acb5ec706824 100644 --- a/lib/python/tests/test_table.py +++ b/lib/python/tests/test_unit_table.py @@ -13,7 +13,7 @@ from dbrepo.api.exceptions import MalformedError, ForbiddenError, NotExistsError AuthenticationError -class TableTest(unittest.TestCase): +class TableUnitTest(unittest.TestCase): def test_create_table_succeeds(self): exp = Table(id=2, diff --git a/lib/python/tests/test_user.py b/lib/python/tests/test_unit_user.py similarity index 79% rename from lib/python/tests/test_user.py rename to lib/python/tests/test_unit_user.py index 67698a8fd58b811f58a89a7ba9d1c5245f2f242c..08133fa6f02318a585ce0bcdb7f328072ba860de 100644 --- a/lib/python/tests/test_user.py +++ b/lib/python/tests/test_unit_user.py @@ -8,7 +8,7 @@ from dbrepo.api.exceptions import ResponseCodeError, UsernameExistsError, EmailE ForbiddenError, AuthenticationError -class UserTest(unittest.TestCase): +class UserUnitTest(unittest.TestCase): def test_whoami_fails(self): username = RestClient().whoami() @@ -143,7 +143,8 @@ class UserTest(unittest.TestCase): json=exp.model_dump()) # test client = RestClient(username="a", password="b") - response = client.update_user(user_id='8638c043-5145-4be8-a3e4-4b79991b0a16', firstname='Martin') + response = client.update_user(user_id='8638c043-5145-4be8-a3e4-4b79991b0a16', firstname='Martin', + language='en', theme='light') self.assertEqual(exp, response) def test_update_user_not_allowed_fails(self): @@ -153,7 +154,8 @@ class UserTest(unittest.TestCase): # test try: client = RestClient(username="a", password="b") - response = client.update_user(user_id='8638c043-5145-4be8-a3e4-4b79991b0a16', firstname='Martin') + response = client.update_user(user_id='8638c043-5145-4be8-a3e4-4b79991b0a16', firstname='Martin', + language='en', theme='light') except ForbiddenError as e: pass @@ -164,7 +166,8 @@ class UserTest(unittest.TestCase): # test try: client = RestClient(username="a", password="b") - response = client.update_user(user_id='8638c043-5145-4be8-a3e4-4b79991b0a16', firstname='Martin') + response = client.update_user(user_id='8638c043-5145-4be8-a3e4-4b79991b0a16', firstname='Martin', + language='en', theme='light') except NotExistsError as e: pass @@ -175,7 +178,8 @@ class UserTest(unittest.TestCase): # test try: client = RestClient(username="a", password="b") - response = client.update_user(user_id='8638c043-5145-4be8-a3e4-4b79991b0a16', firstname='Martin') + response = client.update_user(user_id='8638c043-5145-4be8-a3e4-4b79991b0a16', firstname='Martin', + language='en', theme='light') except ForbiddenError as e: pass @@ -185,62 +189,8 @@ class UserTest(unittest.TestCase): mock.put('http://gateway-service/api/user/8638c043-5145-4be8-a3e4-4b79991b0a16', status_code=405) # test try: - response = RestClient().update_user(user_id='8638c043-5145-4be8-a3e4-4b79991b0a16', firstname='Martin') - except AuthenticationError as e: - pass - - def test_update_user_theme_succeeds(self): - with requests_mock.Mocker() as mock: - exp = User(id='8638c043-5145-4be8-a3e4-4b79991b0a16', username='mweise', given_name='Martin', - attributes=UserAttributes(theme='dark')) - # mock - mock.put('http://gateway-service/api/user/8638c043-5145-4be8-a3e4-4b79991b0a16/theme', status_code=202, - json=exp.model_dump()) - # test - client = RestClient(username="a", password="b") - response = client.update_user_theme(user_id='8638c043-5145-4be8-a3e4-4b79991b0a16', theme='dark') - self.assertEqual(exp, response) - - def test_update_user_theme_not_allowed_fails(self): - with requests_mock.Mocker() as mock: - # mock - mock.put('http://gateway-service/api/user/8638c043-5145-4be8-a3e4-4b79991b0a16/theme', status_code=403) - # test - try: - client = RestClient(username="a", password="b") - response = client.update_user_theme(user_id='8638c043-5145-4be8-a3e4-4b79991b0a16', theme='dark') - except ForbiddenError as e: - pass - - def test_update_user_theme_not_found_fails(self): - with requests_mock.Mocker() as mock: - # mock - mock.put('http://gateway-service/api/user/8638c043-5145-4be8-a3e4-4b79991b0a16/theme', status_code=404) - # test - try: - client = RestClient(username="a", password="b") - response = client.update_user_theme(user_id='8638c043-5145-4be8-a3e4-4b79991b0a16', theme='dark') - except NotExistsError as e: - pass - - def test_update_user_theme_foreign_fails(self): - with requests_mock.Mocker() as mock: - # mock - mock.put('http://gateway-service/api/user/8638c043-5145-4be8-a3e4-4b79991b0a16/theme', status_code=405) - # test - try: - client = RestClient(username="a", password="b") - response = client.update_user_theme(user_id='8638c043-5145-4be8-a3e4-4b79991b0a16', theme='dark') - except ResponseCodeError as e: - pass - - def test_update_user_theme_not_auth_fails(self): - with requests_mock.Mocker() as mock: - # mock - mock.put('http://gateway-service/api/user/8638c043-5145-4be8-a3e4-4b79991b0a16/theme', status_code=405) - # test - try: - response = RestClient().update_user_theme(user_id='8638c043-5145-4be8-a3e4-4b79991b0a16', theme='dark') + response = RestClient().update_user(user_id='8638c043-5145-4be8-a3e4-4b79991b0a16', firstname='Martin', + language='en', theme='light') except AuthenticationError as e: pass diff --git a/lib/python/tests/test_view.py b/lib/python/tests/test_unit_view.py similarity index 99% rename from lib/python/tests/test_view.py rename to lib/python/tests/test_unit_view.py index 2a61cab89ee2a4a0fa1778c598da27e00ea9910b..e7ef8751e73a812772a623d86c590da08cb3852a 100644 --- a/lib/python/tests/test_view.py +++ b/lib/python/tests/test_unit_view.py @@ -11,7 +11,7 @@ from dbrepo.api.dto import UserAttributes, User, View, Result from dbrepo.api.exceptions import ForbiddenError, NotExistsError, MalformedError, AuthenticationError -class ViewTest(unittest.TestCase): +class ViewUnitTest(unittest.TestCase): def test_get_views_empty_succeeds(self): with requests_mock.Mocker() as mock: