diff --git a/reana_server/gitlab_client.py b/reana_server/gitlab_client.py new file mode 100644 index 00000000..4060b702 --- /dev/null +++ b/reana_server/gitlab_client.py @@ -0,0 +1,266 @@ +# This file is part of REANA. +# Copyright (C) 2024 CERN. +# +# REANA is free software; you can redistribute it and/or modify it +# under the terms of the MIT License; see LICENSE file for more details. +"""REANA-Server GitLab client.""" + +from typing import Dict, Optional, Union +from urllib.parse import quote_plus +import requests +import yaml + +from reana_commons.k8s.secrets import REANAUserSecretsStore + +from reana_server.config import REANA_GITLAB_HOST + + +class GitLabException(Exception): + """Base class for GitLab exceptions.""" + + def __init__(self, message): + """Initialise the GitLabException exception.""" + self.message = message + + def __str__(self): + """Return the exception message.""" + return self.message + + +class GitLabRequestError(GitLabException): + """Raised when a GitLab API request fails.""" + + def __init__(self, response, message=None): + """Initialise the GitLabRequestError exception.""" + message = message or f"GitLab API request failed: {response.status_code}" + super().__init__(message) + self.response = response + + +class InvalidGitLabTokenError(GitLabException): + """Raised when GitLab token is invalid or missing.""" + + def __init__(self, message=None): + """Initialise the InvalidGitLabTokenError exception.""" + message = message or ( + "GitLab token invalid or missing, " + "please go to your profile page on REANA " + "and reconnect to GitLab." + ) + super().__init__(message) + + +class GitLabClient: + """Client for interacting with the GitLab API.""" + + MAX_PER_PAGE = 100 + """Maximum number of items per page in paginated responses.""" + + @classmethod + def from_k8s_secret(cls, user_id, **kwargs): + """ + Create a client instance taking the GitLab token from the user's k8s secret. + + :param user_id: User UUID. + """ + secrets_store = REANAUserSecretsStore(str(user_id)) + gitlab_token = secrets_store.get_secret_value("gitlab_access_token") + if not gitlab_token: + raise InvalidGitLabTokenError + return cls(access_token=gitlab_token, **kwargs) + + def __init__( + self, + host: str = REANA_GITLAB_HOST, + access_token: Optional[str] = None, + http_request=None, + ): + """Initialise the GitLab client. + + :param host: GitLab host (default: REANA_GITLAB_HOST) + :param access_token: GitLab access token (default: unauthenticated) + :param http_request: Function to make HTTP requests (default: requests.request). + """ + self.access_token = access_token + self.host = host + self._http_request = ( + http_request if http_request is not None else requests.request + ) + + def _make_url(self, path: str, **kwargs: Dict[str, str]): + quoted = {k: quote_plus(v) for k, v in kwargs.items()} + return f"https://{self.host}/api/v4/{path.lstrip('/').format(**quoted)}" + + def _request(self, verb: str, url: str, params=None, data=None): + res = self._http_request(verb, url, params=params, data=data) + if res.status_code == 401: + raise InvalidGitLabTokenError + elif res.status_code >= 400: + message = f"GitLab API request failed: {res.status_code}, {res.content}" + try: + response = res.json() + if "message" in response: + message = f"GitLab API request failed: {res.status_code}, {response['message']}" + elif "error_description" in response: + message = f"GitLab API request failed: {res.status_code}, {response['error_description']}" + except Exception: + pass + raise GitLabRequestError(res, message) + return res + + def _get(self, url, params=None): + return self._request("GET", url, params) + + def _post(self, url, params=None, data=None): + return self._request("POST", url, params, data) + + def _unroll_pagination(self, url, params): + # use maximum allowed value to avoid too many network requests + params["per_page"] = self.MAX_PER_PAGE + res = self._get(url, params) + while res: + yield from res.json() + next_url = res.links.get("next", {}).get("url") + res = self._get(next_url) if next_url else None + + def oauth_token(self, data): + """Request an OAuth token from GitLab. + + :param data: Dictionary with the following keys: + - client_id: The client ID of the application. + - client_secret: The client secret of the application. + - code: The authorization code. + - redirect_uri: The redirect URI of the application. + - grant_type: The grant type of the request. + """ + # _make_url is not used here as the URL does not contain `api/v4` + url = f"https://{self.host}/oauth/token" + return self._post(url, data=data) + + def get_file( + self, project: Union[int, str], file_path: str, ref: Optional[str] = None + ): + """Get the content of a file in a GitLab repository. + + :param project: Project ID or name. + :param file_path: Path to the file. + :param ref: The name of a repository branch, tag or commit. + """ + url = self._make_url( + "projects/{project}/repository/files/{file_path}/raw", + project=str(project), + file_path=file_path, + ) + params = { + "access_token": self.access_token, + "ref": ref, + } + return self._get(url, params) + + def get_projects(self, page: int = 1, per_page: Optional[int] = None, **kwargs): + """Get a list of projects the user has access to. + + :param page: Page number. + :param per_page: Number of projects per page. + :param kwargs: Additional query parameters to customise and filter the results. + """ + url = self._make_url("projects") + params = { + "access_token": self.access_token, + "page": page, + "per_page": per_page, + **kwargs, + } + return self._get(url, params) + + def get_webhooks( + self, project: Union[int, str], page: int = 1, per_page: Optional[int] = None + ): + """Get a list of webhooks for a project. + + :param project: Project ID or name. + :param page: Page number. + :param per_page: Number of webhooks per page. + """ + url = self._make_url("projects/{project}/hooks", project=str(project)) + params = { + "access_token": self.access_token, + "page": page, + "per_page": per_page, + } + return self._get(url, params) + + def get_all_webhooks(self, project: Union[int, str]): + """Get all webhooks for a project. + + Compared to `get_webhooks`, this method returns a generator that yields + all webhooks in the project, making multiple requests if necessary. + + :param project: Project ID or name. + """ + url = self._make_url("projects/{project}/hooks", project=str(project)) + params = {"access_token": self.access_token} + yield from self._unroll_pagination(url, params) + + def create_webhook(self, project: Union[int, str], config: Dict): + """Create a webhook for a project. + + :param project: Project ID or name. + :param config: Dictionary withe the webhook configuration. + See https://docs.gitlab.com/ee/api/projects.html#add-project-hook + """ + url = self._make_url("projects/{project}/hooks", project=str(project)) + params = {"access_token": self.access_token} + return self._post(url, params, data=config) + + def delete_webhook(self, project: Union[int, str], hook_id: int): + """Delete a webhook from a project. + + :param project: Project ID or name. + :param hook_id: Webhook ID. + """ + url = self._make_url( + "projects/{project}/hooks/{hook_id}", + project=str(project), + hook_id=str(hook_id), + ) + params = { + "access_token": self.access_token, + } + return self._request("DELETE", url, params) + + def set_commit_build_status( + self, + project: Union[int, str], + commit_sha: str, + state: str, + description: Optional[str] = None, + name: str = "reana", + ): + """Set the status of a commit in a GitLab repository. + + :param project: Project ID or name. + :param commit_sha: The commit SHA. + :param state: The state of the status. + Can be one of 'pending', 'running', 'success', 'failed', 'canceled'. + :param description: A short description of the status. + :param name: The name of the context (default: 'reana'). + """ + url = self._make_url( + "projects/{project}/statuses/{commit_sha}", + project=str(project), + commit_sha=commit_sha, + ) + params = { + "access_token": self.access_token, + "state": state, + "description": description, + "name": name, + } + return self._post(url, params) + + def get_user(self): + """Get the user's profile.""" + url = self._make_url("user") + params = {"access_token": self.access_token} + return self._get(url, params) diff --git a/reana_server/rest/gitlab.py b/reana_server/rest/gitlab.py index 25e1460a..d10f44bb 100644 --- a/reana_server/rest/gitlab.py +++ b/reana_server/rest/gitlab.py @@ -37,6 +37,11 @@ REANA_GITLAB_URL, ) from reana_server.decorators import signin_required +from reana_server.gitlab_client import ( + GitLabClient, + GitLabRequestError, + InvalidGitLabTokenError, +) from reana_server.utils import ( _format_gitlab_secrets, _get_gitlab_hook_id, @@ -167,12 +172,20 @@ def gitlab_oauth(user): # noqa "code": gitlab_code, "grant_type": "authorization_code", } - gitlab_response = requests.post( - REANA_GITLAB_URL + "/oauth/token", data=params - ).content + + # request access token + client = GitLabClient() + gitlab_response = client.oauth_token(params).json() + access_token = gitlab_response["access_token"] + + # get GitLab user details + authenticated_client = GitLabClient(access_token=access_token) + gitlab_user = authenticated_client.get_user().json() + + # store access token inside k8s secrets secrets_store = REANAUserSecretsStore(str(user.id_)) secrets_store.add_secrets( - _format_gitlab_secrets(gitlab_response), overwrite=True + _format_gitlab_secrets(gitlab_user, access_token), overwrite=True ) return redirect(next_url), 302 else: @@ -291,20 +304,10 @@ def gitlab_projects( } """ try: - secrets_store = REANAUserSecretsStore(str(user.id_)) - gitlab_token = secrets_store.get_secret_value("gitlab_access_token") - - if not gitlab_token: - return jsonify({"message": "Missing GitLab access token."}), 401 - - gitlab_url = urljoin(REANA_GITLAB_URL, "/api/v4/projects") params = { - "access_token": gitlab_token, # show projects in which user is at least a `Maintainer` # as that's the minimum access level needed to create webhooks "min_access_level": 40, - "page": page, - "per_page": size, "search": search, # include ancestor namespaces when matching search criteria "search_namespaces": "true", @@ -312,38 +315,43 @@ def gitlab_projects( "simple": "true", } - gitlab_res = requests.get(gitlab_url, params=params) - if gitlab_res.status_code == 200: - projects = list() - for gitlab_project in gitlab_res.json(): - hook_id = _get_gitlab_hook_id(gitlab_project["id"], gitlab_token) - projects.append( - { - "id": gitlab_project["id"], - "name": gitlab_project["name"], - "path": gitlab_project["path_with_namespace"], - "url": gitlab_project["web_url"], - "hook_id": hook_id, - } - ) + client = GitLabClient.from_k8s_secret(user.id_) + gitlab_res = client.get_projects(page=page, per_page=size, **params) - response = { - "has_next": bool(gitlab_res.headers.get("x-next-page")), - "has_prev": bool(gitlab_res.headers.get("x-prev-page")), - "items": projects, - "page": int(gitlab_res.headers.get("x-page")), - "size": int(gitlab_res.headers.get("x-per-page")), - "total": ( - int(gitlab_res.headers.get("x-total")) - if gitlab_res.headers.get("x-total") - else None - ), - } + projects = list() + for gitlab_project in gitlab_res.json(): + hook_id = _get_gitlab_hook_id(gitlab_project["id"], client) + projects.append( + { + "id": gitlab_project["id"], + "name": gitlab_project["name"], + "path": gitlab_project["path_with_namespace"], + "url": gitlab_project["web_url"], + "hook_id": hook_id, + } + ) - return jsonify(response), 200 + response = { + "has_next": bool(gitlab_res.headers.get("x-next-page")), + "has_prev": bool(gitlab_res.headers.get("x-prev-page")), + "items": projects, + "page": int(gitlab_res.headers.get("x-page")), + "size": int(gitlab_res.headers.get("x-per-page")), + "total": ( + int(gitlab_res.headers.get("x-total")) + if gitlab_res.headers.get("x-total") + else None + ), + } + + return jsonify(response), 200 + except InvalidGitLabTokenError as e: + return jsonify({"message": str(e)}), 401 + except GitLabRequestError as e: + logging.error(str(e)) return ( jsonify({"message": "Project list could not be retrieved"}), - gitlab_res.status_code, + e.response.status_code, ) except ValueError: return jsonify({"message": "Token is not valid."}), 403 @@ -465,14 +473,10 @@ def gitlab_webhook(user): # noqa """ try: - secrets_store = REANAUserSecretsStore(str(user.id_)) - gitlab_token = secrets_store.get_secret_value("gitlab_access_token") + client = GitLabClient.from_k8s_secret(user.id_) parameters = request.json if request.method == "POST": - gitlab_url = ( - REANA_GITLAB_URL + "/api/v4/projects/" + "{0}/hooks?access_token={1}" - ) - webhook_payload = { + webhook_config = { "url": url_for("workflows.create_workflow", _external=True), "push_events": True, "push_events_branch_filter": "master", @@ -480,24 +484,23 @@ def gitlab_webhook(user): # noqa "enable_ssl_verification": False, "token": user.access_token, } - webhook = requests.post( - gitlab_url.format(parameters["project_id"], gitlab_token), - data=webhook_payload, - ) - return jsonify({"id": webhook.json()["id"]}), 201 + webhook = client.create_webhook( + parameters["project_id"], webhook_config + ).json() + return jsonify({"id": webhook["id"]}), 201 elif request.method == "DELETE": - gitlab_url = ( - REANA_GITLAB_URL - + "/api/v4/projects/" - + "{0}/hooks/{1}?access_token={2}" - ) - resp = requests.delete( - gitlab_url.format( - parameters["project_id"], parameters["hook_id"], gitlab_token - ) - ) + project_id = parameters["project_id"] + hook_id = parameters["hook_id"] + resp = client.delete_webhook(project_id, hook_id) return resp.content, resp.status_code - + except InvalidGitLabTokenError as e: + return jsonify({"message": str(e)}), 401 + except GitLabRequestError as e: + logging.error(str(e)) + return ( + jsonify({"message": "Error while creating or deleting webhook"}), + e.response.status_code, + ) except ValueError: return jsonify({"message": "Token is not valid."}), 403 except Exception as e: diff --git a/reana_server/rest/workflows.py b/reana_server/rest/workflows.py index de6f7861..bf4ea53f 100644 --- a/reana_server/rest/workflows.py +++ b/reana_server/rest/workflows.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # # This file is part of REANA. -# Copyright (C) 2018, 2019, 2020, 2021, 2022 CERN. +# Copyright (C) 2018, 2019, 2020, 2021, 2022, 2024 CERN. # # REANA is free software; you can redistribute it and/or modify it # under the terms of the MIT License; see LICENSE file for more details. @@ -34,6 +34,7 @@ from reana_server.config import REANA_HOSTNAME from reana_server.decorators import check_quota, signin_required from reana_server.deleter import Deleter, InOrOut +from reana_server.gitlab_client import GitLabRequestError, InvalidGitLabTokenError from reana_server.validation import ( validate_inputs, validate_workspace_path, @@ -579,6 +580,14 @@ def create_workflow(user): # noqa parameters = request.json publish_workflow_submission(workflow, user.id_, parameters) return jsonify(response), http_response.status_code + except InvalidGitLabTokenError as e: + return jsonify({"message": str(e)}), 401 + except GitLabRequestError as e: + logging.error(str(e)) + return ( + jsonify({"message": "Could not retrieve REANA specification from GitLab."}), + e.response.status_code, + ) except HTTPError as e: logging.error(traceback.format_exc()) return jsonify(e.response.json()), e.response.status_code @@ -1673,7 +1682,6 @@ def upload_file(workflow_id_or_name, user): # noqa params={"user": str(user.id_), "file_name": request.args.get("file_name")}, headers={"Content-Type": "application/octet-stream"}, ) - return jsonify(http_response.json()), http_response.status_code except HTTPError as e: logging.error(traceback.format_exc()) diff --git a/reana_server/utils.py b/reana_server/utils.py index 9c835ca7..6cb10a26 100644 --- a/reana_server/utils.py +++ b/reana_server/utils.py @@ -67,7 +67,6 @@ ) from reana_server.config import ( ADMIN_USER_ID, - REANA_GITLAB_URL, REANA_HOSTNAME, REANA_USER_EMAIL_CONFIRMATION, REANA_WORKFLOW_SCHEDULING_POLICY, @@ -76,6 +75,11 @@ WORKSPACE_RETENTION_PERIOD, DEFAULT_WORKSPACE_RETENTION_RULE, ) +from reana_server.gitlab_client import ( + GitLabClient, + GitLabException, + InvalidGitLabTokenError, +) from reana_server.validation import validate_retention_rule, validate_workflow @@ -432,11 +436,6 @@ def _get_user_from_invenio_user(id): def _get_reana_yaml_from_gitlab(webhook_data, user_id): - gitlab_api = ( - REANA_GITLAB_URL - + "/api/v4/projects/{0}" - + "/repository/files/{1}/raw?ref={2}&access_token={3}" - ) reana_yaml = "reana.yaml" if webhook_data["object_kind"] == "push": branch = webhook_data["project"]["default_branch"] @@ -444,14 +443,11 @@ def _get_reana_yaml_from_gitlab(webhook_data, user_id): elif webhook_data["object_kind"] == "merge_request": branch = webhook_data["object_attributes"]["source_branch"] commit_sha = webhook_data["object_attributes"]["last_commit"]["id"] - secrets_store = REANAUserSecretsStore(str(user_id)) - gitlab_token = secrets_store.get_secret_value("gitlab_access_token") project_id = webhook_data["project"]["id"] - yaml_file = requests.get( - gitlab_api.format(project_id, reana_yaml, branch, gitlab_token) - ) + client = GitLabClient.from_k8s_secret(user_id) + yaml_file = client.get_file(project_id, reana_yaml, branch).content return ( - yaml.load(yaml_file.content, Loader=yaml.FullLoader), + yaml.safe_load(yaml_file), webhook_data["project"]["path_with_namespace"], webhook_data["project"]["name"], branch, @@ -467,78 +463,43 @@ def _fail_gitlab_commit_build_status( HTTP errors will be ignored. """ state = "failed" - system_name = "reana" - git_repo = urlparse.quote_plus(git_repo) - description = urlparse.quote_plus(description) - - secret_store = REANAUserSecretsStore(user.id_) - gitlab_access_token = secret_store.get_secret_value("gitlab_access_token") - commit_status_url = ( - f"{REANA_GITLAB_URL}/api/v4/projects/{git_repo}/statuses/" - f"{git_ref}?access_token={gitlab_access_token}&state={state}" - f"&description={description}&name={system_name}" - ) - requests.post(commit_status_url) + try: + client = GitLabClient.from_k8s_secret(user.id_) + client.set_commit_build_status(git_repo, git_ref, state, description) + except GitLabException as e: + logging.warn(f"Could not set commit build status: {e}") -def _format_gitlab_secrets(gitlab_response): - access_token = json.loads(gitlab_response)["access_token"] - user = json.loads( - requests.get( - REANA_GITLAB_URL + "/api/v4/user?access_token={0}".format(access_token) - ).content - ) +def _format_gitlab_secrets(gitlab_user, access_token): return { "gitlab_access_token": { "value": base64.b64encode(access_token.encode("utf-8")).decode("utf-8"), "type": "env", }, "gitlab_user": { - "value": base64.b64encode(user["username"].encode("utf-8")).decode("utf-8"), + "value": base64.b64encode(gitlab_user["username"].encode("utf-8")).decode( + "utf-8" + ), "type": "env", }, } -def _unpaginate_gitlab_endpoint(url: str) -> Generator[Any, None, None]: - """Get all the paginated records of a given GitLab endpoint. - - :param url: Endpoint URL to the first page. - """ - while url: - logging.debug(f"Request to '{url}' while unpaginating GitLab endpoint") - response = requests.get(url) - response.raise_for_status() - yield from response.json() - url = response.links.get("next", {}).get("url") - - -def _get_gitlab_hook_id(project_id, gitlab_token): +def _get_gitlab_hook_id(project_id, client: GitLabClient): """Return REANA hook id from a GitLab project if it is connected. By checking its webhooks and comparing them to REANA ones. - :param response: Flask response. :param project_id: Project id on GitLab. - :param gitlab_token: GitLab token. + :param client: GitLab client. """ - gitlab_hooks_url = ( - REANA_GITLAB_URL - + "/api/v4/projects/{0}/hooks?per_page=100&access_token={1}".format( - project_id, gitlab_token - ) - ) create_workflow_url = url_for("workflows.create_workflow", _external=True) - try: - for hook in _unpaginate_gitlab_endpoint(gitlab_hooks_url): + for hook in client.get_all_webhooks(project_id): if hook["url"] and hook["url"] == create_workflow_url: return hook["id"] - except requests.HTTPError as e: - logging.warning( - f"GitLab hook request failed with status code: {e.response.status_code}, " - f"content: {e.response.content}" - ) + except GitLabException as e: + logging.warn(f"GitLab hook request failed: {e}") return None diff --git a/tests/test_gitlab_client.py b/tests/test_gitlab_client.py new file mode 100644 index 00000000..3f50852a --- /dev/null +++ b/tests/test_gitlab_client.py @@ -0,0 +1,266 @@ +# -*- coding: utf-8 -*- +# +# This file is part of REANA. +# Copyright (C) 2024 CERN. +# +# REANA is free software; you can redistribute it and/or modify it +# under the terms of the MIT License; see LICENSE file for more details. +"""REANA-Server GitLab client tests.""" + +import unittest.mock as mock +from uuid import uuid4 + +import pytest +from reana_commons.k8s.secrets import REANAUserSecretsStore + +import reana_server.config as config +from reana_server.gitlab_client import GitLabClient, InvalidGitLabTokenError + + +def mock_response(status_code=200, json={}, content=b"", links={}): + """Mock response.""" + response = mock.MagicMock() + response.status_code = status_code + response.content = content + response.json.return_value = json + response.links = links + return response + + +def test_gitlab_client_from_k8s_secret(monkeypatch): + """Test creating authenticated GitLab client from user k8s secret.""" + user_id = uuid4() + + def get_secret_value(store, secret_name): + assert secret_name == "gitlab_access_token" + return "gitlab_token" + + monkeypatch.setattr(REANAUserSecretsStore, "get_secret_value", get_secret_value) + + gitlab_client = GitLabClient.from_k8s_secret(user_id, host="gitlab.example.org") + assert gitlab_client.access_token == "gitlab_token" + assert gitlab_client.host == "gitlab.example.org" + + +def test_gitlab_client_from_k8s_secret_invalid_token(monkeypatch): + """Test creating authenticated GitLab client when secret is not available.""" + + def get_secret_value(store, secret_name): + assert secret_name == "gitlab_access_token" + return None + + monkeypatch.setattr(REANAUserSecretsStore, "get_secret_value", get_secret_value) + + with pytest.raises(InvalidGitLabTokenError): + GitLabClient.from_k8s_secret(uuid4(), host="gitlab.example.org") + + +def test_gitlab_client_oauth_token(): + """Test getting OAuth token from GitLab.""" + response = mock_response() + + def request(verb, url, params, data): + assert verb == "POST" + assert url == "https://gitlab.example.org/oauth/token" + assert params is None + assert data == {"foo": "bar"} + + return response + + gitlab_client = GitLabClient( + access_token="gitlab_token", host="gitlab.example.org", http_request=request + ) + + res = gitlab_client.oauth_token(data={"foo": "bar"}) + assert res is response + + +def test_gitlab_client_get_file(): + """Test getting file from GitLab.""" + + def request(verb, url, params, data): + assert verb == "GET" + assert ( + url == "https://gitlab.example.org/api/v4/" + "projects/123/repository/files/a.txt/raw" + ) + assert params == {"access_token": "gitlab_token", "ref": "feature-branch"} + assert data is None + + return mock_response(200, content=b"file content") + + gitlab_client = GitLabClient( + access_token="gitlab_token", host="gitlab.example.org", http_request=request + ) + + res = gitlab_client.get_file(project=123, file_path="a.txt", ref="feature-branch") + assert res.content == b"file content" + + +def test_gitlab_client_get_projects(): + """Test getting projects from GitLab.""" + + response = mock_response() + + def request(verb, url, params, data): + assert verb == "GET" + assert url == "https://gitlab.example.org/api/v4/projects" + assert params == {"access_token": "gitlab_token", "page": 123, "per_page": 20} + assert data is None + + return response + + gitlab_client = GitLabClient( + access_token="gitlab_token", host="gitlab.example.org", http_request=request + ) + + res = gitlab_client.get_projects(page=123, per_page=20) + assert res is response + + +def test_gitlab_client_get_webhooks(): + """Test getting webhooks from GitLab.""" + response = mock_response() + + def request(verb, url, params, data): + assert verb == "GET" + assert url == "https://gitlab.example.org/api/v4/projects/123/hooks" + assert params == {"access_token": "gitlab_token", "page": 123, "per_page": 20} + assert data is None + + return response + + gitlab_client = GitLabClient( + access_token="gitlab_token", host="gitlab.example.org", http_request=request + ) + + res = gitlab_client.get_webhooks(project=123, page=123, per_page=20) + assert res is response + + +def test_gitlab_client_get_all_webhooks(): + """Test getting all webhooks from GitLab.""" + num_request = 0 + + def request(verb, url, params, data): + nonlocal num_request + num_request += 1 + if num_request == 1: + assert verb == "GET" + assert url == "https://gitlab.example.org/api/v4/projects/123/hooks" + assert params == { + "access_token": "gitlab_token", + "per_page": 100, + } + assert data is None + return mock_response(json=[1, 2], links={"next": {"url": "second_url"}}) + elif num_request == 2: + assert verb == "GET" + assert url == "second_url" + assert params is None + assert data is None + return mock_response(json=[3, 4], links={}) + else: + assert False, "too many requests" + + gitlab_client = GitLabClient( + access_token="gitlab_token", host="gitlab.example.org", http_request=request + ) + + res = gitlab_client.get_all_webhooks(project=123) + assert list(res) == [1, 2, 3, 4] + + +def test_gitlab_client_create_webhook(): + """Test creating webhook in GitLab.""" + response = mock_response() + + def request(verb, url, params, data): + assert verb == "POST" + assert url == "https://gitlab.example.org/api/v4/projects/123/hooks" + assert params == { + "access_token": "gitlab_token", + } + assert data == {"xyz": "123"} + + return response + + gitlab_client = GitLabClient( + access_token="gitlab_token", host="gitlab.example.org", http_request=request + ) + + res = gitlab_client.create_webhook(project=123, config={"xyz": "123"}) + assert res is response + + +def test_gitlab_client_delete_webhook(): + """Test deleting webhook in GitLab.""" + response = mock_response() + + def request(verb, url, params, data): + assert verb == "DELETE" + assert url == "https://gitlab.example.org/api/v4/projects/123/hooks/456" + assert params == { + "access_token": "gitlab_token", + } + assert data is None + + return response + + gitlab_client = GitLabClient( + access_token="gitlab_token", host="gitlab.example.org", http_request=request + ) + + res = gitlab_client.delete_webhook(project=123, hook_id=456) + assert res is response + + +def test_gitlab_client_set_commit_build_status(): + """Test setting commit build status in GitLab.""" + response = mock_response() + + def request(verb, url, params, data): + assert verb == "POST" + assert url == "https://gitlab.example.org/api/v4/projects/123/statuses/12345" + assert params == { + "access_token": "gitlab_token", + "state": "success", + "name": "custom_name", + "description": "REANA workflow finished successfully", + } + assert data is None + + return response + + gitlab_client = GitLabClient( + access_token="gitlab_token", host="gitlab.example.org", http_request=request + ) + + res = gitlab_client.set_commit_build_status( + project=123, + commit_sha="12345", + state="success", + name="custom_name", + description="REANA workflow finished successfully", + ) + assert res is response + + +def test_gitlab_client_get_user(): + """Test getting user from GitLab.""" + response = mock_response() + + def request(verb, url, params, data): + assert verb == "GET" + assert url == "https://gitlab.example.org/api/v4/user" + assert params == {"access_token": "gitlab_token"} + assert data is None + + return response + + gitlab_client = GitLabClient( + access_token="gitlab_token", host="gitlab.example.org", http_request=request + ) + + res = gitlab_client.get_user() + assert res is response diff --git a/tests/test_utils.py b/tests/test_utils.py index dee6abeb..e06584b2 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -16,7 +16,6 @@ is_valid_email, filter_input_files, get_user_from_token, - _unpaginate_gitlab_endpoint, ) @@ -87,25 +86,3 @@ def test_get_user_from_token_two_tokens(default_user, session): # Check that old revoked token does not work with pytest.raises(ValueError, match="revoked"): get_user_from_token(old_token.token) - - -@patch("requests.get") -def test_gitlab_pagination(mock_get): - """Test getting all paginated results from GitLab.""" - # simulating two pages - first_response = Mock() - first_response.ok = True - first_response.links = {"next": {"url": "next_url"}} - first_response.json.return_value = [1, 2] - - second_response = Mock() - second_response.ok = True - second_response.links = {} - second_response.json.return_value = [3, 4] - - mock_get.side_effect = [first_response, second_response] - - res = list(_unpaginate_gitlab_endpoint("first_url")) - - assert res == [1, 2, 3, 4] - assert mock_get.call_args_list == [call("first_url"), call("next_url")] diff --git a/tests/test_views.py b/tests/test_views.py index e9afcaf5..b27709d0 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -850,7 +850,7 @@ def test_gitlab_projects(app: Flask, default_user): mock_get_secret_value = Mock() mock_get_secret_value.return_value = "gitlab_token" - with patch("requests.get", mock_requests_get), patch( + with patch("requests.request", mock_requests_get), patch( "reana_commons.k8s.secrets.REANAUserSecretsStore.get_secret_value", mock_get_secret_value, ):