diff --git a/AUTHORS.rst b/AUTHORS.rst index 5a9dfb89..5309b206 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -7,6 +7,7 @@ The list of contributors in alphabetical order: - `Audrius Mecionis `_ - `Anton Khodak `_ - `Camila Diaz `_ +- `Daan Rosendal `_ - `Diego Rodriguez `_ - `Dinos Kousidis `_ - `Harri Hirvonsalo `_ diff --git a/docs/openapi.json b/docs/openapi.json index ef6c28d3..db22f201 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -89,6 +89,27 @@ "name": "workflow_id_or_name", "required": false, "type": "string" + }, + { + "description": "Optional flag to list all shared (owned and unowned) workflows.", + "in": "query", + "name": "shared", + "required": false, + "type": "boolean" + }, + { + "description": "Optional argument to list workflows shared by the specified user(s).", + "in": "query", + "name": "shared_by", + "required": false, + "type": "string" + }, + { + "description": "Optional argument to list workflows shared with the specified user(s).", + "in": "query", + "name": "shared_with", + "required": false, + "type": "string" } ], "produces": [ @@ -167,9 +188,15 @@ "name": { "type": "string" }, + "owner_email": { + "type": "string" + }, "progress": { "type": "object" }, + "shared_with": { + "type": "string" + }, "size": { "properties": { "human_readable": { @@ -993,6 +1020,330 @@ "summary": "Get the retention rules of a workflow." } }, + "/api/workflows/{workflow_id_or_name}/share": { + "post": { + "description": "This resource allows to share a workflow with other users.", + "operationId": "share_workflow", + "parameters": [ + { + "description": "Required. UUID of workflow owner.", + "in": "query", + "name": "user_id", + "required": true, + "type": "string" + }, + { + "description": "Required. Analysis UUID or name.", + "in": "path", + "name": "workflow_id_or_name", + "required": true, + "type": "string" + }, + { + "description": "Required. User to share the workflow with.", + "in": "query", + "name": "user_email_to_share_with", + "required": true, + "type": "string" + }, + { + "description": "Optional. Message to include when sharing the workflow.", + "in": "query", + "name": "message", + "required": false, + "type": "string" + }, + { + "description": "Optional. Date when access to the workflow will expire (format YYYY-MM-DD).", + "in": "query", + "name": "valid_until", + "required": false, + "type": "string" + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "Request succeeded. The workflow has been shared with the user.", + "examples": { + "application/json": { + "message": "The workflow has been shared with the user.", + "workflow_id": "cdcf48b1-c2f3-4693-8230-b066e088c6ac", + "workflow_name": "mytest.1" + } + }, + "schema": { + "properties": { + "message": { + "type": "string" + }, + "workflow_id": { + "type": "string" + }, + "workflow_name": { + "type": "string" + } + }, + "type": "object" + } + }, + "400": { + "description": "Request failed. The incoming data specification seems malformed.", + "examples": { + "application/json": { + "errors": [ + "Missing data for required field." + ], + "message": "Malformed request." + } + }, + "schema": { + "properties": { + "errors": { + "items": { + "type": "string" + }, + "type": "array" + }, + "message": { + "type": "string" + } + }, + "type": "object" + } + }, + "403": { + "description": "Request failed. User is not allowed to share the workflow.", + "examples": { + "application/json": { + "errors": [ + "User is not allowed to share the workflow." + ] + } + }, + "schema": { + "properties": { + "message": { + "type": "string" + } + }, + "type": "object" + } + }, + "404": { + "description": "Request failed. Workflow does not exist or user does not exist.", + "examples": { + "application/json": { + "errors": [ + "Workflow cdcf48b1-c2f3-4693-8230-b066e088c6ac does not exist" + ], + "message": "Workflow cdcf48b1-c2f3-4693-8230-b066e088c6ac does not exist" + } + }, + "schema": { + "properties": { + "errors": { + "items": { + "type": "string" + }, + "type": "array" + }, + "message": { + "type": "string" + } + }, + "type": "object" + } + }, + "409": { + "description": "Request failed. The workflow is already shared with the user.", + "examples": { + "application/json": { + "errors": [ + "The workflow is already shared with the user." + ], + "message": "The workflow is already shared with the user." + } + }, + "schema": { + "properties": { + "errors": { + "items": { + "type": "string" + }, + "type": "array" + }, + "message": { + "type": "string" + } + }, + "type": "object" + } + }, + "500": { + "description": "Request failed. Internal controller error.", + "examples": { + "application/json": { + "errors": [ + "Internal controller error." + ], + "message": "Internal controller error." + } + }, + "schema": { + "properties": { + "errors": { + "items": { + "type": "string" + }, + "type": "array" + }, + "message": { + "type": "string" + } + }, + "type": "object" + } + } + }, + "summary": "Share a workflow with other users." + } + }, + "/api/workflows/{workflow_id_or_name}/share-status": { + "get": { + "description": "This resource returns the share status of a given workflow.", + "operationId": "get_workflow_share_status", + "parameters": [ + { + "description": "Required. UUID of workflow owner.", + "in": "query", + "name": "user_id", + "required": true, + "type": "string" + }, + { + "description": "Required. Workflow UUID or name.", + "in": "path", + "name": "workflow_id_or_name", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "Request succeeded. The response contains the share status of the workflow.", + "examples": { + "application/json": { + "shared_with": [ + { + "user_email": "bob@example.org", + "valid_until": "2022-11-24T23:59:59" + } + ], + "workflow_id": "256b25f4-4cfb-4684-b7a8-73872ef455a1", + "workflow_name": "mytest.1" + } + }, + "schema": { + "properties": { + "shared_with": { + "items": { + "properties": { + "user_email": { + "type": "string" + }, + "valid_until": { + "type": "string", + "x-nullable": true + } + }, + "type": "object" + }, + "type": "array" + }, + "workflow_id": { + "type": "string" + }, + "workflow_name": { + "type": "string" + } + }, + "type": "object" + } + }, + "401": { + "description": "Request failed. User not signed in.", + "examples": { + "application/json": { + "message": "User not signed in." + } + }, + "schema": { + "properties": { + "message": { + "type": "string" + } + }, + "type": "object" + } + }, + "403": { + "description": "Request failed. Credentials are invalid or revoked.", + "examples": { + "application/json": { + "message": "Token not valid." + } + }, + "schema": { + "properties": { + "message": { + "type": "string" + } + }, + "type": "object" + } + }, + "404": { + "description": "Request failed. Workflow does not exist.", + "examples": { + "application/json": { + "message": "Workflow mytest.1 does not exist." + } + }, + "schema": { + "properties": { + "message": { + "type": "string" + } + }, + "type": "object" + } + }, + "500": { + "description": "Request failed. Internal server error.", + "examples": { + "application/json": { + "message": "Something went wrong." + } + }, + "schema": { + "properties": { + "message": { + "type": "string" + } + }, + "type": "object" + } + } + }, + "summary": "Get the share status of a workflow." + } + }, "/api/workflows/{workflow_id_or_name}/status": { "get": { "description": "This resource reports the status of workflow.", @@ -1227,6 +1578,183 @@ "summary": "Set workflow status." } }, + "/api/workflows/{workflow_id_or_name}/unshare": { + "post": { + "description": "This resource allows to unshare a workflow with other users.", + "operationId": "unshare_workflow", + "parameters": [ + { + "description": "Required. UUID of workflow owner.", + "in": "query", + "name": "user_id", + "required": true, + "type": "string" + }, + { + "description": "Required. Analysis UUID or name.", + "in": "path", + "name": "workflow_id_or_name", + "required": true, + "type": "string" + }, + { + "description": "Required. User to unshare the workflow with.", + "in": "query", + "name": "user_email_to_unshare_with", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "Request succeeded. The workflow has been unshared with the user.", + "examples": { + "application/json": { + "message": "The workflow has been unsahred with the user.", + "workflow_id": "cdcf48b1-c2f3-4693-8230-b066e088c6ac", + "workflow_name": "mytest.1" + } + }, + "schema": { + "properties": { + "message": { + "type": "string" + }, + "workflow_id": { + "type": "string" + }, + "workflow_name": { + "type": "string" + } + }, + "type": "object" + } + }, + "400": { + "description": "Request failed. The incoming data specification seems malformed.", + "examples": { + "application/json": { + "errors": [ + "Missing data for required field." + ], + "message": "Malformed request." + } + }, + "schema": { + "properties": { + "errors": { + "items": { + "type": "string" + }, + "type": "array" + }, + "message": { + "type": "string" + } + }, + "type": "object" + } + }, + "403": { + "description": "Request failed. User is not allowed to unshare the workflow.", + "examples": { + "application/json": { + "errors": [ + "User is not allowed to unshare the workflow." + ] + } + }, + "schema": { + "properties": { + "message": { + "type": "string" + } + }, + "type": "object" + } + }, + "404": { + "description": "Request failed. Workflow does not exist or user does not exist.", + "examples": { + "application/json": { + "errors": [ + "Workflow cdcf48b1-c2f3-4693-8230-b066e088c6ac does not exist" + ], + "message": "Workflow cdcf48b1-c2f3-4693-8230-b066e088c6ac does not exist" + } + }, + "schema": { + "properties": { + "errors": { + "items": { + "type": "string" + }, + "type": "array" + }, + "message": { + "type": "string" + } + }, + "type": "object" + } + }, + "409": { + "description": "Request failed. The workflow is not shared with the user.", + "examples": { + "application/json": { + "errors": [ + "The workflow is not shared with the user." + ], + "message": "The workflow is not shared with the user." + } + }, + "schema": { + "properties": { + "errors": { + "items": { + "type": "string" + }, + "type": "array" + }, + "message": { + "type": "string" + } + }, + "type": "object" + } + }, + "500": { + "description": "Request failed. Internal controller error.", + "examples": { + "application/json": { + "errors": [ + "Internal controller error." + ], + "message": "Internal controller error." + } + }, + "schema": { + "properties": { + "errors": { + "items": { + "type": "string" + }, + "type": "array" + }, + "message": { + "type": "string" + } + }, + "type": "object" + } + } + }, + "summary": "Unshare a workflow with other users." + } + }, "/api/workflows/{workflow_id_or_name}/workspace": { "get": { "description": "This resource retrieves the file list of a workspace, given its workflow UUID.", diff --git a/reana_workflow_controller/rest/workflows.py b/reana_workflow_controller/rest/workflows.py index 618d6215..348cffd0 100644 --- a/reana_workflow_controller/rest/workflows.py +++ b/reana_workflow_controller/rest/workflows.py @@ -1,29 +1,30 @@ # -*- coding: utf-8 -*- # # This file is part of REANA. -# Copyright (C) 2020, 2021, 2022 CERN. +# Copyright (C) 2020, 2021, 2022, 2023 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 Workflow Controller workflows REST API.""" +import datetime import json import logging +import re from typing import Optional -from uuid import uuid4 +from uuid import UUID, uuid4 from flask import Blueprint, jsonify, request from reana_commons.config import WORKFLOW_TIME_FORMAT from reana_db.database import Session -from reana_db.utils import build_workspace_path -from reana_db.models import User, Workflow, RunStatus, WorkflowResource -from reana_db.utils import _get_workflow_with_uuid_or_name, get_default_quota_resource -from sqlalchemy import and_, nullslast -from webargs import fields -from webargs.flaskparser import use_args, use_kwargs - - +from reana_db.models import RunStatus, User, UserWorkflow, Workflow, WorkflowResource +from reana_db.utils import ( + _get_workflow_by_uuid, + _get_workflow_with_uuid_or_name, + build_workspace_path, + get_default_quota_resource, +) from reana_workflow_controller.config import DEFAULT_NAME_FOR_WORKFLOWS from reana_workflow_controller.errors import ( REANAWorkflowControllerError, @@ -38,7 +39,10 @@ is_uuid_v4, use_paginate_args, ) - +from sqlalchemy import and_, nullslast, or_ +from sqlalchemy.orm import aliased +from webargs import fields +from webargs.flaskparser import use_args, use_kwargs START = "start" STOP = "stop" @@ -61,6 +65,9 @@ "user": fields.String(required=True), "verbose": fields.Bool(missing=False), "workflow_id_or_name": fields.String(), + "shared": fields.Bool(missing=False), + "shared_by": fields.String(), + "shared_with": fields.DelimitedList(fields.String()), }, location="query", ) @@ -135,6 +142,21 @@ def get_workflows(args, paginate=None): # noqa description: Optional analysis UUID or name to filter. required: false type: string + - name: shared + in: query + description: Optional flag to list all shared (owned and unowned) workflows. + required: false + type: boolean + - name: shared_by + in: query + description: Optional argument to list workflows shared by the specified user(s). + required: false + type: string + - name: shared_with + in: query + description: Optional argument to list workflows shared with the specified user(s). + required: false + type: string responses: 200: description: >- @@ -172,6 +194,10 @@ def get_workflows(args, paginate=None): # noqa launcher_url: type: string x-nullable: true + owner_email: + type: string + shared_with: + type: string examples: application/json: [ @@ -255,14 +281,90 @@ def get_workflows(args, paginate=None): # noqa include_progress: bool = args.get("include_progress", verbose) include_workspace_size: bool = args.get("include_workspace_size", verbose) workflow_id_or_name: Optional[str] = args.get("workflow_id_or_name") + shared: bool = args.get("shared") + shared_by: Optional[str] = args.get("shared_by") + shared_with: Optional[str] = args.get("shared_with") try: + if shared_by and shared_with: + return ( + jsonify( + { + "message": "You cannot filter by shared_by and shared_with at the same time." + } + ), + 400, + ) + user = User.query.filter(User.id_ == user_uuid).first() if not user: return jsonify({"message": "User {} does not exist".format(user_uuid)}), 404 workflows = [] - query = user.workflows + + if not shared and not shared_with and not shared_by: + # default case: retrieve owned workflows + query = user.workflows + else: + if shared or shared_by: + # retrieve owned workflows and unowned workflows shared by others + workflows_shared_with_user = Session.query( + user.shared_workflows.subquery().c.id_ + ) + query = Session.query(Workflow).filter( + or_( + Workflow.owner_id == user.id_, + Workflow.id_.in_(workflows_shared_with_user), + ) + ) + if shared_by and not shared: + if shared_by == "anybody": + # retrieve unowned workflows shared by anyone + query = query.filter(Workflow.id_.in_(workflows_shared_with_user)) + else: + # retrieve unowned workflows shared by specific user + shared_by_user = ( + Session.query(User).filter(User.email == shared_by).first() + ) + if not shared_by_user: + return ( + jsonify( + { + "message": f"User with email '{shared_by}' does not exist." + } + ), + 404, + ) + query = query.filter(Workflow.owner_id == shared_by_user.id_) + if shared_with: + # starting point: retrieve owned workflows + query = user.workflows + + first_shared_with = shared_with[0] + workflows_shared_by_user = Session.query(Workflow.id_).join( + UserWorkflow, + and_( + Workflow.id_ == UserWorkflow.workflow_id, + Workflow.owner_id == user.id_, + ), + ) + + if first_shared_with == "nobody": + # retrieve owned unshared workflows + query = query.filter(Workflow.id_.notin_(workflows_shared_by_user)) + elif first_shared_with == "anybody": + # retrieve exclusively owned shared workflows + query = query.filter(Workflow.id_.in_(workflows_shared_by_user)) + else: + # retrieve owned workflows shared with specific users + shared_with_users = Session.query(User.id_).filter( + User.email.in_(shared_with) + ) + shared_with_workflows_ids = Session.query( + UserWorkflow.workflow_id + ).filter(UserWorkflow.user_id.in_(shared_with_users)) + query = query.filter(Workflow.id_.in_(shared_with_workflows_ids)) + if search: search = json.loads(search) search_val = search.get("name")[0] @@ -292,7 +394,38 @@ def get_workflows(args, paginate=None): # noqa elif sort in ["asc", "desc"]: column_sorted = getattr(Workflow.created, sort)() pagination_dict = paginate(query.order_by(column_sorted)) + + owner_ids = {workflow.owner_id for workflow in pagination_dict["items"]} + owners = dict( + Session.query(User.id_, User.email).filter(User.id_.in_(owner_ids)).all() + ) + for workflow in pagination_dict["items"]: + owner_email = owners.get(workflow.owner_id, "-") + + if owner_email == user.email: + owner_email = "-" + + if shared or shared_with: + shared_with_users = ( + Session.query(User.email) + .join( + UserWorkflow, + and_( + User.id_ == UserWorkflow.user_id, + UserWorkflow.workflow_id == workflow.id_, + ), + ) + .all() + ) + else: + shared_with_users = None + + if shared_with_users and owner_email == "-": + shared_with_emails = [user[0] for user in shared_with_users] + else: + shared_with_emails = "-" + workflow_response = { "id": workflow.id_, "name": get_workflow_name(workflow), @@ -303,6 +436,10 @@ def get_workflows(args, paginate=None): # noqa "progress": get_workflow_progress( workflow, include_progress=include_progress ), + "owner_email": owner_email, + "shared_with": ",".join(shared_with_emails) + if shared_with_emails != "-" + else "-", } if type_ == "interactive" or verbose: int_session = workflow.sessions.first() @@ -888,3 +1025,626 @@ def get_workflow_retention_rules(workflow_id_or_name: str, user: str): except Exception as e: logging.exception(str(e)) return jsonify({"message": str(e)}), 500 + + +@blueprint.route("/workflows//share", methods=["POST"]) +@use_kwargs( + { + "user_id": fields.Str(required=True), + "user_email_to_share_with": fields.Str(required=True), + "message": fields.Str(required=False), + "valid_until": fields.Str(required=False), + }, + location="query", +) +def share_workflow( + workflow_id_or_name: str, + user_id: str, + user_email_to_share_with: str, + message: str = None, + valid_until: str = None, +): + r"""Share a workflow with other users. + + --- + post: + summary: Share a workflow with other users. + description: >- + This resource allows to share a workflow with other users. + operationId: share_workflow + produces: + - application/json + parameters: + - name: user_id + in: query + description: Required. UUID of workflow owner. + required: true + type: string + - name: workflow_id_or_name + in: path + description: Required. Analysis UUID or name. + required: true + type: string + - name: user_email_to_share_with + in: query + description: >- + Required. User to share the workflow with. + required: true + type: string + - name: message + in: query + description: Optional. Message to include when sharing the workflow. + required: false + type: string + - name: valid_until + in: query + description: Optional. Date when access to the workflow will expire (format YYYY-MM-DD). + required: false + type: string + responses: + 200: + description: >- + Request succeeded. The workflow has been shared with the user. + schema: + type: object + properties: + message: + type: string + workflow_id: + type: string + workflow_name: + type: string + examples: + application/json: + { + "message": "The workflow has been shared with the user.", + "workflow_id": "cdcf48b1-c2f3-4693-8230-b066e088c6ac", + "workflow_name": "mytest.1" + } + 400: + description: >- + Request failed. The incoming data specification seems malformed. + schema: + type: object + properties: + message: + type: string + errors: + type: array + items: + type: string + examples: + application/json: + { + "message": "Malformed request.", + "errors": ["Missing data for required field."] + } + 403: + description: >- + Request failed. User is not allowed to share the workflow. + schema: + type: object + properties: + message: + type: string + examples: + application/json: + { + "errors": ["User is not allowed to share the workflow."] + } + 404: + description: >- + Request failed. Workflow does not exist or user does not exist. + schema: + type: object + properties: + message: + type: string + errors: + type: array + items: + type: string + examples: + application/json: + { + "message": "Workflow cdcf48b1-c2f3-4693-8230-b066e088c6ac does + not exist", + "errors": ["Workflow cdcf48b1-c2f3-4693-8230-b066e088c6ac does + not exist"] + } + 409: + description: >- + Request failed. The workflow is already shared with the user. + schema: + type: object + properties: + message: + type: string + errors: + type: array + items: + type: string + examples: + application/json: + { + "message": "The workflow is already shared with the user.", + "errors": ["The workflow is already shared with the user."] + } + 500: + description: >- + Request failed. Internal controller error. + schema: + type: object + properties: + message: + type: string + errors: + type: array + items: + type: string + examples: + application/json: + { + "message": "Internal controller error.", + "errors": ["Internal controller error."] + } + """ + try: + user = User.query.filter(User.id_ == user_id).first() + if not user: + return ( + jsonify({"message": f"User with id '{user_id}' does not exist."}), + 404, + ) + + if ( + re.match( + r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,7}\b", + user_email_to_share_with, + ) + is None + ): + raise ValueError(f"User email '{user_email_to_share_with}' is not valid.") + + if user.email == user_email_to_share_with: + raise ValueError("Unable to share a workflow with yourself.") + + user_to_share_with = ( + Session.query(User).filter(User.email == user_email_to_share_with).first() + ) + + if not user_to_share_with: + return ( + jsonify( + { + "message": f"User with email '{user_email_to_share_with}' does not exist." + } + ), + 404, + ) + + if valid_until: + try: + datetime.date.fromisoformat(valid_until) + except ValueError as e: + raise ValueError( + f"Date format is not valid ({str(e)}). Please use YYYY-MM-DD format." + ) + + # check if date is in the future + if datetime.date.fromisoformat(valid_until) < datetime.date.today(): + raise ValueError("The 'valid_until' date cannot be in the past.") + + if message and len(message) > 5000: + raise ValueError( + "Message is too long. Please keep it under 5000 characters." + ) + + workflow = _get_workflow_with_uuid_or_name(workflow_id_or_name, user_id) + + existing_share = ( + Session.query(UserWorkflow) + .filter_by(user_id=user_to_share_with.id_, workflow_id=workflow.id_) + .first() + ) + + if existing_share: + return ( + jsonify( + { + "message": f"{workflow.get_full_workflow_name()} is already shared with {user_email_to_share_with}." + } + ), + 409, + ) + + Session.add( + UserWorkflow( + user_id=user_to_share_with.id_, + workflow_id=workflow.id_, + message=message, + valid_until=valid_until, + ) + ) + Session.commit() + + response = { + "message": "The workflow has been shared with the user.", + "workflow_id": workflow.id_, + "workflow_name": workflow.get_full_workflow_name(), + } + return jsonify(response), 200 + except ValueError as e: + logging.exception(str(e)) + return jsonify({"message": str(e)}), 400 + except Exception as e: + logging.exception(str(e)) + return jsonify({"message": str(e)}), 500 + + +@blueprint.route("/workflows//unshare", methods=["POST"]) +@use_kwargs( + { + "user_id": fields.Str(required=True), + "user_email_to_unshare_with": fields.Str(required=True), + }, + location="query", +) +def unshare_workflow( + workflow_id_or_name: str, user_id: str, user_email_to_unshare_with: str +): + r"""Unshare a workflow with other users. + + --- + post: + summary: Unshare a workflow with other users. + description: >- + This resource allows to unshare a workflow with other users. + operationId: unshare_workflow + produces: + - application/json + parameters: + - name: user_id + in: query + description: Required. UUID of workflow owner. + required: true + type: string + - name: workflow_id_or_name + in: path + description: Required. Analysis UUID or name. + required: true + type: string + - name: user_email_to_unshare_with + in: query + description: >- + Required. User to unshare the workflow with. + required: true + type: string + responses: + 200: + description: >- + Request succeeded. The workflow has been unshared with the user. + schema: + type: object + properties: + message: + type: string + workflow_id: + type: string + workflow_name: + type: string + examples: + application/json: + { + "message": "The workflow has been unsahred with the user.", + "workflow_id": "cdcf48b1-c2f3-4693-8230-b066e088c6ac", + "workflow_name": "mytest.1" + } + 400: + description: >- + Request failed. The incoming data specification seems malformed. + schema: + type: object + properties: + message: + type: string + errors: + type: array + items: + type: string + examples: + application/json: + { + "message": "Malformed request.", + "errors": ["Missing data for required field."] + } + 403: + description: >- + Request failed. User is not allowed to unshare the workflow. + schema: + type: object + properties: + message: + type: string + examples: + application/json: + { + "errors": ["User is not allowed to unshare the workflow."] + } + 404: + description: >- + Request failed. Workflow does not exist or user does not exist. + schema: + type: object + properties: + message: + type: string + errors: + type: array + items: + type: string + examples: + application/json: + { + "message": "Workflow cdcf48b1-c2f3-4693-8230-b066e088c6ac does + not exist", + "errors": ["Workflow cdcf48b1-c2f3-4693-8230-b066e088c6ac does + not exist"] + } + 409: + description: >- + Request failed. The workflow is not shared with the user. + schema: + type: object + properties: + message: + type: string + errors: + type: array + items: + type: string + examples: + application/json: + { + "message": "The workflow is not shared with the user.", + "errors": ["The workflow is not shared with the user."] + } + 500: + description: >- + Request failed. Internal controller error. + schema: + type: object + properties: + message: + type: string + errors: + type: array + items: + type: string + examples: + application/json: + { + "message": "Internal controller error.", + "errors": ["Internal controller error."] + } + """ + try: + user = User.query.filter(User.id_ == user_id).first() + if not user: + return ( + jsonify({"message": f"User with id '{user_id}' does not exist."}), + 404, + ) + + if ( + re.match( + r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,7}\b", + user_email_to_unshare_with, + ) + is None + ): + raise ValueError(f"User email '{user_email_to_unshare_with}' is not valid.") + + if user.email == user_email_to_unshare_with: + raise ValueError("Unable to unshare a workflow with yourself.") + + user_to_unshare_with = ( + Session.query(User).filter(User.email == user_email_to_unshare_with).first() + ) + + if not user_to_unshare_with: + return ( + jsonify( + { + "message": f"User with email '{user_email_to_unshare_with}' does not exist." + } + ), + 404, + ) + + workflow = _get_workflow_with_uuid_or_name(workflow_id_or_name, str(user_id)) + + existing_share = ( + Session.query(UserWorkflow) + .filter_by(user_id=user_to_unshare_with.id_, workflow_id=workflow.id_) + .first() + ) + + if not existing_share: + return ( + jsonify( + { + "message": f"{workflow.get_full_workflow_name()} is not shared with {user_email_to_unshare_with}." + } + ), + 409, + ) + + Session.delete(existing_share) + Session.commit() + + response = { + "message": "The workflow has been unshared with the user.", + "workflow_id": workflow.id_, + "workflow_name": workflow.get_full_workflow_name(), + } + + return jsonify(response), 200 + except ValueError as e: + logging.exception(str(e)) + return jsonify({"message": str(e)}), 400 + except Exception as e: + logging.exception(str(e)) + return jsonify({"message": str(e)}), 500 + + +@blueprint.route("/workflows//share-status", methods=["GET"]) +@use_kwargs( + { + "user_id": fields.Str(required=True), + }, + location="query", +) +def get_workflow_share_status( + workflow_id_or_name: str, + user_id: str, +): + r"""Get the share status of a workflow. + + --- + get: + summary: Get the share status of a workflow. + description: >- + This resource returns the share status of a given workflow. + operationId: get_workflow_share_status + produces: + - application/json + parameters: + - name: user_id + in: query + description: Required. UUID of workflow owner. + required: true + type: string + - name: workflow_id_or_name + in: path + description: Required. Workflow UUID or name. + required: true + type: string + responses: + 200: + description: >- + Request succeeded. The response contains the share status of the workflow. + schema: + type: object + properties: + workflow_id: + type: string + workflow_name: + type: string + shared_with: + type: array + items: + type: object + properties: + user_email: + type: string + valid_until: + type: string + x-nullable: true + examples: + application/json: + { + "workflow_id": "256b25f4-4cfb-4684-b7a8-73872ef455a1", + "workflow_name": "mytest.1", + "shared_with": [ + { + "user_email": "bob@example.org", + "valid_until": "2022-11-24T23:59:59" + } + ] + } + 401: + description: >- + Request failed. User not signed in. + schema: + type: object + properties: + message: + type: string + examples: + application/json: + { + "message": "User not signed in." + } + 403: + description: >- + Request failed. Credentials are invalid or revoked. + schema: + type: object + properties: + message: + type: string + examples: + application/json: + { + "message": "Token not valid." + } + 404: + description: >- + Request failed. Workflow does not exist. + schema: + type: object + properties: + message: + type: string + examples: + application/json: + { + "message": "Workflow mytest.1 does not exist." + } + 500: + description: >- + Request failed. Internal server error. + schema: + type: object + properties: + message: + type: string + examples: + application/json: + { + "message": "Something went wrong." + } + """ + try: + workflow = _get_workflow_with_uuid_or_name(workflow_id_or_name, user_id) + + shared_with = ( + Session.query(UserWorkflow) + .filter_by(workflow_id=workflow.id_) + .join(User, User.id_ == UserWorkflow.user_id) + .add_columns(User.email, UserWorkflow.valid_until) + .all() + ) + + response = { + "workflow_id": workflow.id_, + "workflow_name": workflow.get_full_workflow_name(), + "shared_with": [ + { + "user_email": share[1], + "valid_until": share[2].strftime("%Y-%m-%dT%H:%M:%S") + if share[2] + else None, + } + for share in shared_with + ], + } + + return jsonify(response), 200 + except ValueError as e: + logging.exception(str(e)) + return jsonify({"message": str(e)}), 404 + except Exception as e: + logging.exception(str(e)) + return jsonify({"message": str(e)}), 500 diff --git a/tests/test_utils.py b/tests/test_utils.py index 4b94aa2a..95c78da4 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -7,8 +7,8 @@ # under the terms of the MIT License; see LICENSE file for more details. """REANA-Workflow-Controller utility tests.""" -import os import json +import os import stat import uuid from contextlib import nullcontext as does_not_raise @@ -18,12 +18,12 @@ import mock import pytest from reana_db.models import ( + InteractiveSession, + InteractiveSessionType, Job, JobCache, RunStatus, Workflow, - InteractiveSession, - InteractiveSessionType, ) from reana_db.utils import ( get_disk_usage_or_zero, @@ -53,9 +53,7 @@ pytest.param(RunStatus.running, marks=pytest.mark.xfail(strict=True)), ], ) -def test_delete_workflow( - app, session, default_user, sample_yadage_workflow_in_db, status -): +def test_delete_workflow(app, session, user0, sample_yadage_workflow_in_db, status): """Test deletion of a workflow in all possible statuses.""" sample_yadage_workflow_in_db.status = status session.add(sample_yadage_workflow_in_db) @@ -65,16 +63,14 @@ def test_delete_workflow( assert sample_yadage_workflow_in_db.status == RunStatus.deleted -def test_delete_all_workflow_runs( - app, session, default_user, yadage_workflow_with_name -): +def test_delete_all_workflow_runs(app, session, user0, yadage_workflow_with_name): """Test deletion of all runs of a given workflow.""" # add 5 workflows in the database with the same name for i in range(5): workflow = Workflow( id_=uuid.uuid4(), name=yadage_workflow_with_name["name"], - owner_id=default_user.id_, + owner_id=user0.id_, reana_specification=yadage_workflow_with_name["reana_specification"], operational_options={}, type_=yadage_workflow_with_name["reana_specification"]["workflow"]["type"], @@ -138,7 +134,7 @@ def test_workspace_deletion( mock_update_workflow_quota, app, session, - default_user, + user0, sample_yadage_workflow_in_db, workspace, ): @@ -206,7 +202,7 @@ def test_workspace_deletion( def test_deletion_of_workspace_of_an_already_deleted_workflow( - app, session, default_user, sample_yadage_workflow_in_db + app, session, user0, sample_yadage_workflow_in_db ): """Test workspace deletion of an already deleted workflow.""" create_workflow_workspace(sample_yadage_workflow_in_db.workspace_path) @@ -272,7 +268,7 @@ def test_list_recursive_wildcard(tmp_shared_volume_path): def test_workspace_permissions( - app, session, default_user, sample_yadage_workflow_in_db, tmp_shared_volume_path + app, session, user0, sample_yadage_workflow_in_db, tmp_shared_volume_path ): """Test workspace dir permissions.""" create_workflow_workspace(sample_yadage_workflow_in_db.workspace_path) diff --git a/tests/test_views.py b/tests/test_views.py index be4d2b51..530cc2ad 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # # This file is part of REANA. -# Copyright (C) 2017, 2018, 2019, 2020, 2021, 2022 CERN. +# Copyright (C) 2017, 2018, 2019, 2020, 2021, 2022, 2023 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. @@ -17,21 +17,14 @@ import mock import pytest from flask import url_for -from reana_db.models import ( - Job, - JobCache, - Workflow, - RunStatus, - InteractiveSession, -) -from werkzeug.utils import secure_filename - +from reana_db.models import InteractiveSession, Job, JobCache, RunStatus, Workflow from reana_workflow_controller.rest.utils import ( create_workflow_workspace, delete_workflow, ) from reana_workflow_controller.rest.workflows_status import START, STOP from reana_workflow_controller.workflow_run_manager import WorkflowRunManager +from werkzeug.utils import secure_filename status_dict = { START: RunStatus.pending, @@ -39,7 +32,7 @@ } -def test_get_workflows(app, session, default_user, cwl_workflow_with_name): +def test_get_workflows(app, session, user0, cwl_workflow_with_name): """Test listing all workflows.""" with app.test_client() as client: workflow_uuid = uuid.uuid4() @@ -48,7 +41,7 @@ def test_get_workflows(app, session, default_user, cwl_workflow_with_name): id_=workflow_uuid, name=workflow_name, status=RunStatus.finished, - owner_id=default_user.id_, + owner_id=user0.id_, reana_specification=cwl_workflow_with_name["reana_specification"], type_=cwl_workflow_with_name["reana_specification"]["type"], logs="", @@ -57,7 +50,7 @@ def test_get_workflows(app, session, default_user, cwl_workflow_with_name): session.commit() res = client.get( url_for("workflows.get_workflows"), - query_string={"user": default_user.id_, "type": "batch"}, + query_string={"user": user0.id_, "type": "batch"}, ) assert res.status_code == 200 response_data = json.loads(res.get_data(as_text=True))["items"] @@ -71,6 +64,8 @@ def test_get_workflows(app, session, default_user, cwl_workflow_with_name): "progress": response_data[0]["progress"], "size": {"raw": -1, "human_readable": ""}, "launcher_url": None, + "owner_email": "-", + "shared_with": "-", } ] @@ -97,25 +92,23 @@ def test_get_workflows_missing_user(app): assert res.status_code == 400 -def test_get_workflows_missing_type(app, default_user): +def test_get_workflows_missing_type(app, user0): """Test listing all workflows with missing type.""" with app.test_client() as client: res = client.get( - url_for("workflows.get_workflows"), query_string={"user": default_user.id_} + url_for("workflows.get_workflows"), query_string={"user": user0.id_} ) assert res.status_code == 400 -def test_get_workflows_include_progress( - app, default_user, sample_yadage_workflow_in_db -): +def test_get_workflows_include_progress(app, user0, sample_yadage_workflow_in_db): """Test listing all workflows without including progress.""" workflow = sample_yadage_workflow_in_db with app.test_client() as client: res = client.get( url_for("workflows.get_workflows"), query_string={ - "user": default_user.id_, + "user": user0.id_, "type": "batch", "verbose": "true", "include_progress": "false", @@ -129,7 +122,7 @@ def test_get_workflows_include_progress( def test_get_workflows_include_retention_rules( - app, default_user, sample_yadage_workflow_in_db + app, user0, sample_yadage_workflow_in_db ): """Test listing all workflows without including retention rules.""" workflow = sample_yadage_workflow_in_db @@ -137,7 +130,7 @@ def test_get_workflows_include_retention_rules( res = client.get( url_for("workflows.get_workflows"), query_string={ - "user": default_user.id_, + "user": user0.id_, "type": "batch", "verbose": "true", "include_retention_rules": "false", @@ -149,15 +142,149 @@ def test_get_workflows_include_retention_rules( assert "retention_rules" not in response_data +def test_get_workflows_shared( + app, user1, user2, sample_yadage_workflow_in_db_owned_by_user1 +): + """Test listing shared workflows.""" + workflow = sample_yadage_workflow_in_db_owned_by_user1 + with app.test_client() as client: + # share workflow with user2 + res = client.post( + url_for( + "workflows.share_workflow", + workflow_id_or_name=str(workflow.id_), + ), + query_string={ + "user_id": str(user1.id_), + "user_email_to_share_with": user2.email, + }, + ) + + # list shared workflows for user2 + res = client.get( + url_for("workflows.get_workflows"), + query_string={"user": user1.id_, "shared": True, "type": "batch"}, + ) + assert res.status_code == 200 + response_data = json.loads(res.get_data(as_text=True))["items"] + assert len(response_data) == 1 + assert response_data[0]["id"] == str(workflow.id_) + assert response_data[0]["shared_with"] == user2.email + assert response_data[0]["owner_email"] == "-" + + +def test_get_workflows_shared_by( + app, user1, user2, sample_yadage_workflow_in_db_owned_by_user1 +): + """Test listing workflows shared by a user.""" + workflow = sample_yadage_workflow_in_db_owned_by_user1 + with app.test_client() as client: + # share workflow with user2 + res = client.post( + url_for( + "workflows.share_workflow", + workflow_id_or_name=str(workflow.id_), + ), + query_string={ + "user_id": str(user1.id_), + "user_email_to_share_with": user2.email, + }, + ) + + # list shared workflows for user1 + res = client.get( + url_for("workflows.get_workflows"), + query_string={ + "user": user2.id_, + "shared_by": user1.email, + "type": "batch", + }, + ) + assert res.status_code == 200 + response_data = json.loads(res.get_data(as_text=True))["items"] + assert len(response_data) == 1 + assert response_data[0]["id"] == str(workflow.id_) + assert response_data[0]["owner_email"] == user1.email + + +def test_get_workflows_shared_with( + app, user1, user2, sample_yadage_workflow_in_db_owned_by_user1 +): + """Test listing workflows shared with a user.""" + workflow = sample_yadage_workflow_in_db_owned_by_user1 + with app.test_client() as client: + # share workflow with user2 + res = client.post( + url_for( + "workflows.share_workflow", + workflow_id_or_name=str(workflow.id_), + ), + query_string={ + "user_id": str(user1.id_), + "user_email_to_share_with": user2.email, + }, + ) + + # list shared workflows for user2 + res = client.get( + url_for("workflows.get_workflows"), + query_string={ + "user": user1.id_, + "shared_with": user2.email, + "type": "batch", + }, + ) + assert res.status_code == 200 + response_data = json.loads(res.get_data(as_text=True))["items"] + assert len(response_data) == 1 + assert response_data[0]["id"] == str(workflow.id_) + assert response_data[0]["shared_with"] == user2.email + + +def test_get_workflows_shared_by_and_shared_with( + app, user1, user2, sample_yadage_workflow_in_db_owned_by_user1 +): + """Test listing workflows shared by and shared with a user.""" + workflow = sample_yadage_workflow_in_db_owned_by_user1 + with app.test_client() as client: + # share workflow with user2 + res = client.post( + url_for( + "workflows.share_workflow", + workflow_id_or_name=str(workflow.id_), + ), + query_string={ + "user_id": str(user1.id_), + "user_email_to_share_with": user2.email, + }, + ) + + # list shared workflows for user2 + res = client.get( + url_for("workflows.get_workflows"), + query_string={ + "user": user2.id_, + "shared_with": user1.email, + "shared_by": user1.email, + "type": "batch", + }, + ) + assert res.status_code == 400 + response_data = res.get_json() + assert response_data["message"] == ( + "You cannot filter by shared_by and shared_with at the same time." + ) + + def test_create_workflow_with_name( - app, session, default_user, cwl_workflow_with_name, tmp_shared_volume_path + app, session, user0, cwl_workflow_with_name, tmp_shared_volume_path ): """Test create workflow and its workspace by specifying a name.""" with app.test_client() as client: res = client.post( url_for("workflows.create_workflow"), query_string={ - "user": default_user.id_, + "user": user0.id_, "workspace_root_path": tmp_shared_volume_path, }, content_type="application/json", @@ -185,14 +312,14 @@ def test_create_workflow_with_name( def test_create_workflow_without_name( - app, session, default_user, cwl_workflow_without_name, tmp_shared_volume_path + app, session, user0, cwl_workflow_without_name, tmp_shared_volume_path ): """Test create workflow and its workspace without specifying a name.""" with app.test_client() as client: res = client.post( url_for("workflows.create_workflow"), query_string={ - "user": default_user.id_, + "user": user0.id_, "workspace_root_path": tmp_shared_volume_path, }, content_type="application/json", @@ -254,7 +381,7 @@ def test_create_workflow_wrong_user( def test_download_missing_file( - app, default_user, cwl_workflow_with_name, tmp_shared_volume_path + app, user0, cwl_workflow_with_name, tmp_shared_volume_path ): """Test download missing file.""" with app.test_client() as client: @@ -262,7 +389,7 @@ def test_download_missing_file( res = client.post( url_for("workflows.create_workflow"), query_string={ - "user": default_user.id_, + "user": user0.id_, "workspace_root_path": tmp_shared_volume_path, }, content_type="application/json", @@ -279,7 +406,7 @@ def test_download_missing_file( workflow_id_or_name=workflow_uuid, file_name=file_name, ), - query_string={"user": default_user.id_}, + query_string={"user": user0.id_}, content_type="application/json", data=json.dumps(cwl_workflow_with_name), ) @@ -292,7 +419,7 @@ def test_download_missing_file( def test_download_file( app, session, - default_user, + user0, tmp_shared_volume_path, cwl_workflow_with_name, sample_serial_workflow_in_db, @@ -303,7 +430,7 @@ def test_download_file( res = client.post( url_for("workflows.create_workflow"), query_string={ - "user": default_user.id_, + "user": user0.id_, "workspace_root_path": tmp_shared_volume_path, }, content_type="application/json", @@ -332,7 +459,7 @@ def test_download_file( workflow_id_or_name=workflow_uuid, file_name=file_name, ), - query_string={"user": default_user.id_}, + query_string={"user": user0.id_}, content_type="application/json", data=json.dumps(cwl_workflow_with_name), ) @@ -340,7 +467,7 @@ def test_download_file( def test_download_file_with_path( - app, session, default_user, tmp_shared_volume_path, cwl_workflow_with_name + app, session, user0, tmp_shared_volume_path, cwl_workflow_with_name ): """Test download file prepended with path.""" with app.test_client() as client: @@ -348,7 +475,7 @@ def test_download_file_with_path( res = client.post( url_for("workflows.create_workflow"), query_string={ - "user": default_user.id_, + "user": user0.id_, "workspace_root_path": tmp_shared_volume_path, }, content_type="application/json", @@ -376,7 +503,7 @@ def test_download_file_with_path( workflow_id_or_name=workflow_uuid, file_name=file_name, ), - query_string={"user": default_user.id_}, + query_string={"user": user0.id_}, content_type="application/json", data=json.dumps(cwl_workflow_with_name), ) @@ -384,7 +511,7 @@ def test_download_file_with_path( def test_download_dir_or_wildcard( - app, session, default_user, tmp_shared_volume_path, cwl_workflow_with_name + app, session, user0, tmp_shared_volume_path, cwl_workflow_with_name ): """Test download directory or file(s) matching a wildcard pattern.""" @@ -395,7 +522,7 @@ def _download(pattern, workflow_uuid): workflow_id_or_name=workflow_uuid, file_name=pattern, ), - query_string={"user": default_user.id_}, + query_string={"user": user0.id_}, content_type="application/json", data=json.dumps(cwl_workflow_with_name), ) @@ -405,7 +532,7 @@ def _download(pattern, workflow_uuid): res = client.post( url_for("workflows.create_workflow"), query_string={ - "user": default_user.id_, + "user": user0.id_, "workspace_root_path": tmp_shared_volume_path, }, content_type="application/json", @@ -458,16 +585,14 @@ def _download(pattern, workflow_uuid): assert res.data == files["foo/1.txt"] -def test_get_files( - app, session, default_user, tmp_shared_volume_path, cwl_workflow_with_name -): +def test_get_files(app, session, user0, tmp_shared_volume_path, cwl_workflow_with_name): """Test get files list.""" with app.test_client() as client: # create workflow res = client.post( url_for("workflows.create_workflow"), query_string={ - "user": default_user.id_, + "user": user0.id_, "workspace_root_path": tmp_shared_volume_path, }, content_type="application/json", @@ -491,7 +616,7 @@ def test_get_files( res = client.get( url_for("workspaces.get_files", workflow_id_or_name=workflow_uuid), - query_string={"user": default_user.id_}, + query_string={"user": user0.id_}, content_type="application/json", data=json.dumps(cwl_workflow_with_name), ) @@ -500,7 +625,7 @@ def test_get_files( def test_get_files_deleted_workflow( - app, default_user, tmp_shared_volume_path, cwl_workflow_with_name + app, user0, tmp_shared_volume_path, cwl_workflow_with_name ): """Test get files list of a deleted workflow without a workspace.""" with app.test_client() as client: @@ -508,7 +633,7 @@ def test_get_files_deleted_workflow( res = client.post( url_for("workflows.create_workflow"), query_string={ - "user": default_user.id_, + "user": user0.id_, "workspace_root_path": tmp_shared_volume_path, }, content_type="application/json", @@ -524,7 +649,7 @@ def test_get_files_deleted_workflow( "statuses.set_workflow_status", workflow_id_or_name=workflow_uuid, ), - query_string={"user": default_user.id_, "status": "deleted"}, + query_string={"user": user0.id_, "status": "deleted"}, content_type="application/json", data=json.dumps({}), ) @@ -537,20 +662,20 @@ def test_get_files_deleted_workflow( # get list of files res = client.get( url_for("workspaces.get_files", workflow_id_or_name=workflow_uuid), - query_string={"user": default_user.id_}, + query_string={"user": user0.id_}, content_type="application/json", ) assert res.status_code == 404 -def test_get_files_unknown_workflow(app, default_user): +def test_get_files_unknown_workflow(app, user0): """Test get list of files for non existing workflow.""" with app.test_client() as client: # create workflow random_workflow_uuid = str(uuid.uuid4()) res = client.get( url_for("workspaces.get_files", workflow_id_or_name=random_workflow_uuid), - query_string={"user": default_user.id_}, + query_string={"user": user0.id_}, content_type="application/json", ) @@ -566,7 +691,7 @@ def test_get_files_unknown_workflow(app, default_user): def test_get_workflow_status_with_uuid( - app, session, default_user, cwl_workflow_with_name, tmp_shared_volume_path + app, session, user0, cwl_workflow_with_name, tmp_shared_volume_path ): """Test get workflow status.""" with app.test_client() as client: @@ -574,7 +699,7 @@ def test_get_workflow_status_with_uuid( res = client.post( url_for("workflows.create_workflow"), query_string={ - "user": default_user.id_, + "user": user0.id_, "workspace_root_path": tmp_shared_volume_path, }, content_type="application/json", @@ -587,7 +712,7 @@ def test_get_workflow_status_with_uuid( res = client.get( url_for("statuses.get_workflow_status", workflow_id_or_name=workflow_uuid), - query_string={"user": default_user.id_}, + query_string={"user": user0.id_}, content_type="application/json", data=json.dumps(cwl_workflow_with_name), ) @@ -598,7 +723,7 @@ def test_get_workflow_status_with_uuid( res = client.get( url_for("statuses.get_workflow_status", workflow_id_or_name=workflow_uuid), - query_string={"user": default_user.id_}, + query_string={"user": user0.id_}, content_type="application/json", data=json.dumps(cwl_workflow_with_name), ) @@ -606,9 +731,7 @@ def test_get_workflow_status_with_uuid( assert json_response.get("status") == workflow.status.name -def test_get_workflow_status_with_name( - app, session, default_user, cwl_workflow_with_name -): +def test_get_workflow_status_with_name(app, session, user0, cwl_workflow_with_name): """Test get workflow status.""" with app.test_client() as client: # create workflow @@ -618,7 +741,7 @@ def test_get_workflow_status_with_name( id_=workflow_uuid, name=workflow_name, status=RunStatus.finished, - owner_id=default_user.id_, + owner_id=user0.id_, reana_specification=cwl_workflow_with_name["reana_specification"], type_=cwl_workflow_with_name["reana_specification"]["type"], logs="", @@ -632,7 +755,7 @@ def test_get_workflow_status_with_name( url_for( "statuses.get_workflow_status", workflow_id_or_name=workflow_name + ".1" ), - query_string={"user": default_user.id_}, + query_string={"user": user0.id_}, content_type="application/json", data=json.dumps(cwl_workflow_with_name), ) @@ -646,7 +769,7 @@ def test_get_workflow_status_with_name( url_for( "statuses.get_workflow_status", workflow_id_or_name=workflow_name + ".1" ), - query_string={"user": default_user.id_}, + query_string={"user": user0.id_}, content_type="application/json", data=json.dumps(cwl_workflow_with_name), ) @@ -655,7 +778,7 @@ def test_get_workflow_status_with_name( def test_get_workflow_status_unauthorized( - app, default_user, cwl_workflow_with_name, tmp_shared_volume_path + app, user0, cwl_workflow_with_name, tmp_shared_volume_path ): """Test get workflow status unauthorized.""" with app.test_client() as client: @@ -663,7 +786,7 @@ def test_get_workflow_status_unauthorized( res = client.post( url_for("workflows.create_workflow"), query_string={ - "user": default_user.id_, + "user": user0.id_, "workspace_root_path": tmp_shared_volume_path, }, content_type="application/json", @@ -685,15 +808,13 @@ def test_get_workflow_status_unauthorized( assert res.status_code == 404 -def test_get_workflow_status_unknown_workflow( - app, default_user, cwl_workflow_with_name -): +def test_get_workflow_status_unknown_workflow(app, user0, cwl_workflow_with_name): """Test get workflow status for unknown workflow.""" with app.test_client() as client: # create workflow res = client.post( url_for("workflows.create_workflow"), - query_string={"user": default_user.id_}, + query_string={"user": user0.id_}, content_type="application/json", data=json.dumps(cwl_workflow_with_name), ) @@ -702,7 +823,7 @@ def test_get_workflow_status_unknown_workflow( url_for( "statuses.get_workflow_status", workflow_id_or_name=random_workflow_uuid ), - query_string={"user": default_user.id_}, + query_string={"user": user0.id_}, content_type="application/json", data=json.dumps(cwl_workflow_with_name), ) @@ -714,7 +835,7 @@ def test_set_workflow_status( corev1_api_client_with_user_secrets, user_secrets, session, - default_user, + user0, yadage_workflow_with_name, tmp_shared_volume_path, ): @@ -724,7 +845,7 @@ def test_set_workflow_status( res = client.post( url_for("workflows.create_workflow"), query_string={ - "user": default_user.id_, + "user": user0.id_, "workspace_root_path": tmp_shared_volume_path, }, content_type="application/json", @@ -751,7 +872,7 @@ def test_set_workflow_status( "statuses.set_workflow_status", workflow_id_or_name=workflow_created_uuid, ), - query_string={"user": default_user.id_, "status": "start"}, + query_string={"user": user0.id_, "status": "start"}, ) json_response = json.loads(res.data.decode()) assert json_response.get("status") == status_dict[payload].name @@ -761,7 +882,7 @@ def test_set_workflow_status( def test_start_already_started_workflow( app, session, - default_user, + user0, corev1_api_client_with_user_secrets, user_secrets, yadage_workflow_with_name, @@ -774,7 +895,7 @@ def test_start_already_started_workflow( res = client.post( url_for("workflows.create_workflow"), query_string={ - "user": default_user.id_, + "user": user0.id_, "workspace_root_path": tmp_shared_volume_path, }, content_type="application/json", @@ -801,7 +922,7 @@ def test_start_already_started_workflow( "statuses.set_workflow_status", workflow_id_or_name=workflow_created_uuid, ), - query_string={"user": default_user.id_, "status": "start"}, + query_string={"user": user0.id_, "status": "start"}, ) json_response = json.loads(res.data.decode()) assert json_response.get("status") == status_dict[payload].name @@ -810,7 +931,7 @@ def test_start_already_started_workflow( "statuses.set_workflow_status", workflow_id_or_name=workflow_created_uuid, ), - query_string={"user": default_user.id_, "status": "start"}, + query_string={"user": user0.id_, "status": "start"}, ) json_response = json.loads(res.data.decode()) assert res.status_code == 409 @@ -837,7 +958,7 @@ def test_stop_workflow( expected_http_status_code, k8s_stop_call_count, app, - default_user, + user0, yadage_workflow_with_name, sample_serial_workflow_in_db, session, @@ -856,7 +977,7 @@ def test_stop_workflow( "statuses.set_workflow_status", workflow_id_or_name=sample_serial_workflow_in_db.name, ), - query_string={"user": default_user.id_, "status": "stop"}, + query_string={"user": user0.id_, "status": "stop"}, ) assert sample_serial_workflow_in_db.status == expected_status assert res.status_code == expected_http_status_code @@ -867,7 +988,7 @@ def test_stop_workflow( def test_set_workflow_status_unauthorized( - app, default_user, yadage_workflow_with_name, tmp_shared_volume_path + app, user0, yadage_workflow_with_name, tmp_shared_volume_path ): """Test set workflow status unauthorized.""" with app.test_client() as client: @@ -875,7 +996,7 @@ def test_set_workflow_status_unauthorized( res = client.post( url_for("workflows.create_workflow"), query_string={ - "user": default_user.id_, + "user": user0.id_, "workspace_root_path": tmp_shared_volume_path, }, content_type="application/json", @@ -898,7 +1019,7 @@ def test_set_workflow_status_unauthorized( def test_set_workflow_status_unknown_workflow( - app, default_user, yadage_workflow_with_name, tmp_shared_volume_path + app, user0, yadage_workflow_with_name, tmp_shared_volume_path ): """Test set workflow status for unknown workflow.""" with app.test_client() as client: @@ -906,7 +1027,7 @@ def test_set_workflow_status_unknown_workflow( res = client.post( url_for("workflows.create_workflow"), query_string={ - "user": default_user.id_, + "user": user0.id_, "workspace_root_path": tmp_shared_volume_path, }, content_type="application/json", @@ -918,7 +1039,7 @@ def test_set_workflow_status_unknown_workflow( url_for( "statuses.set_workflow_status", workflow_id_or_name=random_workflow_uuid ), - query_string={"user": default_user.id_}, + query_string={"user": user0.id_}, content_type="application/json", data=json.dumps(payload), ) @@ -926,7 +1047,7 @@ def test_set_workflow_status_unknown_workflow( def test_upload_file( - app, session, default_user, tmp_shared_volume_path, cwl_workflow_with_name + app, session, user0, tmp_shared_volume_path, cwl_workflow_with_name ): """Test upload file.""" with app.test_client() as client: @@ -934,7 +1055,7 @@ def test_upload_file( res = client.post( url_for("workflows.create_workflow"), query_string={ - "user": default_user.id_, + "user": user0.id_, "workspace_root_path": tmp_shared_volume_path, }, content_type="application/json", @@ -950,7 +1071,7 @@ def test_upload_file( res = client.post( url_for("workspaces.upload_file", workflow_id_or_name=workflow_uuid), - query_string={"user": default_user.id_, "file_name": file_name}, + query_string={"user": user0.id_, "file_name": file_name}, content_type="application/octet-stream", input_stream=io.BytesIO(file_binary_content), ) @@ -969,7 +1090,7 @@ def test_upload_file( assert f.read() == file_binary_content -def test_upload_file_unknown_workflow(app, default_user): +def test_upload_file_unknown_workflow(app, user0): """Test upload file to non existing workflow.""" with app.test_client() as client: random_workflow_uuid = uuid.uuid4() @@ -979,14 +1100,14 @@ def test_upload_file_unknown_workflow(app, default_user): res = client.post( url_for("workspaces.upload_file", workflow_id_or_name=random_workflow_uuid), - query_string={"user": default_user.id_, "file_name": file_name}, + query_string={"user": user0.id_, "file_name": file_name}, content_type="application/octet-stream", input_stream=io.BytesIO(file_binary_content), ) assert res.status_code == 404 -def test_delete_file(app, default_user, sample_serial_workflow_in_db): +def test_delete_file(app, user0, sample_serial_workflow_in_db): """Test delete file.""" # Move to fixture from flask import current_app @@ -1007,14 +1128,14 @@ def test_delete_file(app, default_user, sample_serial_workflow_in_db): workflow_id_or_name=sample_serial_workflow_in_db.id_, file_name=file_name, ), - query_string={"user": default_user.id_}, + query_string={"user": user0.id_}, ) assert res.status_code == 200 assert not os.path.exists(abs_path_to_file) def test_get_created_workflow_logs( - app, default_user, cwl_workflow_with_name, tmp_shared_volume_path + app, user0, cwl_workflow_with_name, tmp_shared_volume_path ): """Test get workflow logs.""" with app.test_client() as client: @@ -1022,7 +1143,7 @@ def test_get_created_workflow_logs( res = client.post( url_for("workflows.create_workflow"), query_string={ - "user": default_user.id_, + "user": user0.id_, "workspace_root_path": tmp_shared_volume_path, }, content_type="application/json", @@ -1033,7 +1154,7 @@ def test_get_created_workflow_logs( workflow_name = response_data.get("workflow_name") res = client.get( url_for("statuses.get_workflow_logs", workflow_id_or_name=workflow_uuid), - query_string={"user": default_user.id_}, + query_string={"user": user0.id_}, content_type="application/json", data=json.dumps(None), ) @@ -1042,14 +1163,14 @@ def test_get_created_workflow_logs( expected_data = { "workflow_id": workflow_uuid, "workflow_name": workflow_name, - "user": str(default_user.id_), + "user": str(user0.id_), "logs": '{"workflow_logs": "", "job_logs": {},' ' "engine_specific": null}', } assert response_data == expected_data def test_get_unknown_workflow_logs( - app, default_user, yadage_workflow_with_name, tmp_shared_volume_path + app, user0, yadage_workflow_with_name, tmp_shared_volume_path ): """Test set workflow status for unknown workflow.""" with app.test_client() as client: @@ -1057,7 +1178,7 @@ def test_get_unknown_workflow_logs( res = client.post( url_for("workflows.create_workflow"), query_string={ - "user": default_user.id_, + "user": user0.id_, "workspace_root_path": tmp_shared_volume_path, }, content_type="application/json", @@ -1068,14 +1189,14 @@ def test_get_unknown_workflow_logs( url_for( "statuses.get_workflow_logs", workflow_id_or_name=random_workflow_uuid ), - query_string={"user": default_user.id_}, + query_string={"user": user0.id_}, content_type="application/json", ) assert res.status_code == 404 def test_get_workflow_logs_unauthorized( - app, default_user, yadage_workflow_with_name, tmp_shared_volume_path + app, user0, yadage_workflow_with_name, tmp_shared_volume_path ): """Test set workflow status for unknown workflow.""" with app.test_client() as client: @@ -1083,7 +1204,7 @@ def test_get_workflow_logs_unauthorized( res = client.post( url_for("workflows.create_workflow"), query_string={ - "user": default_user.id_, + "user": user0.id_, "workspace_root_path": tmp_shared_volume_path, }, content_type="application/json", @@ -1103,7 +1224,7 @@ def test_get_workflow_logs_unauthorized( def test_start_input_parameters( app, session, - default_user, + user0, user_secrets, corev1_api_client_with_user_secrets, sample_serial_workflow_in_db, @@ -1134,7 +1255,7 @@ def test_start_input_parameters( "statuses.set_workflow_status", workflow_id_or_name=workflow_created_uuid, ), - query_string={"user": default_user.id_, "status": "start"}, + query_string={"user": user0.id_, "status": "start"}, content_type="application/json", data=json.dumps(parameters), ) @@ -1149,7 +1270,7 @@ def test_start_input_parameters( def test_start_workflow_db_failure( app, session, - default_user, + user0, user_secrets, corev1_api_client_with_user_secrets, sample_serial_workflow_in_db, @@ -1177,7 +1298,7 @@ def test_start_workflow_db_failure( "statuses.set_workflow_status", workflow_id_or_name=sample_serial_workflow_in_db.id_, ), - query_string={"user": default_user.id_, "status": "start"}, + query_string={"user": user0.id_, "status": "start"}, content_type="application/json", data=json.dumps({}), ) @@ -1187,7 +1308,7 @@ def test_start_workflow_db_failure( def test_start_workflow_kubernetes_failure( app, session, - default_user, + user0, user_secrets, corev1_api_client_with_user_secrets, sample_serial_workflow_in_db, @@ -1211,7 +1332,7 @@ def test_start_workflow_kubernetes_failure( "statuses.set_workflow_status", workflow_id_or_name=sample_serial_workflow_in_db.id_, ), - query_string={"user": default_user.id_, "status": "start"}, + query_string={"user": user0.id_, "status": "start"}, content_type="application/json", data=json.dumps({}), ) @@ -1228,9 +1349,7 @@ def test_start_workflow_kubernetes_failure( pytest.param(RunStatus.running, marks=pytest.mark.xfail(strict=True)), ], ) -def test_delete_workflow( - app, session, default_user, sample_yadage_workflow_in_db, status -): +def test_delete_workflow(app, session, user0, sample_yadage_workflow_in_db, status): """Test deletion of a workflow in all possible statuses.""" sample_yadage_workflow_in_db.status = status session.add(sample_yadage_workflow_in_db) @@ -1241,23 +1360,21 @@ def test_delete_workflow( "statuses.set_workflow_status", workflow_id_or_name=sample_yadage_workflow_in_db.id_, ), - query_string={"user": default_user.id_, "status": "deleted"}, + query_string={"user": user0.id_, "status": "deleted"}, content_type="application/json", data=json.dumps({}), ) assert sample_yadage_workflow_in_db.status == RunStatus.deleted -def test_delete_all_workflow_runs( - app, session, default_user, yadage_workflow_with_name -): +def test_delete_all_workflow_runs(app, session, user0, yadage_workflow_with_name): """Test deletion of all runs of a given workflow.""" # add 5 workflows in the database with the same name for i in range(5): workflow = Workflow( id_=uuid.uuid4(), name=yadage_workflow_with_name["name"], - owner_id=default_user.id_, + owner_id=user0.id_, reana_specification=yadage_workflow_with_name["reana_specification"], operational_options={}, type_=yadage_workflow_with_name["reana_specification"]["workflow"]["type"], @@ -1276,7 +1393,7 @@ def test_delete_all_workflow_runs( url_for( "statuses.set_workflow_status", workflow_id_or_name=first_workflow.id_ ), - query_string={"user": default_user.id_, "status": "deleted"}, + query_string={"user": user0.id_, "status": "deleted"}, content_type="application/json", data=json.dumps({"all_runs": True}), ) @@ -1290,7 +1407,7 @@ def test_delete_all_workflow_runs( def test_workspace_deletion( app, session, - default_user, + user0, yadage_workflow_with_name, tmp_shared_volume_path, workspace, @@ -1300,7 +1417,7 @@ def test_workspace_deletion( res = client.post( url_for("workflows.create_workflow"), query_string={ - "user": default_user.id_, + "user": user0.id_, "workspace_root_path": tmp_shared_volume_path, }, content_type="application/json", @@ -1329,7 +1446,7 @@ def test_workspace_deletion( url_for( "statuses.set_workflow_status", workflow_id_or_name=workflow.id_ ), - query_string={"user": default_user.id_, "status": "deleted"}, + query_string={"user": user0.id_, "status": "deleted"}, content_type="application/json", data=json.dumps({"workspace": workspace}), ) @@ -1346,14 +1463,14 @@ def test_workspace_deletion( def test_deletion_of_workspace_of_an_already_deleted_workflow( - app, session, default_user, yadage_workflow_with_name, tmp_shared_volume_path + app, session, user0, yadage_workflow_with_name, tmp_shared_volume_path ): """Test workspace deletion of an already deleted workflow.""" with app.test_client() as client: res = client.post( url_for("workflows.create_workflow"), query_string={ - "user": default_user.id_, + "user": user0.id_, "workspace_root_path": tmp_shared_volume_path, }, content_type="application/json", @@ -1374,7 +1491,7 @@ def test_deletion_of_workspace_of_an_already_deleted_workflow( url_for( "statuses.set_workflow_status", workflow_id_or_name=workflow.id_ ), - query_string={"user": default_user.id_, "status": "deleted"}, + query_string={"user": user0.id_, "status": "deleted"}, content_type="application/json", data=json.dumps({"workspace": False}), ) @@ -1386,7 +1503,7 @@ def test_deletion_of_workspace_of_an_already_deleted_workflow( def test_get_workflow_diff( app, - default_user, + user0, sample_yadage_workflow_in_db, sample_serial_workflow_in_db, tmp_shared_volume_path, @@ -1399,7 +1516,7 @@ def test_get_workflow_diff( workflow_id_or_name_a=sample_serial_workflow_in_db.id_, workflow_id_or_name_b=sample_yadage_workflow_in_db.id_, ), - query_string={"user": default_user.id_}, + query_string={"user": user0.id_}, content_type="application/json", ) assert res.status_code == 200 @@ -1436,7 +1553,7 @@ def test_get_workflow_diff( def test_get_workspace_diff( app, - default_user, + user0, sample_yadage_workflow_in_db, sample_serial_workflow_in_db, tmp_shared_volume_path, @@ -1464,7 +1581,7 @@ def test_get_workspace_diff( workflow_id_or_name_a=sample_serial_workflow_in_db.id_, workflow_id_or_name_b=sample_yadage_workflow_in_db.id_, ), - query_string={"user": default_user.id_}, + query_string={"user": user0.id_}, content_type="application/json", ) assert res.status_code == 200 @@ -1472,7 +1589,7 @@ def test_get_workspace_diff( assert "# File" in response_data["workspace_listing"] -def test_create_interactive_session(app, default_user, sample_serial_workflow_in_db): +def test_create_interactive_session(app, user0, sample_serial_workflow_in_db): """Test create interactive session.""" wrm = WorkflowRunManager(sample_serial_workflow_in_db) expected_data = {"path": wrm._generate_interactive_workflow_path()} @@ -1490,13 +1607,13 @@ def test_create_interactive_session(app, default_user, sample_serial_workflow_in workflow_id_or_name=sample_serial_workflow_in_db.id_, interactive_session_type="jupyter", ), - query_string={"user": default_user.id_}, + query_string={"user": user0.id_}, ) assert res.json == expected_data def test_create_interactive_session_unknown_type( - app, default_user, sample_serial_workflow_in_db + app, user0, sample_serial_workflow_in_db ): """Test create interactive session for unknown interactive type.""" with app.test_client() as client: @@ -1507,13 +1624,13 @@ def test_create_interactive_session_unknown_type( workflow_id_or_name=sample_serial_workflow_in_db.id_, interactive_session_type="terminl", ), - query_string={"user": default_user.id_}, + query_string={"user": user0.id_}, ) assert res.status_code == 404 def test_create_interactive_session_custom_image( - app, default_user, sample_serial_workflow_in_db + app, user0, sample_serial_workflow_in_db ): """Create an interactive session with custom image.""" custom_image = "test/image" @@ -1532,7 +1649,7 @@ def test_create_interactive_session_custom_image( workflow_id_or_name=sample_serial_workflow_in_db.id_, interactive_session_type="jupyter", ), - query_string={"user": default_user.id_}, + query_string={"user": user0.id_}, content_type="application/json", data=json.dumps(interactive_session_configuration), ) @@ -1542,9 +1659,7 @@ def test_create_interactive_session_custom_image( assert fargs[1].spec.template.spec.containers[0].image == custom_image -def test_close_interactive_session( - app, session, default_user, sample_serial_workflow_in_db -): +def test_close_interactive_session(app, session, user0, sample_serial_workflow_in_db): """Test close an interactive session.""" expected_data = {"message": "The interactive session has been closed"} path = "/5d9b30fd-f225-4615-9107-b1373afec070" @@ -1566,14 +1681,14 @@ def test_close_interactive_session( "workflows_session.close_interactive_session", workflow_id_or_name=sample_serial_workflow_in_db.id_, ), - query_string={"user": default_user.id_}, + query_string={"user": user0.id_}, content_type="application/json", ) assert res.json == expected_data def test_close_interactive_session_not_opened( - app, session, default_user, sample_serial_workflow_in_db + app, session, user0, sample_serial_workflow_in_db ): """Test close an interactive session when session is not opened.""" expected_data = { @@ -1590,7 +1705,7 @@ def test_close_interactive_session_not_opened( "workflows_session.close_interactive_session", workflow_id_or_name=sample_serial_workflow_in_db.id_, ), - query_string={"user": default_user.id_}, + query_string={"user": user0.id_}, content_type="application/json", ) assert res.json == expected_data @@ -1633,7 +1748,7 @@ def test_get_workflow_retention_rules_no_rules(app, sample_serial_workflow_in_db assert res.json["retention_rules"] == [] -def test_get_workflow_retention_rules_invalid_workflow(app, default_user): +def test_get_workflow_retention_rules_invalid_workflow(app, user0): """Test get_workflow_retention_rules for invalid workflow.""" with app.test_client() as client: res = client.get( @@ -1641,7 +1756,7 @@ def test_get_workflow_retention_rules_invalid_workflow(app, default_user): "workflows.get_workflow_retention_rules", workflow_id_or_name="invalid_name", ), - query_string={"user": default_user.id_}, + query_string={"user": user0.id_}, ) assert res.status_code == 404 assert b"invalid_name" in res.data @@ -1659,3 +1774,751 @@ def test_get_workflow_retention_rules_invalid_user(app, sample_serial_workflow_i query_string={"user": uuid.uuid4()}, ) assert res.status_code == 404 + + +def test_share_workflow(app, user1, user2, sample_serial_workflow_in_db_owned_by_user1): + """Test share workflow.""" + workflow = sample_serial_workflow_in_db_owned_by_user1 + with app.test_client() as client: + res = client.post( + url_for( + "workflows.share_workflow", + workflow_id_or_name=str(workflow.id_), + ), + query_string={ + "user_id": str(user1.id_), + "user_email_to_share_with": user2.email, + }, + ) + assert res.status_code == 200 + response_data = res.get_json() + assert response_data["message"] == "The workflow has been shared with the user." + + +def test_share_workflow_with_message_and_valid_until( + app, user1, user2, sample_serial_workflow_in_db_owned_by_user1 +): + """Test share workflow with a message and a valid until date.""" + workflow = sample_serial_workflow_in_db_owned_by_user1 + message = "This is a shared workflow with a message." + valid_until = "2023-12-31" + with app.test_client() as client: + res = client.post( + url_for( + "workflows.share_workflow", + workflow_id_or_name=str(workflow.id_), + ), + query_string={ + "user_id": str(user1.id_), + "user_email_to_share_with": user2.email, + "message": message, + "valid_until": valid_until, + }, + ) + assert res.status_code == 200 + response_data = res.get_json() + assert response_data["message"] == "The workflow has been shared with the user." + + +def test_share_workflow_invalid_email( + app, user2, sample_serial_workflow_in_db_owned_by_user1 +): + """Test share workflow with invalid email format.""" + workflow = sample_serial_workflow_in_db_owned_by_user1 + invalid_emails = [ + "invalid_email", + "invalid_email@", + "@invalid_email.com", + "invalid_email.com", + "invalid@ email.com", # Contains a space + "invalid email@domain.com", # Contains a space + "invalid_email@.com", # Empty domain + "invalid_email@com.", # Empty top-level domain + "invalid_email@com", # Missing top-level domain + "invalid_email@com.", # Extra dot in top-level domain + ] + + with app.test_client() as client: + for invalid_email in invalid_emails: + res = client.post( + url_for( + "workflows.share_workflow", + workflow_id_or_name=str(workflow.id_), + ), + query_string={ + "user_id": str(user2.id_), + "user_email_to_share_with": invalid_email, + }, + ) + assert res.status_code == 400 + response_data = res.get_json() + assert ( + response_data["message"] + == f"User email '{invalid_email}' is not valid." + ) + + +def test_share_workflow_with_valid_email_but_unexisting_user( + app, user1, user2, sample_serial_workflow_in_db_owned_by_user1 +): + """Test share workflow with valid email but unexisting user.""" + workflow = sample_serial_workflow_in_db_owned_by_user1 + valid_emails = [ + "valid_email@example.com", + "another_valid_email@test.org", + "john.doe@email-domain.net", + "alice.smith@sub.domain.co.uk", + "user2234@gmail.com", + "admin@company.com", + "support@website.org", + "marketing@example.net", + "jane_doe@sub.example.co", + "user.name@sub.domain.co.uk", + ] + + with app.test_client() as client: + for valid_email in valid_emails: + res = client.post( + url_for( + "workflows.share_workflow", + workflow_id_or_name=str(workflow.id_), + ), + query_string={ + "user_id": str(user1.id_), + "user_email_to_share_with": valid_email, + }, + ) + assert res.status_code == 404 + response_data = res.get_json() + assert ( + response_data["message"] + == f"User with email '{valid_email}' does not exist." + ) + + +def test_share_workflow_with_invalid_date_format( + app, user1, user2, sample_serial_workflow_in_db_owned_by_user1 +): + """Test share workflow with an invalid date format for 'valid_until'.""" + workflow = sample_serial_workflow_in_db_owned_by_user1 + invalid_date = "2023/12/31" # Invalid format + with app.test_client() as client: + res = client.post( + url_for( + "workflows.share_workflow", + workflow_id_or_name=str(workflow.id_), + ), + query_string={ + "user_id": str(user1.id_), + "user_email_to_share_with": user2.email, + "valid_until": invalid_date, + }, + ) + assert res.status_code == 400 + response_data = res.get_json() + assert ( + response_data["message"] + == "Date format is not valid (Invalid isoformat string: '2023/12/31'). Please use YYYY-MM-DD format." + ) + + +def test_share_non_existent_workflow(app, user1, user2): + """Test sharing a non-existent workflow.""" + non_existent_workflow_id = "non_existent_workflow_id" + with app.test_client() as client: + res = client.post( + url_for( + "workflows.share_workflow", + workflow_id_or_name=non_existent_workflow_id, + ), + query_string={ + "user_id": str(user1.id_), + "user_email_to_share_with": user2.email, + }, + ) + assert res.status_code == 400 + response_data = res.get_json() + assert ( + response_data["message"] + == f"REANA_WORKON is set to {non_existent_workflow_id}, but that workflow does not exist. Please set your REANA_WORKON environment variable appropriately." + ) + + +def test_share_workflow_with_self( + app, user1, sample_serial_workflow_in_db_owned_by_user1 +): + """Test attempting to share a workflow with yourself.""" + workflow = sample_serial_workflow_in_db_owned_by_user1 + with app.test_client() as client: + res = client.post( + url_for( + "workflows.share_workflow", + workflow_id_or_name=str(workflow.id_), + ), + query_string={ + "user_id": str(user1.id_), + "user_email_to_share_with": user1.email, + }, + ) + assert res.status_code == 400 + response_data = res.get_json() + assert response_data["message"] == "Unable to share a workflow with yourself." + + +def test_share_workflow_already_shared( + app, user1, user2, sample_serial_workflow_in_db_owned_by_user1 +): + """Test attempting to share a workflow that is already shared with the user.""" + workflow = sample_serial_workflow_in_db_owned_by_user1 + with app.test_client() as client: + client.post( + url_for( + "workflows.share_workflow", + workflow_id_or_name=str(workflow.id_), + ), + query_string={ + "user_id": str(user1.id_), + "user_email_to_share_with": user2.email, + }, + ) + + # Attempt to share the same workflow again + with app.test_client() as client: + res = client.post( + url_for( + "workflows.share_workflow", + workflow_id_or_name=str(workflow.id_), + ), + query_string={ + "user_id": str(user1.id_), + "user_email_to_share_with": user2.email, + }, + ) + assert res.status_code == 409 + response_data = res.get_json() + assert ( + response_data["message"] + == f"{workflow.get_full_workflow_name()} is already shared with {user2.email}." + ) + + +def test_share_workflow_with_past_valid_until_date( + app, user1, user2, sample_serial_workflow_in_db_owned_by_user1 +): + """Test share workflow with a 'valid_until' date in the past.""" + workflow = sample_serial_workflow_in_db_owned_by_user1 + past_date = "2021-01-01" # A date in the past + with app.test_client() as client: + res = client.post( + url_for( + "workflows.share_workflow", + workflow_id_or_name=str(workflow.id_), + ), + query_string={ + "user_id": str(user1.id_), + "user_email_to_share_with": user2.email, + "valid_until": past_date, + }, + ) + assert res.status_code == 400 + response_data = res.get_json() + assert ( + response_data["message"] == "The 'valid_until' date cannot be in the past." + ) + + +def test_share_workflow_with_long_message( + app, user1, user2, sample_serial_workflow_in_db_owned_by_user1 +): + """Test share workflow with a message exceeding 5000 characters.""" + workflow = sample_serial_workflow_in_db_owned_by_user1 + long_message = "A" * 5001 # A message exceeding the 5000-character limit + with app.test_client() as client: + res = client.post( + url_for( + "workflows.share_workflow", + workflow_id_or_name=str(workflow.id_), + ), + query_string={ + "user_id": str(user1.id_), + "user_email_to_share_with": user2.email, + "message": long_message, + }, + ) + assert res.status_code == 400 + response_data = res.get_json() + assert ( + response_data["message"] + == "Message is too long. Please keep it under 5000 characters." + ) + + +def test_share_multiple_workflows( + app, + user1, + user2, + sample_serial_workflow_in_db_owned_by_user1, + sample_yadage_workflow_in_db_owned_by_user1, +): + """Test sharing multiple workflows with different users.""" + workflow1 = sample_serial_workflow_in_db_owned_by_user1 + with app.test_client() as client: + res = client.post( + url_for( + "workflows.share_workflow", + workflow_id_or_name=str(workflow1.id_), + ), + query_string={ + "user_id": str(user1.id_), + "user_email_to_share_with": user2.email, + }, + ) + assert res.status_code == 200 + response_data = res.get_json() + assert response_data["message"] == "The workflow has been shared with the user." + + workflow2 = sample_yadage_workflow_in_db_owned_by_user1 + with app.test_client() as client: + res = client.post( + url_for( + "workflows.share_workflow", + workflow_id_or_name=str(workflow2.id_), + ), + query_string={ + "user_id": str(user1.id_), + "user_email_to_share_with": user2.email, + }, + ) + assert res.status_code == 200 + response_data = res.get_json() + assert response_data["message"] == "The workflow has been shared with the user." + + +def test_unshare_workflow( + app, user1, user2, sample_serial_workflow_in_db_owned_by_user1 +): + """Test unshare workflow.""" + workflow = sample_serial_workflow_in_db_owned_by_user1 + with app.test_client() as client: + # share workflow + client.post( + url_for( + "workflows.share_workflow", + workflow_id_or_name=str(workflow.id_), + ), + query_string={ + "user_id": str(user1.id_), + "user_email_to_share_with": user2.email, + }, + ) + # unshare workflow + res = client.post( + url_for( + "workflows.unshare_workflow", + workflow_id_or_name=str(workflow.id_), + ), + query_string={ + "user_id": str(user1.id_), + "user_email_to_unshare_with": user2.email, + }, + ) + assert res.status_code == 200 + response_data = res.get_json() + assert ( + response_data["message"] == "The workflow has been unshared with the user." + ) + + +def test_unshare_workflow_not_shared( + app, user1, user2, sample_serial_workflow_in_db_owned_by_user1 +): + """Test unshare workflow that is not shared with the user.""" + workflow = sample_serial_workflow_in_db_owned_by_user1 + with app.test_client() as client: + # unshare workflow + res = client.post( + url_for( + "workflows.unshare_workflow", + workflow_id_or_name=str(workflow.id_), + ), + query_string={ + "user_id": str(user1.id_), + "user_email_to_unshare_with": user2.email, + }, + ) + assert res.status_code == 409 + response_data = res.get_json() + assert ( + response_data["message"] + == f"{workflow.get_full_workflow_name()} is not shared with {user2.email}." + ) + + +def test_unshare_workflow_with_self( + app, user1, sample_serial_workflow_in_db_owned_by_user1 +): + """Test attempting to unshare a workflow with yourself.""" + workflow = sample_serial_workflow_in_db_owned_by_user1 + with app.test_client() as client: + res = client.post( + url_for( + "workflows.unshare_workflow", + workflow_id_or_name=str(workflow.id_), + ), + query_string={ + "user_id": str(user1.id_), + "user_email_to_unshare_with": user1.email, + }, + ) + assert res.status_code == 400 + response_data = res.get_json() + assert response_data["message"] == "Unable to unshare a workflow with yourself." + + +def test_unshare_workflow_with_invalid_email( + app, user1, user2, sample_serial_workflow_in_db_owned_by_user1 +): + """Test unshare workflow with invalid email format.""" + workflow = sample_serial_workflow_in_db_owned_by_user1 + invalid_emails = [ + "invalid_email", + "invalid_email@", + "@invalid_email.com", + "invalid_email.com", + "invalid@ email.com", # Contains a space + "invalid @email", # Contains a space + "invalid_email@.com", # Empty domain + "invalid_email@com.", # Empty top-level domain + "invalid_email@com", # Missing top-level domain + "invalid_email@com.", # Extra dot in top-level domain + ] + + with app.test_client() as client: + for invalid_email in invalid_emails: + res = client.post( + url_for( + "workflows.unshare_workflow", + workflow_id_or_name=str(workflow.id_), + ), + query_string={ + "user_id": str(user1.id_), + "user_email_to_unshare_with": invalid_email, + }, + ) + assert res.status_code == 400 + response_data = res.get_json() + assert ( + response_data["message"] + == f"User email '{invalid_email}' is not valid." + ) + + +def test_unshare_workflow_with_valid_email_but_unexisting_user( + app, user1, user2, sample_serial_workflow_in_db_owned_by_user1 +): + """Test unshare workflow with valid email but unexisting user.""" + workflow = sample_serial_workflow_in_db_owned_by_user1 + valid_emails = [ + "valid_email@example.com", + "another_valid_email@test.org", + "john.doe@email-domain.net", + "alice.smith@sub.domain.co.uk", + "user2234@gmail.com", + "admin@company.com", + "support@website.org", + "marketing@example.net", + "jane_doe@sub.example.co", + "user.name@sub.domain.co.uk", + ] + + with app.test_client() as client: + for valid_email in valid_emails: + res = client.post( + url_for( + "workflows.unshare_workflow", + workflow_id_or_name=str(workflow.id_), + ), + query_string={ + "user_id": str(user1.id_), + "user_email_to_unshare_with": valid_email, + }, + ) + assert res.status_code == 404 + response_data = res.get_json() + assert ( + response_data["message"] + == f"User with email '{valid_email}' does not exist." + ) + + +def test_unshare_non_existent_workflow(app, user1, user2): + """Test unsharing a non-existent workflow.""" + non_existent_workflow_id = "non_existent_workflow_id" + with app.test_client() as client: + res = client.post( + url_for( + "workflows.unshare_workflow", + workflow_id_or_name=non_existent_workflow_id, + ), + query_string={ + "user_id": str(user1.id_), + "user_email_to_unshare_with": user2.email, + }, + ) + assert res.status_code == 400 + response_data = res.get_json() + assert ( + response_data["message"] + == f"REANA_WORKON is set to {non_existent_workflow_id}, but that workflow does not exist. Please set your REANA_WORKON environment variable appropriately." + ) + + +def test_unshare_workflow_already_unshared( + app, user1, user2, sample_serial_workflow_in_db_owned_by_user1 +): + """Test unsharing a workflow that is already unshared with the user.""" + workflow = sample_serial_workflow_in_db_owned_by_user1 + with app.test_client() as client: + # unshare workflow + res = client.post( + url_for( + "workflows.unshare_workflow", + workflow_id_or_name=str(workflow.id_), + ), + query_string={ + "user_id": str(user1.id_), + "user_email_to_unshare_with": user2.email, + }, + ) + assert res.status_code == 409 + response_data = res.get_json() + assert ( + response_data["message"] + == f"{workflow.get_full_workflow_name()} is not shared with {user2.email}." + ) + + +def test_unshare_multiple_workflows( + app, + user1, + user2, + sample_serial_workflow_in_db_owned_by_user1, + sample_yadage_workflow_in_db_owned_by_user1, +): + """Test unsharing multiple workflows with different users.""" + workflow1 = sample_serial_workflow_in_db_owned_by_user1 + with app.test_client() as client: + # share workflow + client.post( + url_for( + "workflows.share_workflow", + workflow_id_or_name=str(workflow1.id_), + ), + query_string={ + "user_id": str(user1.id_), + "user_email_to_share_with": user2.email, + }, + ) + # unshare workflow + res = client.post( + url_for( + "workflows.unshare_workflow", + workflow_id_or_name=str(workflow1.id_), + ), + query_string={ + "user_id": str(user1.id_), + "user_email_to_unshare_with": user2.email, + }, + ) + assert res.status_code == 200 + response_data = res.get_json() + assert ( + response_data["message"] == "The workflow has been unshared with the user." + ) + + workflow2 = sample_yadage_workflow_in_db_owned_by_user1 + with app.test_client() as client: + # share workflow + client.post( + url_for( + "workflows.share_workflow", + workflow_id_or_name=str(workflow2.id_), + ), + query_string={ + "user_id": str(user1.id_), + "user_email_to_share_with": user2.email, + }, + ) + # unshare workflow + res = client.post( + url_for( + "workflows.unshare_workflow", + workflow_id_or_name=str(workflow2.id_), + ), + query_string={ + "user_id": str(user1.id_), + "user_email_to_unshare_with": user2.email, + }, + ) + assert res.status_code == 200 + response_data = res.get_json() + assert ( + response_data["message"] == "The workflow has been unshared with the user." + ) + + +def test_unshare_workflow_with_message_and_valid_until( + app, user1, user2, sample_serial_workflow_in_db_owned_by_user1 +): + """Test unshare workflow with a message and a valid until date.""" + workflow = sample_serial_workflow_in_db_owned_by_user1 + message = "This is a shared workflow with a message." + valid_until = "2023-12-31" + with app.test_client() as client: + # share workflow + client.post( + url_for( + "workflows.share_workflow", + workflow_id_or_name=str(workflow.id_), + ), + query_string={ + "user_id": str(user1.id_), + "user_email_to_share_with": user2.email, + "message": message, + "valid_until": valid_until, + }, + ) + # unshare workflow + res = client.post( + url_for( + "workflows.unshare_workflow", + workflow_id_or_name=str(workflow.id_), + ), + query_string={ + "user_id": str(user1.id_), + "user_email_to_unshare_with": user2.email, + }, + ) + assert res.status_code == 200 + response_data = res.get_json() + assert ( + response_data["message"] == "The workflow has been unshared with the user." + ) + + +def test_get_workflow_share_status( + app, user1, user2, sample_serial_workflow_in_db_owned_by_user1 +): + """Test get_workflow_share_status.""" + workflow = sample_serial_workflow_in_db_owned_by_user1 + with app.test_client() as client: + # share workflow + client.post( + url_for( + "workflows.share_workflow", + workflow_id_or_name=str(workflow.id_), + ), + query_string={ + "user_id": str(user1.id_), + "user_email_to_share_with": user2.email, + }, + ) + # get workflow share status + res = client.get( + url_for( + "workflows.get_workflow_share_status", + workflow_id_or_name=str(workflow.id_), + ), + query_string={ + "user_id": str(user1.id_), + "user_email_to_check": user2.email, + }, + ) + assert res.status_code == 200 + response_data = res.get_json() + assert response_data["shared_with"][0]["user_email"] == "user2@reana.io" + + +def test_get_workflow_share_status_not_shared( + app, user1, user2, sample_serial_workflow_in_db_owned_by_user1 +): + """Test get_workflow_share_status for a workflow that is not shared.""" + workflow = sample_serial_workflow_in_db_owned_by_user1 + with app.test_client() as client: + # get workflow share status + res = client.get( + url_for( + "workflows.get_workflow_share_status", + workflow_id_or_name=str(workflow.id_), + ), + query_string={ + "user_id": str(user1.id_), + }, + ) + assert res.status_code == 200 + response_data = res.get_json() + assert response_data["shared_with"] == [] + + +def test_get_workflow_share_status_valid_until_not_set( + app, user1, user2, sample_serial_workflow_in_db_owned_by_user1 +): + workflow = sample_serial_workflow_in_db_owned_by_user1 + with app.test_client() as client: + # Share the workflow without setting valid_until + client.post( + url_for( + "workflows.share_workflow", + workflow_id_or_name=str(workflow.id_), + ), + query_string={ + "user_id": str(user1.id_), + "user_email_to_share_with": user2.email, + }, + ) + res = client.get( + url_for( + "workflows.get_workflow_share_status", + workflow_id_or_name=str(workflow.id_), + ), + query_string={ + "user_id": str(user1.id_), + }, + ) + assert res.status_code == 200 + response_data = res.get_json() + shared_with = response_data["shared_with"][0] + assert shared_with["valid_until"] is None + + +def test_get_workflow_share_status_valid_until_set( + app, user1, user2, sample_serial_workflow_in_db_owned_by_user1 +): + workflow = sample_serial_workflow_in_db_owned_by_user1 + valid_until = "2023-12-31" + with app.test_client() as client: + # Share the workflow setting valid_until + client.post( + url_for( + "workflows.share_workflow", + workflow_id_or_name=str(workflow.id_), + ), + query_string={ + "user_id": str(user1.id_), + "user_email_to_share_with": user2.email, + "valid_until": valid_until, + }, + ) + res = client.get( + url_for( + "workflows.get_workflow_share_status", + workflow_id_or_name=str(workflow.id_), + ), + query_string={ + "user_id": str(user1.id_), + }, + ) + assert res.status_code == 200 + response_data = res.get_json() + shared_with = response_data["shared_with"][0] + assert shared_with["valid_until"] == valid_until + "T00:00:00"