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
58 changes: 53 additions & 5 deletions app/controllers/api/scratch/projects_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,22 +14,70 @@ def show
end

def create
render json: { status: 'ok', 'content-name': 'new-project-id' }, status: :ok
original_project = load_original_project(source_project_identifier)
return render json: { error: I18n.t('errors.admin.unauthorized') }, status: :unauthorized unless current_ability.can?(:show, original_project)

remix_params = create_params
return render json: { error: I18n.t('errors.project.remixing.invalid_params') }, status: :bad_request if remix_params[:scratch_component].blank?

remix_origin = request.origin || request.referer

result = Project::CreateRemix.call(
params: remix_params,
user_id: current_user.id,
original_project:,
remix_origin:
)

if result.success?
render json: { status: 'ok', 'content-name': result[:project].identifier }, status: :ok
else
render json: { error: result[:error] }, status: :bad_request
end
end

def update
scratch_content = params.permit!.slice(:meta, :targets, :monitors, :extensions)
@project.scratch_component&.content = scratch_content.to_unsafe_h
@project.scratch_component&.content = scratch_content_params
@project.save!
render json: { status: 'ok' }, status: :ok
end

private

def ensure_create_is_a_remix
return if params[:is_remix] == '1'
return if params[:is_remix] == '1' && params[:original_id].present?

render json: { error: I18n.t('errors.project.remixing.only_existing_allowed') }, status: :forbidden
end

def source_project_identifier
params[:original_id]
end

def create_params
{}.tap do |hash|
hash[:identifier] = source_project_identifier
scratch_content = scratch_content_params
hash[:scratch_component] = { content: scratch_content } if scratch_content.present?
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When would scratch_content not be present? Is this something we could use params.require for?

end
end

def load_original_project(identifier)
project_loader = ProjectLoader.new(identifier, [params[:locale]])
original_project = project_loader.load

raise ActiveRecord::RecordNotFound, I18n.t('errors.project.not_found') unless original_project
raise ActiveRecord::RecordNotFound, I18n.t('errors.project.not_found') unless valid_original_project?(original_project)

original_project
end

def scratch_content_params
params.slice(:meta, :targets, :monitors, :extensions).to_unsafe_h
end

render json: { error: 'Only remixing existing projects is allowed' }, status: :forbidden
def valid_original_project?(project)
project.project_type == Project::Types::CODE_EDITOR_SCRATCH
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small thing, but it would be nice to push this logic down into the Project model since it's also being used elsewhere.

end
end
end
Expand Down
2 changes: 2 additions & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ en:
unauthorized: "Not authorized."
csv_file_required: "A CSV file is required."
project:
not_found: "Project not found"
editing:
delete_default_component: "Cannot delete default file"
change_default_name: "Cannot amend default file name"
Expand All @@ -12,6 +13,7 @@ en:
remixing:
invalid_params: "Invalid parameters"
cannot_save: "Cannot create project remix"
only_existing_allowed: "Only remixing existing projects is allowed"
validations:
school:
website: "must be a valid URL"
Expand Down
20 changes: 18 additions & 2 deletions lib/concepts/project/operations/create_remix.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,11 @@ def validate_params(response, params, user_id, original_project, remix_origin)
end

def remix_project(response, params, user_id, original_project, remix_origin)
response[:project] = create_remix(original_project, params, user_id, remix_origin)
response[:project] = if scratch_project?(original_project)
create_scratch_remix(original_project, params, user_id, remix_origin)
else
create_remix(original_project, params, user_id, remix_origin)
end
response[:project].save!
response
end
Expand All @@ -50,17 +54,29 @@ def create_remix(original_project, params, user_id, remix_origin)
remix
end

def create_scratch_remix(original_project, params, user_id, remix_origin)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is so much easier to follow now, thanks for making this change.

remix = format_project(original_project, params, user_id, remix_origin)
scratch_component = params.fetch(:scratch_component)
remix.build_scratch_component(content: scratch_component[:content] || scratch_component['content'])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small thing - I saw 'content' is provided as a symbol in the controller, so why is it necessary to also accept strings here? If it is important to accept strings, does params.fetch(:scratch_component) also need to change?


remix
end

def format_project(original_project, params, user_id, remix_origin)
original_project.dup.tap do |proj|
proj.identifier = PhraseIdentifier.generate
proj.locale = nil
proj.name = params[:name]
proj.name = params[:name] || original_project.name
proj.user_id = user_id
proj.remixed_from_id = original_project.id
proj.remix_origin = remix_origin
proj.lesson_id = nil # Only the original can have a lesson id
end
end

def scratch_project?(project)
project.project_type == Project::Types::CODE_EDITOR_SCRATCH
end
end
end
end
43 changes: 43 additions & 0 deletions spec/concepts/project/create_remix_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,49 @@
end
end

context 'when the original project has Scratch content' do
let!(:original_project) do
create(:project, project_type: Project::Types::CODE_EDITOR_SCRATCH, locale: nil)
end
let(:original_scratch_project) do
{
meta: { semver: '3.0.0' },
targets: ['original target'],
monitors: [],
extensions: []
}
end

before do
create(:scratch_component, project: original_project, content: original_scratch_project)
end

context 'when new Scratch content is provided' do
let(:remixed_scratch_project) do
{
meta: { semver: '3.0.0' },
targets: ['remixed target'],
monitors: [],
extensions: ['pen']
}
end
let(:remix_params) do
{
name: 'My remixed project',
identifier: original_project.identifier,
scratch_component: {
content: remixed_scratch_project
}
}
end

it 'uses the supplied Scratch content' do
expect(create_remix[:project].scratch_component.content.to_h)
.to eq(remixed_scratch_project.deep_stringify_keys)
end
end
end

context 'when user_id is not present' do
let(:user_id) { nil }
let(:params) { { project_id: original_project.identifier } }
Expand Down
114 changes: 96 additions & 18 deletions spec/features/scratch/creating_a_scratch_project_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,47 +5,125 @@
RSpec.describe 'Creating a Scratch project (remixing)', type: :request do
let(:school) { create(:school) }
let(:teacher) { create(:teacher, school:) }
let(:cookie_headers) { { 'Cookie' => "scratch_auth=#{UserProfileMock::TOKEN}" } }
let(:params) { { original_id: 'original-project-id', project: { targets: [] }, is_remix: '1' } }
let(:headers) do
{
'Cookie' => "scratch_auth=#{UserProfileMock::TOKEN}",
'Origin' => 'editor.com'
}
end
let(:request_query) { { original_id: original_project.identifier, is_remix: '1' } }
let(:scratch_project) do
{
meta: { semver: '3.0.0' },
targets: ['updated target'],
monitors: [],
extensions: ['pen']
}
end
let(:lesson) { create(:lesson, school:, user_id: teacher.id) }
let(:original_project) do
create(
:project,
school:,
lesson:,
user_id: teacher.id,
project_type: Project::Types::CODE_EDITOR_SCRATCH,
locale: nil
)
end

before do
mock_phrase_generation('new-project-id')
create(:scratch_component, project: original_project)

Flipper.disable :cat_mode
Flipper.disable_actor :cat_mode, school
end

def make_request(query: request_query, request_headers: headers, request_params: scratch_project)
post(
"/api/scratch/projects?#{Rack::Utils.build_query(query)}",
params: request_params,
headers: request_headers,
as: :json
)
end

it 'responds 401 Unauthorized when no cookie is provided' do
post '/api/scratch/projects', params: params
make_request(request_headers: {})

expect(response).to have_http_status(:unauthorized)
end

it 'responds 404 Not Found when cat_mode is not enabled' do
authenticated_in_hydra_as(teacher)

post '/api/scratch/projects', params: params, headers: cookie_headers
make_request

expect(response).to have_http_status(:not_found)
end

it 'responds 403 Forbidden when not remixing' do
authenticated_in_hydra_as(teacher)
Flipper.enable_actor :cat_mode, school
context 'when authenticated and cat_mode is enabled' do
before do
authenticated_in_hydra_as(teacher)
Flipper.enable_actor :cat_mode, school
end

post '/api/scratch/projects', params: params.merge(is_remix: '0'), headers: cookie_headers
it 'responds 403 Forbidden when not remixing' do
make_request(query: request_query.merge(is_remix: '0'))

expect(response).to have_http_status(:forbidden)
end
expect(response).to have_http_status(:forbidden)
end

it 'return new project id when cat_mode is enabled and a cookie is provided' do
authenticated_in_hydra_as(teacher)
Flipper.enable_actor :cat_mode, school
it 'responds 403 Forbidden when original_id is missing' do
make_request(query: { is_remix: '1' })

expect(response).to have_http_status(:forbidden)
end

it 'responds 404 Not Found when original project does not exist' do
make_request(query: { original_id: 'no-such-project', is_remix: '1' })

expect(response).to have_http_status(:not_found)
end

it 'responds 404 Not Found when the original project is not a Scratch project' do
non_scratch_project = create(:project, school:, lesson:, user_id: teacher.id, locale: nil)

make_request(query: { original_id: non_scratch_project.identifier, is_remix: '1' })

expect(response).to have_http_status(:not_found)
end

it 'responds 401 Unauthorized when the user cannot access the original project' do
inaccessible_project = create(:project, project_type: Project::Types::CODE_EDITOR_SCRATCH, locale: nil)
create(:scratch_component, project: inaccessible_project)

make_request(query: { original_id: inaccessible_project.identifier, is_remix: '1' })

expect(response).to have_http_status(:unauthorized)
end

it 'responds 400 Bad Request when no Scratch content is submitted' do
make_request(request_params: {})

expect(response).to have_http_status(:bad_request)
end

post '/api/scratch/projects', params: params, headers: cookie_headers
it 'creates a remix, associates it to the current user, and returns the new identifier' do
expect { make_request }.to change(Project, :count).by(1)

expect(response).to have_http_status(:ok)
expect(response).to have_http_status(:ok)
expect(response.parsed_body).to eq(
'status' => 'ok',
'content-name' => 'new-project-id'
)

data = JSON.parse(response.body, symbolize_names: true)
expect(data[:status]).to eq('ok')
expect(data[:'content-name']).to eq('new-project-id')
remixed_project = Project.find_by!(identifier: 'new-project-id')
expect(remixed_project.user_id).to eq(teacher.id)
expect(remixed_project.remixed_from_id).to eq(original_project.id)
expect(remixed_project.lesson_id).to be_nil
expect(remixed_project.scratch_component.content.to_h).to eq(scratch_project.deep_stringify_keys)
end
end
end
Loading