diff --git a/app/controllers/api/service/base_controller.rb b/app/controllers/api/service/base_controller.rb new file mode 100644 index 000000000..93dc0fa74 --- /dev/null +++ b/app/controllers/api/service/base_controller.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +module Api + module Service + class BaseController < ApiController + class TypeError < StandardError; end + + class PermissionError < StandardError + attr_reader :klass, :mode + + def initialize(klass, mode) + @klass = klass + @mode = mode + end + end + + rescue_from StandardError do |e| + logger.error e.message + logger.error e.backtrace.join("\n") + render_error(I18n.t('api.core.errors.general.title'), + I18n.t('api.core.errors.general.detail'), + :bad_request) + end + + rescue_from PermissionError do |e| + model = e.klass.name.underscore + render_error( + I18n.t("api.core.errors.#{e.mode}_permission.title"), + I18n.t("api.core.errors.#{e.mode}_permission.detail", model: model), + :forbidden + ) + end + + rescue_from ActionController::ParameterMissing do |e| + render_error( + I18n.t('api.core.errors.parameter.title'), e.message, :bad_request + ) + end + + rescue_from ActiveRecord::RecordNotFound do |e| + render_error( + I18n.t('api.core.errors.record_not_found.title'), + I18n.t('api.core.errors.record_not_found.detail', + model: e.model, + id: e.id), + :not_found + ) + end + + rescue_from ActiveRecord::RecordInvalid do |e| + render_error( + I18n.t('api.core.errors.validation.title'), e.message, :bad_request + ) + end + + rescue_from JWT::DecodeError, + JWT::InvalidPayload, + JWT::VerificationError, + JWT::ExpiredSignature do |e| + render_error( + I18n.t('api.core.invalid_token'), e.message, :unauthorized + ) + end + + private + + def load_team + @team = current_user.teams.find(params.require(:team_id)) + raise PermissionError.new(Team, :read) unless can_read_team?(@team) + end + + def render_error(title, message, status) + logger.error message + render json: { + errors: [ + { + id: request.uuid, + status: Rack::Utils.status_code(status), + title: title, + detail: message + } + ] + }, status: status + end + end + end +end diff --git a/app/controllers/api/service/experiments_controller.rb b/app/controllers/api/service/experiments_controller.rb new file mode 100644 index 000000000..3c751bd17 --- /dev/null +++ b/app/controllers/api/service/experiments_controller.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Api + module Service + class ExperimentsController < BaseController + before_action :load_team + + def clone + @project = @team.projects.find(params.require(:clone_experiment).require(:to_project_id)) + raise PermissionError.new(Project, :create_project_experiments) unless can_create_project_experiments?(@project) + + @experiment = Experiment.find(params.require(:clone_experiment).require(:experiment_id)) + raise PermissionError.new(Experiment, :manage) unless can_clone_experiment?(@experiment) + + service = Experiments::CopyExperimentAsTemplateService.call(experiment: @experiment, + project: @project, + user: current_user) + + if service.succeed? + render jsonapi: service.cloned_experiment, serializer: Api::V1::ExperimentSerializer + else + render json: service.errors, status: :error + end + end + end + end +end diff --git a/app/controllers/api/v1/base_controller.rb b/app/controllers/api/v1/base_controller.rb index 2baf0ebd8..c58f8646f 100644 --- a/app/controllers/api/v1/base_controller.rb +++ b/app/controllers/api/v1/base_controller.rb @@ -4,8 +4,11 @@ module Api module V1 class BaseController < ApiController class TypeError < StandardError; end + class IDMismatchError < StandardError; end + class IncludeNotSupportedError < StandardError; end + class PermissionError < StandardError attr_reader :klass, :mode diff --git a/app/controllers/experiments_controller.rb b/app/controllers/experiments_controller.rb index 9e62ca9f2..1cc19bab3 100644 --- a/app/controllers/experiments_controller.rb +++ b/app/controllers/experiments_controller.rb @@ -200,10 +200,12 @@ class ExperimentsController < ApplicationController # POST: clone_experiment(id) def clone - service = Experiments::CopyExperimentAsTemplateService - .call(experiment_id: @experiment.id, - project_id: move_experiment_param, - user_id: current_user.id) + project = current_team.projects.find(move_experiment_param) + return render_403 unless can_create_project_experiments?(project) + + service = Experiments::CopyExperimentAsTemplateService.call(experiment: @experiment, + project: project, + user: current_user) if service.succeed? flash[:success] = t('experiments.clone.success_flash', diff --git a/app/services/experiments/copy_experiment_as_template_service.rb b/app/services/experiments/copy_experiment_as_template_service.rb index 8eab8e08f..be4c3b2ed 100644 --- a/app/services/experiments/copy_experiment_as_template_service.rb +++ b/app/services/experiments/copy_experiment_as_template_service.rb @@ -7,11 +7,11 @@ module Experiments attr_reader :errors, :c_exp alias cloned_experiment c_exp - def initialize(experiment_id:, project_id:, user_id:) - @exp = Experiment.find experiment_id - @project = Project.find project_id - @user = User.find user_id - @original_project = @exp&.project + def initialize(experiment:, project:, user:) + @exp = experiment + @project = project + @user = user + @original_project = @exp.project @c_exp = nil @errors = {} end @@ -66,23 +66,16 @@ module Experiments def valid? unless @exp && @project && @user @errors[:invalid_arguments] = - { 'experiment': @exp, - 'project': @project, - 'user': @user } + { experiment: @exp, + project: @project, + user: @user } .map do |key, value| "Can't find #{key.capitalize}" if value.nil? end.compact - return false - end - - if @exp.project.team.projects - .with_user_permission(@user, ProjectPermissions::EXPERIMENTS_CREATE).include?(@project) - true - else - @errors[:user_without_permissions] = - ['You are not allowed to copy this experiment to this project'] false end + + true end def track_activity diff --git a/config/routes.rb b/config/routes.rb index 98c81b443..6ca2b1df2 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -668,6 +668,11 @@ Rails.application.routes.draw do namespace :api, defaults: { format: 'json' } do get 'health', to: 'api#health' get 'status', to: 'api#status' + namespace :service do + resources :teams, except: %i(index new create show edit update destroy) do + post 'clone_experiment' => 'experiments#clone' + end + end if Rails.configuration.x.core_api_v1_enabled namespace :v1 do resources :teams, only: %i(index show) do diff --git a/spec/requests/api/service/experiments_controller_spec.rb b/spec/requests/api/service/experiments_controller_spec.rb new file mode 100644 index 000000000..714769c4e --- /dev/null +++ b/spec/requests/api/service/experiments_controller_spec.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe "Api::Service::ExperimentsController", type: :request do + before :all do + @user = create(:user) + @team = create(:team, created_by: @user) + create(:user_team, user: @user, team: @team, role: 2) + + @valid_project = create(:project, name: Faker::Name.unique.name, created_by: @user, team: @team) + + create(:user_assignment, + assignable: @valid_project, + user: @user, + user_role: UserRole.find_by(name: I18n.t('user_roles.predefined.owner')), + assigned_by: @user) + + @unaccessible_project = create(:project, name: Faker::Name.unique.name, created_by: @user, team: @team) + @unaccessible_project.user_assignments.destroy_all + + @experiment = create(:experiment, created_by: @user, last_modified_by: @user, project: @valid_project) + + @valid_headers = + { 'Authorization': 'Bearer ' + generate_token(@user.id) } + end + + describe 'POST clone experiment, #clone' do + before :all do + @valid_headers['Content-Type'] = 'application/json' + end + + let(:action) do + post( + api_service_team_clone_experiment_path(team_id: @valid_project.team.id), + params: request_body.to_json, + headers: @valid_headers + ) + end + + context 'when has valid params' do + let(:request_body) do + { + clone_experiment: { + experiment_id: @experiment.id, + to_project_id: @valid_project.id + } + } + end + + it 'creates new experiment' do + expect { action }.to change { Experiment.count }.by(1) + end + + it 'returns status 200' do + action + expect(response).to have_http_status 200 + end + end + + context 'when has missing param' do + let(:request_body) do + { + clone_experiment: { + experiment_id: @experiment.id + } + } + end + + it 'renders 400' do + action + + expect(response).to have_http_status(400) + end + end + + context 'when has wrong project' do + let(:request_body) do + { + clone_experiment: { + experiment_id: @experiment.id, + to_project_id: @unaccessible_project.id + } + } + end + + it 'renders 403' do + action + + expect(response).to have_http_status(403) + end + end + end +end diff --git a/spec/services/experiments/copy_experiment_as_template_service_sepc.rb b/spec/services/experiments/copy_experiment_as_template_service_sepc.rb index 316e0940a..217f3ccca 100644 --- a/spec/services/experiments/copy_experiment_as_template_service_sepc.rb +++ b/spec/services/experiments/copy_experiment_as_template_service_sepc.rb @@ -17,10 +17,7 @@ describe Experiments::CopyExperimentAsTemplateService do end let(:user) { create :user } let(:service_call) do - Experiments::CopyExperimentAsTemplateService - .call(experiment_id: experiment.id, - project_id: new_project.id, - user_id: user.id) + Experiments::CopyExperimentAsTemplateService.call(experiment: experiment, project: new_project, user: user) end context 'when service call is successful' do