From 23f829876681db7a5152b5705a2607391ad1baa7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20G=C3=B6bel?= <dgoebel@techfak.uni-bielefeld.de> Date: Fri, 18 Aug 2023 14:27:00 +0200 Subject: [PATCH] Add endpoints to update/delete the credentials for a workflow #42 --- app/api/api.py | 4 + app/api/endpoints/workflow.py | 15 +- app/api/endpoints/workflow_credentials.py | 122 +++++++++ app/api/endpoints/workflow_version.py | 35 +-- app/crud/crud_workflow.py | 29 +- app/crud/crud_workflow_version.py | 2 +- app/schemas/workflow.py | 23 +- app/schemas/workflow_version.py | 50 +--- app/tests/api/test_workflow_credentials.py | 292 +++++++++++++++++++++ app/tests/api/test_workflow_execution.py | 13 +- app/tests/conftest.py | 45 +++- app/tests/crud/test_workflow.py | 48 +++- 12 files changed, 587 insertions(+), 91 deletions(-) create mode 100644 app/api/endpoints/workflow_credentials.py create mode 100644 app/tests/api/test_workflow_credentials.py diff --git a/app/api/api.py b/app/api/api.py index 5b224b4..261a792 100644 --- a/app/api/api.py +++ b/app/api/api.py @@ -4,6 +4,7 @@ from fastapi import APIRouter, Depends, status from app.api.dependencies import decode_bearer_token from app.api.endpoints.workflow import router as workflow_router +from app.api.endpoints.workflow_credentials import router as credentials_router from app.api.endpoints.workflow_execution import router as execution_router from app.api.endpoints.workflow_version import router as version_router from app.schemas.security import ErrorDetail @@ -29,6 +30,9 @@ alternative_responses: Dict[Union[int, str], Dict[str, Any]] = { api_router = APIRouter() workflow_router.include_router(version_router) api_router.include_router(workflow_router, responses=alternative_responses, dependencies=[Depends(decode_bearer_token)]) +api_router.include_router( + credentials_router, responses=alternative_responses, dependencies=[Depends(decode_bearer_token)] +) api_router.include_router( execution_router, responses=alternative_responses, dependencies=[Depends(decode_bearer_token)] ) diff --git a/app/api/endpoints/workflow.py b/app/api/endpoints/workflow.py index 5d6c265..e5dfc35 100644 --- a/app/api/endpoints/workflow.py +++ b/app/api/endpoints/workflow.py @@ -10,7 +10,8 @@ from app.core.config import settings from app.crud import CRUDWorkflow, CRUDWorkflowVersion from app.git_repository import GitHubRepository, build_repository from app.schemas.workflow import WorkflowIn, WorkflowOut, WorkflowStatistic -from app.schemas.workflow_version import WorkflowVersionFull, WorkflowVersionUpdate +from app.schemas.workflow_version import WorkflowVersion as WorkflowVersionSchema +from app.schemas.workflow_version import WorkflowVersionUpdate from app.scm import SCM, Provider router = APIRouter(prefix="/workflows", tags=["Workflow"]) @@ -52,7 +53,7 @@ async def list_workflows( Filter workflows by a developer. Query Parameter. name_substring : string | None, default None Filter workflows by a substring in their name. Query Parameter. - version_status : List[clowmdb.models.WorkflowVersionReduced.Status] | None, default None + version_status : List[clowmdb.models.WorkflowVersion.Status] | None, default None Status of Workflow versions to filter for to fetch. Query Parameter. authorization : Callable[[str], Awaitable[Any]] Async function to ask the auth service for authorization. Dependency Injection. @@ -257,7 +258,7 @@ async def get_workflow( ---------- workflow : clowmdb.models.Workflow Workflow with given ID. Dependency Injection. - version_status : List[clowmdb.models.WorkflowVersionReduced.Status] | None, default None + version_status : List[clowmdb.models.WorkflowVersion.Status] | None, default None Status of Workflow versions to filter for to fetch. Query Parameter db : sqlalchemy.ext.asyncio.AsyncSession. Async database session to perform query on. Dependency Injection. @@ -386,7 +387,7 @@ async def update_workflow( min_length=40, max_length=40, ), -) -> WorkflowVersionFull: +) -> WorkflowVersionSchema: """ Create a new workflow version.\n Permission "workflow:update" required. @@ -416,14 +417,14 @@ async def update_workflow( Returns ------- - version : app.schemas.workflow_version.WorkflowVersionFull + version : app.schemas.workflow_version.WorkflowVersion The new workflow version """ await authorization("update") - version_update = WorkflowVersionUpdate(version=version, git_commit_hash=git_commit_hash) if current_user.uid != workflow.developer_id: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only the developer can update his workflow") + version_update = WorkflowVersionUpdate(version=version, git_commit_hash=git_commit_hash) # Check if git commit is already used if await CRUDWorkflowVersion.get(db, version_update.git_commit_hash) is not None: raise HTTPException( @@ -457,4 +458,4 @@ async def update_workflow( icon_slug=icon_slug, previous_version=previous_version.git_commit_hash if previous_version else None, ) - return WorkflowVersionFull.from_db_version_with_repo(version, repo) + return WorkflowVersionSchema.from_db_version(version) diff --git a/app/api/endpoints/workflow_credentials.py b/app/api/endpoints/workflow_credentials.py new file mode 100644 index 0000000..c4c62cb --- /dev/null +++ b/app/api/endpoints/workflow_credentials.py @@ -0,0 +1,122 @@ +from typing import Annotated, Any, Awaitable, Callable + +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status + +from app.api.dependencies import AuthorizationDependency, CurrentUser, CurrentWorkflow, DBSession, HTTPClient, S3Service +from app.api.utils import check_repo, upload_scm_file +from app.core.config import settings +from app.crud.crud_workflow import CRUDWorkflow +from app.crud.crud_workflow_version import CRUDWorkflowVersion +from app.git_repository import GitHubRepository, build_repository +from app.schemas.workflow import WorkflowCredentialsIn +from app.scm import SCM, Provider + +router = APIRouter(prefix="/workflows/{wid}/credentials", tags=["Workflow Credentials"]) +workflow_authorization = AuthorizationDependency(resource="workflow") + +Authorization = Annotated[Callable[[str], Awaitable[Any]], Depends(workflow_authorization)] + + +@router.delete("", status_code=status.HTTP_204_NO_CONTENT, summary="Delete the credentials of a workflow") +async def delete_workflow_credentials( + workflow: CurrentWorkflow, + db: DBSession, + current_user: CurrentUser, + authorization: Authorization, + s3: S3Service, +) -> None: + """ + Delete the credentials for the repository of a workflow.\n + Permission "workflow:delete" required. + \f + Parameters + ---------- + workflow : clowmdb.models.Workflow + Workflow with given ID. Dependency Injection. + db : sqlalchemy.ext.asyncio.AsyncSession. + Async database session to perform query on. Dependency Injection. + current_user : clowmdb.models.User + Current user. Dependency Injection. + authorization : Callable[[str], Awaitable[Any]] + Async function to ask the auth service for authorization. Dependency Injection. + s3 : boto3_type_annotations.s3.ServiceResource + S3 Service to perform operations on buckets in Ceph. Dependency Injection. + + Returns + ------- + workflow : app.schemas.workflow.WorkflowOut + Workflow with existing ID + """ + rbac_operation = "delete" if workflow.developer_id == current_user.uid else "delete_any" + await authorization(rbac_operation) + s3.Bucket(settings.PARAMS_BUCKET).Object(f"{workflow.workflow_id.hex}.scm").delete() + await CRUDWorkflow.update_credentials(db, workflow.workflow_id, token=None, username=None) + + +@router.put("", status_code=status.HTTP_200_OK, summary="Delete the credentials of a workflow") +async def update_workflow_credentials( + credentials: WorkflowCredentialsIn, + workflow: CurrentWorkflow, + db: DBSession, + current_user: CurrentUser, + authorization: Authorization, + s3: S3Service, + background_tasks: BackgroundTasks, + client: HTTPClient, +) -> None: + """ + Update the credentials for the repository of a workflow.\n + Permission "workflow:update" required. + \f + Parameters + ---------- + credentials : app.schemas.workflow.WorkflowCredentialsIn + Updated credentials for the workflow git repository + workflow : clowmdb.models.Workflow + Workflow with given ID. Dependency Injection. + db : sqlalchemy.ext.asyncio.AsyncSession. + Async database session to perform query on. Dependency Injection. + current_user : clowmdb.models.User + Current user. Dependency Injection. + authorization : Callable[[str], Awaitable[Any]] + Async function to ask the auth service for authorization. Dependency Injection. + s3 : boto3_type_annotations.s3.ServiceResource + S3 Service to perform operations on buckets in Ceph. Dependency Injection. + client : httpx.AsyncClient + Http client with an open connection. Dependency Injection. + background_tasks : fastapi.BackgroundTasks + Entrypoint for new BackgroundTasks. Provided by FastAPI. + + Returns + ------- + workflow : app.schemas.workflow.WorkflowOut + Workflow with existing ID + """ + await authorization("update") + if current_user.uid != workflow.developer_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="Only the developer can update the repository credentials" + ) + latest_version = await CRUDWorkflowVersion.get_latest(db, workflow.workflow_id, published=False) + # Build a git repository object based on the repository url + repo = build_repository( + workflow.repository_url, + latest_version.git_commit_hash, # type: ignore[union-attr] + token=credentials.token, + username=credentials.username, + ) + # if the workflow is in a private GitHub repo and the username is None, then take the username from the url + if credentials.username is None and isinstance(repo, GitHubRepository): + credentials.username = repo.user + + await check_repo(repo=repo, client=client) + scm_provider = Provider.from_repo(repo=repo, name=f"repo{workflow.workflow_id.hex}") + background_tasks.add_task( + upload_scm_file, + s3=s3, + scm=SCM(providers=[scm_provider]), # type: ignore[list-item] + scm_file_id=workflow.workflow_id.hex, + ) + await CRUDWorkflow.update_credentials( + db, workflow.workflow_id, token=credentials.token, username=credentials.username + ) diff --git a/app/api/endpoints/workflow_version.py b/app/api/endpoints/workflow_version.py index 4f5b531..cd2b188 100644 --- a/app/api/endpoints/workflow_version.py +++ b/app/api/endpoints/workflow_version.py @@ -5,8 +5,8 @@ from fastapi import APIRouter, Depends, HTTPException, Path, Query, status from app.api.dependencies import AuthorizationDependency, CurrentUser, CurrentWorkflow, DBSession from app.crud import CRUDWorkflowVersion -from app.git_repository import build_repository -from app.schemas.workflow_version import WorkflowVersionFull, WorkflowVersionStatus +from app.schemas.workflow_version import WorkflowVersion as WorkflowVersionSchema +from app.schemas.workflow_version import WorkflowVersionStatus router = APIRouter(prefix="/{wid}/versions", tags=["WorkflowVersion"]) workflow_authorization = AuthorizationDependency(resource="workflow") @@ -33,7 +33,7 @@ async def list_workflow_version( None, description=f"Which versions of the workflow to include in the response. Permission 'workflow:list_filter' required if you are not the developer of this workflow. Default {WorkflowVersion.Status.PUBLISHED.name} and {WorkflowVersion.Status.DEPRECATED.name}", # noqa: E501 ), -) -> List[WorkflowVersionFull]: +) -> List[WorkflowVersionSchema]: """ List all versions of a Workflow.\n Permission "workflow:list" required. @@ -42,7 +42,7 @@ async def list_workflow_version( ---------- workflow : clowmdb.models.Workflow Workflow with given ID. Dependency Injection. - version_status : List[clowmdb.models.WorkflowVersionReduced.Status] | None, default None + version_status : List[clowmdb.models.WorkflowVersion.Status] | None, default None Status of Workflow versions to filter for to fetch. Query Parameter db : sqlalchemy.ext.asyncio.AsyncSession. Async database session to perform query on. Dependency Injection. @@ -53,7 +53,7 @@ async def list_workflow_version( Returns ------- - versions : [app.schemas.workflow_version.WorkflowVersionFull] + versions : [app.schemas.workflow_version.WorkflowVersion] All versions of the workflow """ rbac_operation = ( @@ -68,10 +68,7 @@ async def list_workflow_version( if version_status is not None else [WorkflowVersion.Status.PUBLISHED, WorkflowVersion.Status.DEPRECATED], ) - return [ - WorkflowVersionFull.from_db_version_with_repo(v, build_repository(workflow.repository_url, v.git_commit_hash)) - for v in versions - ] + return [WorkflowVersionSchema.from_db_version(v) for v in versions] @router.get( @@ -90,7 +87,7 @@ async def get_workflow_version( regex=r"^([0-9a-f]{40}|latest)$", examples=["latest", "ba8bcd9294c2c96aedefa1763a84a18077c50c0f"], ), -) -> WorkflowVersionFull: +) -> WorkflowVersionSchema: """ Get a specific version of a workflow.\n Permission "workflow:read" required if the version is public or you are the developer of the workflow, @@ -111,7 +108,7 @@ async def get_workflow_version( Returns ------- - version : app.schemas.workflow_version.WorkflowVersionFull + version : app.schemas.workflow_version.WorkflowVersion The specified WorkflowVersion """ rbac_operation = "read" @@ -133,9 +130,7 @@ async def get_workflow_version( ): rbac_operation = "read_any" await authorization(rbac_operation) - return WorkflowVersionFull.from_db_version_with_repo( - version, repo=build_repository(workflow.repository_url, version.git_commit_hash) - ) + return WorkflowVersionSchema.from_db_version(version) @router.patch("/{git_commit_hash}/status", status_code=status.HTTP_200_OK, summary="Update status of workflow version") @@ -145,7 +140,7 @@ async def update_workflow_version_status( git_commit_hash: GitCommitHash, db: DBSession, authorization: Authorization, -) -> WorkflowVersionFull: +) -> WorkflowVersionSchema: """ Update the status of a workflow version.\n Permission "workflow:update_status" @@ -177,9 +172,7 @@ async def update_workflow_version_status( ) await CRUDWorkflowVersion.update_status(db, git_commit_hash, version_status.status) version.status = version_status.status - return WorkflowVersionFull.from_db_version_with_repo( - version, repo=build_repository(workflow.repository_url, version.git_commit_hash) - ) + return WorkflowVersionSchema.from_db_version(version) @router.post("/{git_commit_hash}/deprecate", status_code=status.HTTP_200_OK, summary="Deprecate a workflow version") @@ -189,7 +182,7 @@ async def deprecate_workflow_version( db: DBSession, authorization: Authorization, current_user: CurrentUser, -) -> WorkflowVersionFull: +) -> WorkflowVersionSchema: """ Deprecate a workflow version.\n Permission "workflow:update" required if you are the developer of the workflow, @@ -222,6 +215,4 @@ async def deprecate_workflow_version( ) await CRUDWorkflowVersion.update_status(db, git_commit_hash, WorkflowVersion.Status.DEPRECATED) version.status = WorkflowVersion.Status.DEPRECATED - return WorkflowVersionFull.from_db_version_with_repo( - version, repo=build_repository(workflow.repository_url, version.git_commit_hash) - ) + return WorkflowVersionSchema.from_db_version(version) diff --git a/app/crud/crud_workflow.py b/app/crud/crud_workflow.py index 25b8011..af8fa55 100644 --- a/app/crud/crud_workflow.py +++ b/app/crud/crud_workflow.py @@ -2,7 +2,7 @@ from typing import List, Optional, Union from uuid import UUID from clowmdb.models import Workflow, WorkflowExecution, WorkflowVersion -from sqlalchemy import Date, cast, delete, func, or_, select +from sqlalchemy import Date, cast, delete, func, or_, select, update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import joinedload @@ -67,6 +67,33 @@ class CRUDWorkflow: await db.execute(stmt) await db.commit() + @staticmethod + async def update_credentials( + db: AsyncSession, workflow_id: Union[UUID, bytes], token: Optional[str] = None, username: Optional[str] = None + ) -> None: + """ + Delete a workflow. + + Parameters + ---------- + db : sqlalchemy.ext.asyncio.AsyncSession. + Async database session to perform query on. + workflow_id : bytes | uuid.UUID + UID of a workflow + token : str | None + Token to save in the database. If None, the token in the database gets deleted + username : str | None + Username to save in the database. If None, the token in the database gets deleted + """ + wid = workflow_id.bytes if isinstance(workflow_id, UUID) else workflow_id + stmt = ( + update(Workflow) + .where(Workflow._workflow_id == wid) + .values(credentials_token=token, credentials_username=username) + ) + await db.execute(stmt) + await db.commit() + @staticmethod async def statistics(db: AsyncSession, workflow_id: Union[bytes, UUID]) -> List[WorkflowStatistic]: """ diff --git a/app/crud/crud_workflow_version.py b/app/crud/crud_workflow_version.py index 0269725..b29797b 100644 --- a/app/crud/crud_workflow_version.py +++ b/app/crud/crud_workflow_version.py @@ -129,7 +129,7 @@ class CRUDWorkflowVersion: Returns ------- workflow_version : clowmdb.models.WorkflowVersion - Newly create WorkflowVersionReduced + Newly create WorkflowVersion """ workflow_version = WorkflowVersion( git_commit_hash=git_commit_hash, diff --git a/app/schemas/workflow.py b/app/schemas/workflow.py index 9b57760..40d488b 100644 --- a/app/schemas/workflow.py +++ b/app/schemas/workflow.py @@ -6,7 +6,7 @@ from clowmdb.models import Workflow as WorkflowDB from clowmdb.models import WorkflowVersion as WorkflowVersionDB from pydantic import AnyHttpUrl, BaseModel, Field, FieldSerializationInfo, field_serializer -from app.schemas.workflow_version import WorkflowVersionReduced +from app.schemas.workflow_version import WorkflowVersion class _BaseWorkflow(BaseModel): @@ -67,7 +67,7 @@ class WorkflowIn(_BaseWorkflow): class WorkflowOut(_BaseWorkflow): workflow_id: UUID = Field(..., description="Id of the workflow", examples=["0cc78936-381b-4bdd-999d-736c40591078"]) - versions: List[WorkflowVersionReduced] = Field(..., description="Versions of the workflow") + versions: List[WorkflowVersion] = Field(..., description="Versions of the workflow") developer_id: str = Field( ..., description="Id of developer of the workflow", examples=["28c5353b8bb34984a8bd4169ba94c606"] ) @@ -75,11 +75,11 @@ class WorkflowOut(_BaseWorkflow): @staticmethod def from_db_workflow( - db_workflow: WorkflowDB, versions: Sequence[Union[WorkflowVersionReduced, WorkflowVersionDB]] + db_workflow: WorkflowDB, versions: Sequence[Union[WorkflowVersion, WorkflowVersionDB]] ) -> "WorkflowOut": temp_versions = versions if len(versions) > 0 and isinstance(versions[0], WorkflowVersionDB): - temp_versions = [WorkflowVersionReduced.from_db_version(v) for v in versions] + temp_versions = [WorkflowVersion.from_db_version(v) for v in versions] return WorkflowOut( workflow_id=db_workflow.workflow_id, name=db_workflow.name, @@ -94,3 +94,18 @@ class WorkflowOut(_BaseWorkflow): class WorkflowStatistic(BaseModel): day: date = Field(..., description="Day of the datapoint", examples=[date(day=1, month=1, year=2023)]) count: int = Field(..., description="Number of started workflows on that day", examples=[1]) + + +class WorkflowCredentialsIn(BaseModel): + token: str = Field( + ..., + description="Token to access the content git repository", + examples=["vnpau89avpa48iunga984gh9h89pvhj"], + max_length=128, + ) + username: Optional[str] = Field( + default=None, + description="Username belonging to the token. If not provided, it will be parsed from the git repository URL", + examples=["bilbobaggins"], + max_length=128, + ) diff --git a/app/schemas/workflow_version.py b/app/schemas/workflow_version.py index ce0dda7..b86b86d 100644 --- a/app/schemas/workflow_version.py +++ b/app/schemas/workflow_version.py @@ -6,7 +6,6 @@ from clowmdb.models import WorkflowVersion as WorkflowVersionDB from pydantic import AnyHttpUrl, BaseModel, Field from app.core.config import settings -from app.git_repository.abstract_repository import GitRepository class WorkflowVersionStatus(BaseModel): @@ -15,7 +14,8 @@ class WorkflowVersionStatus(BaseModel): ) -class WorkflowVersionReduced(WorkflowVersionStatus): +class WorkflowVersion(WorkflowVersionStatus): + workflow_id: UUID = Field(..., description="ID of the corresponding workflow", examples=[uuid4()]) version: str = Field( ..., description="Version of the Workflow. Should follow semantic versioning", @@ -43,11 +43,12 @@ class WorkflowVersionReduced(WorkflowVersionStatus): ) @staticmethod - def from_db_version(db_version: WorkflowVersionDB) -> "WorkflowVersionReduced": + def from_db_version(db_version: WorkflowVersionDB) -> "WorkflowVersion": icon_url = None if db_version.icon_slug is not None: icon_url = str(settings.OBJECT_GATEWAY_URI) + "/".join([settings.ICON_BUCKET, db_version.icon_slug]) - return WorkflowVersionReduced( + return WorkflowVersion( + workflow_id=db_version.workflow_id, version=db_version.version, git_commit_hash=db_version.git_commit_hash, icon_url=icon_url, @@ -56,47 +57,6 @@ class WorkflowVersionReduced(WorkflowVersionStatus): ) -class WorkflowVersionFull(WorkflowVersionReduced): - workflow_id: UUID = Field(..., description="ID of the corresponding workflow", examples=[uuid4()]) - readme_url: AnyHttpUrl = Field( - ..., - description="URL to download README.md from", - examples=["https://raw.githubusercontent.com/example/example/README.md"], - ) - changelog_url: AnyHttpUrl = Field( - ..., - description="URL to download CHANGELOG.md from", - examples=["https://raw.githubusercontent.com/example/example/CHANGELOG.md"], - ) - usage_url: AnyHttpUrl = Field( - ..., - description="URL to download usage.md from", - examples=["https://raw.githubusercontent.com/example/example/docs/usage.md"], - ) - output_url: AnyHttpUrl = Field( - ..., - description="URL to download output.md from", - examples=["https://raw.githubusercontent.com/example/example/docs/output.md"], - ) - parameter_schema_url: AnyHttpUrl = Field( - ..., - description="URL to download nextflow_schema.json from", - examples=["https://raw.githubusercontent.com/example/example/nextflow_schema.json"], - ) - - @staticmethod - def from_db_version_with_repo(db_version: WorkflowVersionDB, repo: GitRepository) -> "WorkflowVersionFull": - return WorkflowVersionFull( - workflow_id=db_version.workflow_id, - readme_url=AnyHttpUrl("https://example.org/README.md"), - changelog_url=AnyHttpUrl("https://example.org/CHANGELOG.md"), - usage_url=AnyHttpUrl("https://example.org/docs/usage.md"), - output_url=AnyHttpUrl("https://example.org/docs/output.md"), - parameter_schema_url=AnyHttpUrl("https://example.org/nextflow_schema.json"), - **WorkflowVersionReduced.from_db_version(db_version).model_dump(), - ) - - class WorkflowVersionUpdate(BaseModel): version: str = Field( ..., diff --git a/app/tests/api/test_workflow_credentials.py b/app/tests/api/test_workflow_credentials.py new file mode 100644 index 0000000..e9e0013 --- /dev/null +++ b/app/tests/api/test_workflow_credentials.py @@ -0,0 +1,292 @@ +from io import BytesIO + +import pytest +from botocore.client import ClientError +from clowmdb.models import Workflow +from fastapi import status +from httpx import AsyncClient +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import settings +from app.schemas.workflow import WorkflowCredentialsIn, WorkflowOut +from app.scm import SCM +from app.tests.mocks.mock_s3_resource import MockS3ServiceResource +from app.tests.utils.user import UserWithAuthHeader +from app.tests.utils.utils import random_lower_string + + +class _TestWorkflowCredentialRoutes: + base_path: str = "/workflows" + + +class TestWorkflowCredentialsRoutesUpdate(_TestWorkflowCredentialRoutes): + @pytest.mark.asyncio + async def test_update_workflow_credentials_on_public_workflow( + self, + db: AsyncSession, + client: AsyncClient, + random_user: UserWithAuthHeader, + random_workflow: WorkflowOut, + mock_s3_service: MockS3ServiceResource, + ) -> None: + """ + Test for updating the credentials on a workflow formerly hosted in a public git repository. + + Parameters + ---------- + db : sqlalchemy.ext.asyncio.AsyncSession. + Async database session to perform query on. pytest fixture. + client : httpx.AsyncClient + HTTP Client to perform the request on. pytest fixture. + random_user : app.tests.utils.user.UserWithAuthHeader + Random user for testing. pytest fixture. + mock_s3_service : app.tests.mocks.mock_s3_resource.MockS3ServiceResource + Mock S3 Service to manipulate objects. pytest fixture. + random_workflow : app.schemas.workflow.WorkflowOut + Random workflow for testing. pytest fixture. + """ + credentials = WorkflowCredentialsIn(token=random_lower_string(15), username=random_lower_string(15)) + response = await client.put( + "/".join([self.base_path, str(random_workflow.workflow_id), "credentials"]), + json=credentials.model_dump(), + headers=random_user.auth_headers, + ) + assert response.status_code == status.HTTP_200_OK + + stmt = select(Workflow).where(Workflow._workflow_id == random_workflow.workflow_id.bytes) + db_workflow = (await db.execute(stmt)).scalar() + assert db_workflow is not None + + assert db_workflow.credentials_token == credentials.token + assert db_workflow.credentials_username == credentials.username + + scm_file = mock_s3_service.Bucket(settings.PARAMS_BUCKET).Object(f"{db_workflow.workflow_id.hex}.scm") + with BytesIO() as f: + scm_file.download_fileobj(f) + f.seek(0) + scm = SCM.deserialize(f) + + assert len(scm.providers) == 1 + assert scm.providers[0].password == credentials.token + + # Clean up after test + scm_file.delete() + + @pytest.mark.asyncio + async def test_update_workflow_credentials_on_private_workflow( + self, + db: AsyncSession, + client: AsyncClient, + random_user: UserWithAuthHeader, + random_private_workflow: WorkflowOut, + mock_s3_service: MockS3ServiceResource, + ) -> None: + """ + Test for updating the credentials on a workflow hosted in a private git repository. + + Parameters + ---------- + db : sqlalchemy.ext.asyncio.AsyncSession. + Async database session to perform query on. pytest fixture. + client : httpx.AsyncClient + HTTP Client to perform the request on. pytest fixture. + random_user : app.tests.utils.user.UserWithAuthHeader + Random second user for testing. pytest fixture. + mock_s3_service : app.tests.mocks.mock_s3_resource.MockS3ServiceResource + Mock S3 Service to manipulate objects. pytest fixture. + random_private_workflow : app.schemas.workflow.WorkflowOut + Random private workflow for testing. pytest fixture. + """ + credentials = WorkflowCredentialsIn(token=random_lower_string(15), username=random_lower_string(15)) + response = await client.put( + "/".join([self.base_path, str(random_private_workflow.workflow_id), "credentials"]), + json=credentials.model_dump(), + headers=random_user.auth_headers, + ) + assert response.status_code == status.HTTP_200_OK + + stmt = select(Workflow).where(Workflow._workflow_id == random_private_workflow.workflow_id.bytes) + db_workflow = (await db.execute(stmt)).scalar() + assert db_workflow is not None + + assert db_workflow.credentials_token == credentials.token + assert db_workflow.credentials_username == credentials.username + + scm_file = mock_s3_service.Bucket(settings.PARAMS_BUCKET).Object(f"{db_workflow.workflow_id.hex}.scm") + with BytesIO() as f: + scm_file.download_fileobj(f) + f.seek(0) + scm = SCM.deserialize(f) + + assert len(scm.providers) == 1 + assert scm.providers[0].password == credentials.token + + @pytest.mark.asyncio + async def test_update_workflow_credentials_without_username( + self, + db: AsyncSession, + client: AsyncClient, + random_user: UserWithAuthHeader, + random_private_workflow: WorkflowOut, + mock_s3_service: MockS3ServiceResource, + ) -> None: + """ + Test for updating the credentials on a workflow hosted in a private git repository. + + Parameters + ---------- + db : sqlalchemy.ext.asyncio.AsyncSession. + Async database session to perform query on. pytest fixture. + client : httpx.AsyncClient + HTTP Client to perform the request on. pytest fixture. + random_user : app.tests.utils.user.UserWithAuthHeader + Random user for testing. pytest fixture. + mock_s3_service : app.tests.mocks.mock_s3_resource.MockS3ServiceResource + Mock S3 Service to manipulate objects. pytest fixture. + random_private_workflow : app.schemas.workflow.WorkflowOut + Random private workflow for testing. pytest fixture. + """ + credentials = WorkflowCredentialsIn(token=random_lower_string(15)) + response = await client.put( + "/".join([self.base_path, str(random_private_workflow.workflow_id), "credentials"]), + json=credentials.model_dump(), + headers=random_user.auth_headers, + ) + assert response.status_code == status.HTTP_200_OK + + stmt = select(Workflow).where(Workflow._workflow_id == random_private_workflow.workflow_id.bytes) + db_workflow = (await db.execute(stmt)).scalar() + assert db_workflow is not None + + assert db_workflow.credentials_token == credentials.token + + scm_file = mock_s3_service.Bucket(settings.PARAMS_BUCKET).Object(f"{db_workflow.workflow_id.hex}.scm") + with BytesIO() as f: + scm_file.download_fileobj(f) + f.seek(0) + scm = SCM.deserialize(f) + + assert len(scm.providers) == 1 + assert scm.providers[0].password == credentials.token + + @pytest.mark.asyncio + async def test_update_workflow_credentials_on_foreign_workflow( + self, + db: AsyncSession, + client: AsyncClient, + random_second_user: UserWithAuthHeader, + random_private_workflow: WorkflowOut, + mock_s3_service: MockS3ServiceResource, + ) -> None: + """ + Test for updating the credentials on a workflow hosted in a private git repository. + + Parameters + ---------- + db : sqlalchemy.ext.asyncio.AsyncSession. + Async database session to perform query on. pytest fixture. + client : httpx.AsyncClient + HTTP Client to perform the request on. pytest fixture. + random_second_user : app.tests.utils.user.UserWithAuthHeader + Random user for testing. pytest fixture. + mock_s3_service : app.tests.mocks.mock_s3_resource.MockS3ServiceResource + Mock S3 Service to manipulate objects. pytest fixture. + random_private_workflow : app.schemas.workflow.WorkflowOut + Random private workflow for testing. pytest fixture. + """ + credentials = WorkflowCredentialsIn(token=random_lower_string(15), username=random_lower_string(15)) + response = await client.put( + "/".join([self.base_path, str(random_private_workflow.workflow_id), "credentials"]), + json=credentials.model_dump(), + headers=random_second_user.auth_headers, + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + + +class TestWorkflowCredentialsRoutesDelete(_TestWorkflowCredentialRoutes): + @pytest.mark.asyncio + async def test_delete_workflow_credentials_on_public_workflow( + self, + db: AsyncSession, + client: AsyncClient, + random_user: UserWithAuthHeader, + random_workflow: WorkflowOut, + mock_s3_service: MockS3ServiceResource, + ) -> None: + """ + Test for updating the credentials on a workflow formerly hosted in a public git repository. + + Parameters + ---------- + db : sqlalchemy.ext.asyncio.AsyncSession. + Async database session to perform query on. pytest fixture. + client : httpx.AsyncClient + HTTP Client to perform the request on. pytest fixture. + random_user : app.tests.utils.user.UserWithAuthHeader + Random user for testing. pytest fixture. + mock_s3_service : app.tests.mocks.mock_s3_resource.MockS3ServiceResource + Mock S3 Service to manipulate objects. pytest fixture. + random_workflow : app.schemas.workflow.WorkflowOut + Random workflow for testing. pytest fixture. + """ + response = await client.delete( + "/".join([self.base_path, str(random_workflow.workflow_id), "credentials"]), + headers=random_user.auth_headers, + ) + assert response.status_code == status.HTTP_204_NO_CONTENT + + stmt = select(Workflow).where(Workflow._workflow_id == random_workflow.workflow_id.bytes) + db_workflow = (await db.execute(stmt)).scalar() + assert db_workflow is not None + + assert db_workflow.credentials_token is None + assert db_workflow.credentials_username is None + + scm_file = mock_s3_service.Bucket(settings.PARAMS_BUCKET).Object(f"{db_workflow.workflow_id.hex}.scm") + with pytest.raises(ClientError): + with BytesIO() as f: + scm_file.download_fileobj(f) + + @pytest.mark.asyncio + async def test_delete_workflow_credentials_on_private_workflow( + self, + db: AsyncSession, + client: AsyncClient, + random_user: UserWithAuthHeader, + random_private_workflow: WorkflowOut, + mock_s3_service: MockS3ServiceResource, + ) -> None: + """ + Test for updating the credentials on a workflow formerly hosted in a public git repository. + + Parameters + ---------- + db : sqlalchemy.ext.asyncio.AsyncSession. + Async database session to perform query on. pytest fixture. + client : httpx.AsyncClient + HTTP Client to perform the request on. pytest fixture. + random_user : app.tests.utils.user.UserWithAuthHeader + Random user for testing. pytest fixture. + mock_s3_service : app.tests.mocks.mock_s3_resource.MockS3ServiceResource + Mock S3 Service to manipulate objects. pytest fixture. + random_private_workflow : app.schemas.workflow.WorkflowOut + Random private workflow for testing. pytest fixture. + """ + response = await client.delete( + "/".join([self.base_path, str(random_private_workflow.workflow_id), "credentials"]), + headers=random_user.auth_headers, + ) + assert response.status_code == status.HTTP_204_NO_CONTENT + + stmt = select(Workflow).where(Workflow._workflow_id == random_private_workflow.workflow_id.bytes) + db_workflow = (await db.execute(stmt)).scalar() + assert db_workflow is not None + + assert db_workflow.credentials_token is None + assert db_workflow.credentials_username is None + + scm_file = mock_s3_service.Bucket(settings.PARAMS_BUCKET).Object(f"{db_workflow.workflow_id.hex}.scm") + with pytest.raises(ClientError): + with BytesIO() as f: + scm_file.download_fileobj(f) diff --git a/app/tests/api/test_workflow_execution.py b/app/tests/api/test_workflow_execution.py index 2b5bf01..aea05f1 100644 --- a/app/tests/api/test_workflow_execution.py +++ b/app/tests/api/test_workflow_execution.py @@ -9,6 +9,7 @@ from sqlalchemy import update from sqlalchemy.ext.asyncio import AsyncSession from app.core.config import settings +from app.schemas.workflow import WorkflowOut from app.schemas.workflow_execution import DevWorkflowExecutionIn, WorkflowExecutionIn from app.scm import SCM from app.tests.mocks.mock_s3_resource import MockS3ServiceResource @@ -64,6 +65,7 @@ class TestWorkflowExecutionRoutesCreate(_TestWorkflowExecutionRoutes): client: AsyncClient, random_user: UserWithAuthHeader, random_workflow_version: WorkflowVersion, + random_private_workflow: WorkflowOut, mock_s3_service: MockS3ServiceResource, ) -> None: """ @@ -77,14 +79,11 @@ class TestWorkflowExecutionRoutesCreate(_TestWorkflowExecutionRoutes): Random user for testing. pytest fixture. random_workflow_version : clowmdb.models.WorkflowVersion Random workflow version for testing. pytest fixture. + random_private_workflow : app.schemas.workflow.WorkflowOut + Random private workflow for testing. pytest fixture. mock_s3_service : app.tests.mocks.mock_s3_resource.MockS3ServiceResource Mock S3 Service to manipulate objects. pytest fixture. """ - # Create an SCM file in S3 to mark this workflow as a private repository - scm_file = mock_s3_service.Bucket(settings.PARAMS_BUCKET).Object( - f"{random_workflow_version.workflow_id.hex}.scm" - ) - scm_file.upload_fileobj(BytesIO(b"")) execution_in = WorkflowExecutionIn(workflow_version_id=random_workflow_version.git_commit_hash, parameters={}) response = await client.post(self.base_path, headers=random_user.auth_headers, json=execution_in.model_dump()) @@ -99,8 +98,6 @@ class TestWorkflowExecutionRoutesCreate(_TestWorkflowExecutionRoutes): f"params-{UUID(hex=execution_response['execution_id']).hex }.json" in mock_s3_service.Bucket(settings.PARAMS_BUCKET).objects.all_keys() ) - # Clean up after tests - scm_file.delete() @pytest.mark.asyncio async def test_start_gitlab_workflow_execution( @@ -205,7 +202,7 @@ class TestWorkflowExecutionRoutesCreate(_TestWorkflowExecutionRoutes): assert response.status_code == status.HTTP_404_NOT_FOUND @pytest.mark.asyncio - async def test_start_workflow_execution_with_unaccessable_workflow_version( + async def test_start_workflow_execution_with_deprecated_workflow_version( self, client: AsyncClient, db: AsyncSession, diff --git a/app/tests/conftest.py b/app/tests/conftest.py index d88b3ea..9a5f51c 100644 --- a/app/tests/conftest.py +++ b/app/tests/conftest.py @@ -9,13 +9,15 @@ import pytest import pytest_asyncio from clowmdb.db.session import get_async_session from clowmdb.models import Bucket, Workflow, WorkflowExecution, WorkflowVersion -from sqlalchemy import select +from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession from app.api.dependencies import get_decode_jwt_function, get_httpx_client, get_s3_resource from app.core.config import settings +from app.git_repository import build_repository from app.main import app from app.schemas.workflow import WorkflowOut +from app.scm import SCM, Provider from app.tests.mocks.mock_s3_resource import MockS3ServiceResource from app.tests.utils.bucket import create_random_bucket from app.tests.utils.user import UserWithAuthHeader, create_random_user, decode_mock_token, get_authorization_headers @@ -147,7 +149,7 @@ async def random_workflow( """ workflow_db = Workflow( name=random_lower_string(10), - repository_url="https://github.de/example/example", + repository_url="https://github.de/example-user/example", short_description=random_lower_string(65), developer_id=random_user.user.uid, ) @@ -171,6 +173,45 @@ async def random_workflow( await db.commit() +@pytest_asyncio.fixture(scope="function") +async def random_private_workflow( + db: AsyncSession, + random_workflow: WorkflowOut, + random_workflow_version: WorkflowVersion, + mock_s3_service: MockS3ServiceResource, +) -> AsyncIterator[WorkflowOut]: + """ + Transform the random workflow into a private workflow. + """ + # Update credentials in Database + token = random_lower_string(15) + username = random_lower_string(15) + await db.execute( + update(Workflow) + .where(Workflow._workflow_id == random_workflow.workflow_id.bytes) + .values(credentials_token=token, credentials_username=username) + ) + await db.commit() + + # Upload SCM file to PARAMS_BUCKET + scm_provider = Provider.from_repo( + build_repository( + random_workflow.repository_url, random_workflow_version.git_commit_hash, token=token, username=username + ), + name=f"repo{random_workflow.workflow_id.hex}", + ) + assert scm_provider is not None + with BytesIO() as f: + SCM([scm_provider]).serialize(f) + f.seek(0) + mock_s3_service.Bucket(settings.PARAMS_BUCKET).Object(f"{random_workflow.workflow_id.hex}.scm").upload_fileobj( + f + ) + yield random_workflow + # Delete SCM in PARAMS_BUCKET after tests + mock_s3_service.Bucket(settings.PARAMS_BUCKET).Object(f"{random_workflow.workflow_id.hex}.scm").delete() + + @pytest_asyncio.fixture(scope="function") async def random_workflow_version(db: AsyncSession, random_workflow: WorkflowOut) -> WorkflowVersion: """ diff --git a/app/tests/crud/test_workflow.py b/app/tests/crud/test_workflow.py index aa7680f..95d1655 100644 --- a/app/tests/crud/test_workflow.py +++ b/app/tests/crud/test_workflow.py @@ -185,6 +185,30 @@ class TestWorkflowCRUDCreate: await db.execute(delete(Workflow).where(Workflow._workflow_id == workflow.workflow_id.bytes)) await db.commit() + @pytest.mark.asyncio + async def test_create_workflow_credentials(self, db: AsyncSession, random_workflow: WorkflowOut) -> None: + """ + Test for updating the workflow credentials in CRUD Repository. + + Parameters + ---------- + db : sqlalchemy.ext.asyncio.AsyncSession. + Async database session to perform query on. pytest fixture. + random_workflow : app.schemas.workflow.WorkflowOut + Random workflow for testing. pytest fixture. + """ + token = random_lower_string(15) + username = random_lower_string(15) + await CRUDWorkflow.update_credentials( + db, workflow_id=random_workflow.workflow_id, token=token, username=username + ) + + stmt = select(Workflow).where(Workflow._workflow_id == random_workflow.workflow_id.bytes) + workflow = (await db.execute(stmt)).scalar() + assert workflow is not None + assert workflow.credentials_token == token + assert workflow.credentials_username == username + class TestWorkflowCRUDDelete: @pytest.mark.asyncio @@ -197,7 +221,7 @@ class TestWorkflowCRUDDelete: db : sqlalchemy.ext.asyncio.AsyncSession. Async database session to perform query on. pytest fixture. random_workflow : app.schemas.workflow.WorkflowOut - Random bucket for testing. pytest fixture. + Random workflow for testing. pytest fixture. """ await CRUDWorkflow.delete(db, workflow_id=random_workflow.workflow_id) @@ -210,3 +234,25 @@ class TestWorkflowCRUDDelete: versions = (await db.execute(stmt)).scalars().all() assert len(versions) == 0 + + @pytest.mark.asyncio + async def test_delete_workflow_credentials(self, db: AsyncSession, random_private_workflow: WorkflowOut) -> None: + """ + Test for deleting the workflow credentials in CRUD Repository. + + Parameters + ---------- + db : sqlalchemy.ext.asyncio.AsyncSession. + Async database session to perform query on. pytest fixture. + random_private_workflow : app.schemas.workflow.WorkflowOut + Random private workflow for testing. pytest fixture. + """ + await CRUDWorkflow.update_credentials( + db, workflow_id=random_private_workflow.workflow_id, token=None, username=None + ) + + stmt = select(Workflow).where(Workflow._workflow_id == random_private_workflow.workflow_id.bytes) + workflow = (await db.execute(stmt)).scalar() + assert workflow is not None + assert workflow.credentials_token is None + assert workflow.credentials_username is None -- GitLab