Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions app/graphql/editor_api_error.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# frozen_string_literal: true

# rubocop:disable GraphQL/ObjectDescription
class EditorApiError
class Base < GraphQL::ExecutionError
def initialize(*, **args)
args[:extensions] ||= {}
args[:extensions].merge!(code:)

super(*, **args)
end

def code
self.class::CODE
end
end

# These are modelled on Apollo GraphQL server
# https://www.apollographql.com/docs/apollo-server/data/errors/#built-in-error-codes

CODES = [
'GRAPHQL_PARSE_FAILED', # The GraphQL operation string contains a syntax error.
'GRAPHQL_VALIDATION_FAILED', # The GraphQL operation is not valid against the server's schema.
'BAD_USER_INPUT', # The GraphQL operation includes an invalid value for a field argument.
'BAD_REQUEST', # An error occurred before your server could attempt to parse the given GraphQL operation.
'UNAUTHORIZED', # User needs to be authorized before this request can be fulfilled
'FORBIDDEN', # User is not permitted to make the request
'NOT_FOUND', # The object is not found
'INTERNAL_SERVER_ERROR' # Something else..
].freeze

CODES.each do |code|
klass = Class.new(Base) do
const_set :CODE, code
end

const_set code.downcase.camelize, klass
end
end
# rubocop:enable GraphQL/ObjectDescription
8 changes: 5 additions & 3 deletions app/graphql/editor_api_schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,18 +35,20 @@ def self.resolve_type(_abstract_type, obj, _ctx)
when Component
Types::ComponentType
else
raise("Unexpected object: #{obj}")
raise EditorApiError::GraphqlValidationFailed,
"Unexpected object: #{obj}"
end
end

def self.unauthorized_object(error)
# Add a top-level error to the response instead of returning nil:
raise GraphQL::ExecutionError, "An object of type #{error.type.graphql_name} was hidden due to permissions"
raise EditorApiError::Forbidden,
"An object of type #{error.type.graphql_name} was hidden due to permissions"
end

def self.unauthorized_field(error)
# Add a top-level error to the response instead of returning nil:
raise GraphQL::ExecutionError,
raise EditorApiError::Forbidden,
"The field #{error.field.graphql_name} on " \
"an object of type #{error.type.graphql_name} was hidden due to permissions"
end
Expand Down
13 changes: 9 additions & 4 deletions app/graphql/mutations/create_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,13 @@ class CreateComponent < BaseMutation
def resolve(**input)
project = GlobalID.find(input[:project_id])

raise GraphQL::ExecutionError, 'Project not found' unless project
raise EditorApiError::NotFound, 'Project not found' unless project

component = Component.new input
component.project = project

unless context[:current_ability].can?(:create, component)
raise GraphQL::ExecutionError,
raise EditorApiError::Forbidden,
'You are not permitted to update this component'
end

Expand All @@ -25,12 +25,17 @@ def resolve(**input)
raise GraphQL::ExecutionError, component.errors.full_messages.join(', ')
end

def ready?(**_args)
def ready?(...)
unless context[:current_user_id]
raise EditorApiError::Unauthorized,
'You must be authenticated to create a component'
end

if context[:current_ability]&.can?(:create, Component, Project.new(user_id: context[:current_user_id]))
return true
end

raise GraphQL::ExecutionError, 'You are not permitted to create a component'
raise EditorApiError::Forbidden, 'You are not permitted to create a component'
end
end
end
11 changes: 8 additions & 3 deletions app/graphql/mutations/create_project.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,15 @@ def resolve(**input)
{ project: response[:project] }
end

def ready?(**_args)
return true if context[:current_ability]&.can?(:create, Project, user_id: context[:current_user_id])
def ready?(...)
unless context[:current_user_id]
raise EditorApiError::Unauthorized,
'You must be authenticated to create a project'
end

raise GraphQL::ExecutionError, 'You are not permitted to create a project'
return true if context[:current_ability].can?(:create, Project, user_id: context[:current_user_id])

raise EditorApiError::Forbidden, 'You are not permitted to create a project'
end
end
end
11 changes: 8 additions & 3 deletions app/graphql/mutations/delete_project.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@ class DeleteProject < BaseMutation
def resolve(**input)
project = GlobalID.find(input[:id])

raise GraphQL::ExecutionError, 'Project not found' unless project
raise EditorApiError::NotFound, 'Project not found' unless project

unless context[:current_ability].can?(:destroy, project)
raise GraphQL::ExecutionError, 'You are not permitted to delete that project'
raise EditorApiError::Forbidden, 'You are not permitted to delete that project'
end

return { id: project.id } if project.destroy
Expand All @@ -23,9 +23,14 @@ def resolve(**input)
end

def ready?(...)
unless context[:current_user_id]
raise EditorApiError::Unauthorized,
'You must be authenticated to delete a project'
end

return true if context[:current_ability]&.can?(:destroy, Project, user_id: context[:current_user_id])

raise GraphQL::ExecutionError, 'You are not permitted to delete projects'
raise EditorApiError::Forbidden, 'You are not permitted to delete projects'
end
end
end
14 changes: 10 additions & 4 deletions app/graphql/mutations/remix_project.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ class RemixProject < BaseMutation

def resolve(**input)
original_project = GlobalID.find(input[:id])
raise GraphQL::ExecutionError, 'Project not found' unless original_project
raise GraphQL::ExecutionError, 'You are not permitted to read this project' unless can_read?(original_project)

raise EditorApiError::NotFound, 'Project not found' unless original_project
raise EditorApiError::Forbidden, 'You are not permitted to read this project' unless can_read?(original_project)

params = {
name: input[:name] || original_project.name,
Expand All @@ -24,10 +25,15 @@ def resolve(**input)
{ project: response[:project] }
end

def ready?(**_args)
def ready?(...)
unless context[:current_user_id]
raise EditorApiError::Unauthorized,
'You must be authenticated to remix a project'
end

return true if can_create_project?

raise GraphQL::ExecutionError, 'You are not permitted to create a project'
raise EditorApiError::Forbidden, 'You are not permitted to remix projects'
end

def can_create_project?
Expand Down
13 changes: 9 additions & 4 deletions app/graphql/mutations/update_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ class UpdateComponent < BaseMutation

def resolve(**input)
component = GlobalID.find(input[:id])
raise GraphQL::ExecutionError, 'Component not found' unless component
raise EditorApiError::NotFound, 'Component not found' unless component

unless context[:current_ability].can?(:update, component)
raise GraphQL::ExecutionError,
raise EditorApiError::Forbidden,
'You are not permitted to update this component'
end

Expand All @@ -22,10 +22,15 @@ def resolve(**input)
raise GraphQL::ExecutionError, component.errors.full_messages.join(', ')
end

def ready?(**_args)
def ready?(...)
unless context[:current_user_id]
raise EditorApiError::Unauthorized,
'You must be authenticated to update a component'
end

return true if context[:current_ability]&.can?(:update, Component, user_id: context[:current_user_id])

raise GraphQL::ExecutionError, 'You are not permitted to update a component'
raise EditorApiError::Forbidden, 'You are not permitted to update a component'
end
end
end
13 changes: 9 additions & 4 deletions app/graphql/mutations/update_project.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ class UpdateProject < BaseMutation

def resolve(**input)
project = GlobalID.find(input[:id])
raise GraphQL::ExecutionError, 'Project not found' unless project
raise EditorApiError::NotFound, 'Project not found' unless project

unless context[:current_ability].can?(:update, project)
raise GraphQL::ExecutionError,
raise EditorApiError::Forbidden,
'You are not permitted to update this project'
end

Expand All @@ -22,10 +22,15 @@ def resolve(**input)
raise GraphQL::ExecutionError, project.errors.full_messages.join(', ')
end

def ready?(**_args)
def ready?(...)
unless context[:current_user_id]
raise EditorApiError::Unauthorized,
'You must be authenticated to update a project'
end

return true if context[:current_ability]&.can?(:update, Project, user_id: context[:current_user_id])

raise GraphQL::ExecutionError, 'You are not permitted to update a project'
raise EditorApiError::Forbidden, 'You are not permitted to update a project'
end
end
end
19 changes: 19 additions & 0 deletions spec/graphql/editor_api_error/base_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# frozen_string_literal: true

require 'rails_helper'

RSpec.describe EditorApiError::Base do
subject { described_class }

before do
stub_const('EditorApiError::Base::CODE', 'TESTING')
end

it 'can be initialized with extensions' do
expect(described_class.new('message', extensions: { foo: :bar }).extensions).to include(foo: :bar)
end

it 'sets the error code' do
expect(described_class.new('message').extensions).to include(code: 'TESTING')
end
end
11 changes: 11 additions & 0 deletions spec/graphql/editor_api_error/not_found_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# frozen_string_literal: true

require 'rails_helper'

RSpec.describe EditorApiError::NotFound do
subject { described_class }

it 'sets the error code' do
expect(described_class.new('message').extensions).to include(code: 'NOT_FOUND')
end
end
33 changes: 20 additions & 13 deletions spec/graphql/mutations/create_component_mutation_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,24 +22,26 @@
}
end

it { expect(mutation).to be_a_valid_graphql_query }

context 'when unauthenticated' do
shared_examples 'a no-op' do |error_code:|
it 'does not create a component' do
expect { result }.not_to change(Component, :count)
end

it 'returns an error' do
expect(result.dig('errors', 0, 'message')).not_to be_blank
expect(result.dig('errors', 0, 'extensions', 'code')).to eq error_code
end
end

it { expect(mutation).to be_a_valid_graphql_query }

context 'when unauthenticated' do
it_behaves_like 'a no-op', error_code: 'UNAUTHORIZED'
end

context 'when the graphql context is unset' do
let(:graphql_context) { nil }

it 'does not create a component' do
expect { result }.not_to change(Component, :count)
end
it_behaves_like 'a no-op', error_code: 'UNAUTHORIZED'
end

context 'when authenticated' do
Expand All @@ -50,20 +52,25 @@
expect(result.dig('data', 'createComponent', 'component', 'id')).not_to be_nil
end

context 'when the user is not allowed to update components' do
before do
ability = instance_double(Ability, can?: false)
allow(Ability).to receive(:new).and_return(ability)
end

it_behaves_like 'a no-op', error_code: 'FORBIDDEN'
end

context 'when project id doesnt exist' do
let(:project_id) { 'dummy-id' }

it 'returns an error' do
expect(result.dig('errors', 0, 'message')).not_to be_blank
end
it_behaves_like 'a no-op', error_code: 'NOT_FOUND'
end

context 'when project id exists but belongs to someone else' do
let(:project) { create(:project) }

it 'returns an error' do
expect(result.dig('errors', 0, 'message')).not_to be_blank
end
it_behaves_like 'a no-op', error_code: 'FORBIDDEN'
end

context 'when project component fails to save' do
Expand Down
25 changes: 18 additions & 7 deletions spec/graphql/mutations/create_project_mutation_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,24 +21,26 @@
}
end

it { expect(mutation).to be_a_valid_graphql_query }

context 'when unauthenticated' do
shared_examples 'a no-op' do |error_code:|
it 'does not create a project' do
expect { result }.not_to change(Project, :count)
end

it 'returns an error' do
expect(result.dig('errors', 0, 'message')).not_to be_blank
expect(result.dig('errors', 0, 'extensions', 'code')).to eq error_code
end
end

it { expect(mutation).to be_a_valid_graphql_query }

context 'when unauthenticated' do
it_behaves_like 'a no-op', error_code: 'UNAUTHORIZED'
end

context 'when the graphql context is unset' do
let(:graphql_context) { nil }

it 'does not create a project' do
expect { result }.not_to change(Project, :count)
end
it_behaves_like 'a no-op', error_code: 'UNAUTHORIZED'
end

context 'when authenticated' do
Expand All @@ -50,6 +52,15 @@
expect(result.dig('data', 'createProject', 'project', 'id')).to eq Project.first.to_gid_param
end

context 'when the user is not allowed to create projects' do
before do
ability = instance_double(Ability, can?: false)
allow(Ability).to receive(:new).and_return(ability)
end

it_behaves_like 'a no-op', error_code: 'FORBIDDEN'
end

context 'when project creation fails' do
before do
allow(Project::Create).to receive(:call).and_return(OperationResponse[error: 'Foo'])
Expand Down
Loading