From 46ff35da2f216f7158a06ff00bcca8115a88cc22 Mon Sep 17 00:00:00 2001 From: Daan Rosendal Date: Wed, 8 Nov 2023 23:28:56 +0100 Subject: [PATCH] rest: add unshare_workflow endpoint Adds a new endpoint to unshare a workflow. Closes reanahub/reana-client#681 --- docs/openapi.json | 177 +++++++++++ reana_workflow_controller/rest/workflows.py | 216 ++++++++++++++ tests/test_views.py | 306 ++++++++++++++++++++ 3 files changed, 699 insertions(+) diff --git a/docs/openapi.json b/docs/openapi.json index bce28f6f..f66dce6b 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -1418,6 +1418,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 0712290a..fcb3d82b 100644 --- a/reana_workflow_controller/rest/workflows.py +++ b/reana_workflow_controller/rest/workflows.py @@ -1146,3 +1146,219 @@ def share_workflow( 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 diff --git a/tests/test_views.py b/tests/test_views.py index 4191ecaa..bce43b91 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -1950,3 +1950,309 @@ def test_share_multiple_workflows( 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, user0, user1, sample_serial_workflow_in_db): + """Test unshare workflow.""" + workflow = sample_serial_workflow_in_db + 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(user0.id_), + "user_email_to_share_with": user1.email, + }, + ) + # unshare workflow + res = client.post( + url_for( + "workflows.unshare_workflow", + workflow_id_or_name=str(workflow.id_), + ), + query_string={ + "user_id": str(user0.id_), + "user_email_to_unshare_with": user1.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, user0, user1, sample_serial_workflow_in_db): + """Test unshare workflow that is not shared with the user.""" + workflow = sample_serial_workflow_in_db + 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(user0.id_), + "user_email_to_unshare_with": user1.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 {user1.email}." + ) + + +def test_unshare_workflow_with_self(app, user0, sample_serial_workflow_in_db): + """Test attempting to unshare a workflow with yourself.""" + workflow = sample_serial_workflow_in_db + 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(user0.id_), + "user_email_to_unshare_with": user0.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, user0, user1, sample_serial_workflow_in_db +): + """Test unshare workflow with invalid email format.""" + workflow = sample_serial_workflow_in_db + 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(user0.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, user0, user1, sample_serial_workflow_in_db +): + """Test unshare workflow with valid email but unexisting user.""" + workflow = sample_serial_workflow_in_db + valid_emails = [ + "valid_email@example.com", + "another_valid_email@test.org", + "john.doe@email-domain.net", + "alice.smith@sub.domain.co.uk", + "user1234@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(user0.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, user0, user1): + """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(user0.id_), + "user_email_to_unshare_with": user1.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, user0, user1, sample_serial_workflow_in_db +): + """Test unsharing a workflow that is already unshared with the user.""" + workflow = sample_serial_workflow_in_db + 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(user0.id_), + "user_email_to_unshare_with": user1.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 {user1.email}." + ) + + +def test_unshare_multiple_workflows( + app, + user0, + user1, + sample_serial_workflow_in_db, + sample_yadage_workflow_in_db, +): + """Test unsharing multiple workflows with different users.""" + workflow1 = sample_serial_workflow_in_db + 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(user0.id_), + "user_email_to_share_with": user1.email, + }, + ) + # unshare workflow + res = client.post( + url_for( + "workflows.unshare_workflow", + workflow_id_or_name=str(workflow1.id_), + ), + query_string={ + "user_id": str(user0.id_), + "user_email_to_unshare_with": user1.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 + 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(user0.id_), + "user_email_to_share_with": user1.email, + }, + ) + # unshare workflow + res = client.post( + url_for( + "workflows.unshare_workflow", + workflow_id_or_name=str(workflow2.id_), + ), + query_string={ + "user_id": str(user0.id_), + "user_email_to_unshare_with": user1.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, user0, user1, sample_serial_workflow_in_db +): + """Test unshare workflow with a message and a valid until date.""" + workflow = sample_serial_workflow_in_db + 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(user0.id_), + "user_email_to_share_with": user1.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(user0.id_), + "user_email_to_unshare_with": user1.email, + }, + ) + assert res.status_code == 200 + response_data = res.get_json() + assert ( + response_data["message"] == "The workflow has been unshared with the user." + )