diff --git a/Gemfile b/Gemfile index cb2a7d483..69896c150 100644 --- a/Gemfile +++ b/Gemfile @@ -6,6 +6,7 @@ git_source(:github) { |repo| "https://github.com/#{repo}.git" } ruby '3.0.3' gem 'bootsnap', require: false +gem 'cancancan', '~> 3.3' gem 'faraday' gem 'importmap-rails' gem 'jbuilder' diff --git a/Gemfile.lock b/Gemfile.lock index 2ac248da0..affd4caa7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -67,6 +67,7 @@ GEM msgpack (~> 1.0) builder (3.2.4) byebug (11.1.3) + cancancan (3.3.0) coderay (1.1.3) concurrent-ruby (1.1.9) crack (0.4.5) @@ -250,6 +251,7 @@ PLATFORMS DEPENDENCIES bootsnap + cancancan (~> 3.3) dotenv-rails factory_bot_rails faker diff --git a/app/concepts/project/operation/create_remix.rb b/app/concepts/project/operation/create_remix.rb index cb617cec5..22a7c3818 100644 --- a/app/concepts/project/operation/create_remix.rb +++ b/app/concepts/project/operation/create_remix.rb @@ -19,12 +19,12 @@ class << self private def validate_params(response, params, user_id) - valid = params[:phrase_id].present? && user_id.present? + valid = params[:project_id].present? && user_id.present? response[:error] = 'Invalid parameters' unless valid end def remix_project(response, params, user_id) - original_project = Project.find_by!(identifier: params[:phrase_id]) + original_project = Project.find_by!(identifier: params[:project_id]) response[:project] = create_remix(original_project, user_id) diff --git a/app/controllers/api/projects/phrases_controller.rb b/app/controllers/api/projects/phrases_controller.rb deleted file mode 100644 index d92373ad8..000000000 --- a/app/controllers/api/projects/phrases_controller.rb +++ /dev/null @@ -1,36 +0,0 @@ -# frozen_string_literal: true - -module Api - module Projects - class PhrasesController < ApiController - require 'phrase_identifier' - - def show - @project = Project.find_by!(identifier: params[:id]) - render '/api/projects/show', formats: [:json] - end - - def update - @project = Project.find_by!(identifier: params[:id]) - - if oauth_user_id && oauth_user_id == @project.user_id - components = project_params[:components] - - components.each do |comp_params| - component = Component.find(comp_params[:id]) - component.update(comp_params) - end - head :ok - else - head :unauthorized - end - end - - private - - def project_params - params.require(:project).permit(:identifier, :type, components: %i[id name extension content]) - end - end - end -end diff --git a/app/controllers/api/projects/remixes_controller.rb b/app/controllers/api/projects/remixes_controller.rb index eb25f0a46..f24a87963 100644 --- a/app/controllers/api/projects/remixes_controller.rb +++ b/app/controllers/api/projects/remixes_controller.rb @@ -19,7 +19,7 @@ def create private def remix_params - params.permit(:phrase_id) + params.permit(:project_id) end end end diff --git a/app/controllers/api/projects_controller.rb b/app/controllers/api/projects_controller.rb new file mode 100644 index 000000000..f773d8b66 --- /dev/null +++ b/app/controllers/api/projects_controller.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Api + class ProjectsController < ApiController + require 'phrase_identifier' + + before_action :require_oauth_user, only: %i[update] + + def show + @project = Project.find_by!(identifier: params[:id]) + render :show, formats: [:json] + end + + def update + @project = Project.find_by!(identifier: params[:id]) + authorize! :update, @project + + components = project_params[:components] + components.each do |comp_params| + component = Component.find(comp_params[:id]) + component.update(comp_params) + end + head :ok + end + + private + + def project_params + params.require(:project) + .permit(:identifier, + :type, + components: %i[id name extension content]) + end + end +end diff --git a/app/controllers/api_controller.rb b/app/controllers/api_controller.rb index 1a43feefd..fecf2da8c 100644 --- a/app/controllers/api_controller.rb +++ b/app/controllers/api_controller.rb @@ -5,6 +5,7 @@ class ApiController < ActionController::API unless Rails.application.config.consider_all_requests_local rescue_from ActiveRecord::RecordNotFound, with: -> { return404 } + rescue_from CanCan::AccessDenied, with: -> { return401 } end private @@ -13,7 +14,16 @@ def require_oauth_user head :unauthorized unless oauth_user_id end + def current_user + # current_user is required by CanCanCan + oauth_user_id + end + def return404 - render json: { error: '404 Not found' }, status: :not_found + head :not_found + end + + def return401 + head :unauthorized end end diff --git a/app/models/ability.rb b/app/models/ability.rb new file mode 100644 index 000000000..438d12901 --- /dev/null +++ b/app/models/ability.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class Ability + include CanCan::Ability + + def initialize(user) + return if user.blank? + + can :update, Project, user_id: user + end +end diff --git a/config/routes.rb b/config/routes.rb index 3903d8011..0b07b8978 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -12,10 +12,8 @@ get '/python', to: 'default_projects#python' end - namespace :projects do - resources :phrases, only: %i[show update] do - resource :remix, only: %i[create] - end + resources :projects, only: %i[show update] do + resource :remix, only: %i[create], controller: 'projects/remixes' end end end diff --git a/docker-compose.yml b/docker-compose.yml index 6591b4a01..662fc10ce 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,7 @@ services: command: bash -c "rm -f tmp/pids/server.pid && bundle exec rails s -p 3009 -b '0.0.0.0'" volumes: - .:/app + - bundle:/usr/local/bundle ports: - "3009:3009" depends_on: @@ -22,4 +23,5 @@ services: tty: true volumes: - pg-data: + pg-data: null + bundle: null diff --git a/spec/concepts/project/operation/create_remix_spec.rb b/spec/concepts/project/operation/create_remix_spec.rb index 9cc6a6c69..c2c58e221 100644 --- a/spec/concepts/project/operation/create_remix_spec.rb +++ b/spec/concepts/project/operation/create_remix_spec.rb @@ -14,7 +14,7 @@ describe '.call' do context 'when all params valid' do - let(:params) { { phrase_id: original_project.identifier } } + let(:params) { { project_id: original_project.identifier } } it 'returns success' do result = create_remix @@ -54,7 +54,7 @@ context 'when user_id is not present' do let(:user_id) { nil } - let(:params) { { phrase_id: original_project.identifier } } + let(:params) { { project_id: original_project.identifier } } it 'returns failure' do result = create_remix diff --git a/spec/request/projects/remix_spec.rb b/spec/request/projects/remix_spec.rb index 1f1a62c46..bb8a28fc7 100644 --- a/spec/request/projects/remix_spec.rb +++ b/spec/request/projects/remix_spec.rb @@ -17,13 +17,13 @@ end it 'returns success response' do - post "/api/projects/phrases/#{original_project.identifier}/remix" + post "/api/projects/#{original_project.identifier}/remix" expect(response.status).to eq(200) end it 'returns 404 response if invalid project' do - post '/api/projects/phrases/no-such-project/remix' + post '/api/projects/no-such-project/remix' expect(response.status).to eq(404) end @@ -31,7 +31,7 @@ context 'when auth is invalid' do it 'returns unauthorized' do - post "/api/projects/phrases/#{original_project.identifier}/remix" + post "/api/projects/#{original_project.identifier}/remix" expect(response.status).to eq(401) end diff --git a/spec/request/projects/show_spec.rb b/spec/request/projects/show_spec.rb new file mode 100644 index 000000000..02ac6aba9 --- /dev/null +++ b/spec/request/projects/show_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Project show requests', type: :request do + let!(:project) { create(:project) } + let(:expected_json) do + { + identifier: project.identifier, + project_type: 'python', + name: project.name, + user_id: project.user_id, + components: [] + }.to_json + end + + it 'returns success response' do + get "/api/projects/#{project.identifier}" + + expect(response.status).to eq(200) + end + + it 'returns json' do + get "/api/projects/#{project.identifier}" + expect(response.content_type).to eq('application/json; charset=utf-8') + end + + it 'returns the project json' do + get "/api/projects/#{project.identifier}" + expect(response.body).to eq(expected_json) + end + + it 'returns 404 response if invalid project' do + get '/api/projects/no-such-project' + + expect(response.status).to eq(404) + end +end diff --git a/spec/request/projects/update_spec.rb b/spec/request/projects/update_spec.rb new file mode 100644 index 000000000..0c97d046e --- /dev/null +++ b/spec/request/projects/update_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Project update requests', type: :request do + let(:user_id) { 'e0675b6c-dc48-4cd6-8c04-0f7ac05af51a' } + let(:project) { create(:project, user_id: user_id) } + + context 'when authed user is project creator' do + let(:project) { create(:project, user_id: user_id) } + let!(:component) { create(:component, project: project) } + let(:changes) { { name: 'updated', extension: 'py', content: 'updated content' } } + let(:params) { { project: { components: [{ id: component.id, **changes }] } } } + + before do + mock_oauth_user(user_id) + end + + it 'returns success response' do + put "/api/projects/#{project.identifier}", params: params + expect(response.status).to eq(200) + end + + it 'updates component' do + put "/api/projects/#{project.identifier}", params: params + updated = component.reload.attributes.symbolize_keys.slice(:name, :content, :extension) + expect(updated).to eq(changes) + end + end + + context 'when authed user is not creator' do + let(:project) { create(:project) } + let(:params) { { project: { components: [] } } } + + before do + mock_oauth_user(user_id) + end + + it 'returns unauthorized response' do + put "/api/projects/#{project.identifier}", params: params + expect(response.status).to eq(401) + end + end + + context 'when auth is invalid' do + it 'returns unauthorized' do + put "/api/projects/#{project.identifier}" + + expect(response.status).to eq(401) + end + end +end