diff --git a/app/controllers/api/v1/grid_summons_controller.rb b/app/controllers/api/v1/grid_summons_controller.rb index 08450c6..47ec1b0 100644 --- a/app/controllers/api/v1/grid_summons_controller.rb +++ b/app/controllers/api/v1/grid_summons_controller.rb @@ -3,17 +3,19 @@ module Api module V1 ## - # Controller responsible for managing grid summons for a party. - # Provides actions to create, update, and destroy grid summons. + # Controller handling API requests related to grid summons within a party. # - # @note All actions assume that the request has been authorized. + # This controller provides endpoints for creating, updating, resolving conflicts, and deleting grid summons. + # It ensures that the correct party and summons are found and that the current user (or edit key) is authorized. + # + # @see Api::V1::ApiController for shared API behavior. class GridSummonsController < Api::V1::ApiController attr_reader :party, :incoming_summon - before_action :set, only: %w[update update_uncap_level update_quick_summon] - before_action :find_party, only: :create + before_action :find_grid_summon, only: %i[update update_uncap_level update_quick_summon resolve destroy] + before_action :find_party, only: %i[create update update_uncap_level update_quick_summon resolve destroy] before_action :find_incoming_summon, only: :create - before_action :authorize, only: %i[create update update_uncap_level update_quick_summon destroy] + before_action :authorize_party_edit!, only: %i[create update update_uncap_level update_quick_summon destroy] ## # Creates a new grid summon. @@ -52,11 +54,11 @@ module Api # # @return [void] def update - @summon.attributes = summon_params + @grid_summon.attributes = summon_params - return render json: GridSummonBlueprint.render(@summon, view: :nested, root: :grid_summon) if @summon.save + return render json: GridSummonBlueprint.render(@grid_summon, view: :nested, root: :grid_summon) if @grid_summon.save - render_validation_error_response(@summon) + render_validation_error_response(@grid_summon) end ## @@ -68,7 +70,7 @@ module Api # # @return [void] def update_uncap_level - summon = @summon.summon + summon = @grid_summon.summon max_level = max_uncap_level(summon) greater_than_max_uncap = summon_params[:uncap_level].to_i > max_level @@ -79,10 +81,10 @@ module Api new_uncap_level = greater_than_max_uncap || can_be_transcended ? max_level : summon_params[:uncap_level] new_transcendence_step = summon.transcendence && summon_params[:transcendence_step].present? ? summon_params[:transcendence_step] : 0 - if @summon.update(uncap_level: new_uncap_level, transcendence_step: new_transcendence_step) - render json: GridSummonBlueprint.render(@summon, view: :nested, root: :grid_summon) + if @grid_summon.update(uncap_level: new_uncap_level, transcendence_step: new_transcendence_step) + render json: GridSummonBlueprint.render(@grid_summon, view: :nested, root: :grid_summon) else - render_validation_error_response(@summon) + render_validation_error_response(@grid_summon) end end @@ -95,19 +97,19 @@ module Api # # @return [void] def update_quick_summon - return if [4, 5, 6].include?(@summon.position) + return if [4, 5, 6].include?(@grid_summon.position) - quick_summons = @summon.party.summons.select(&:quick_summon) + quick_summons = @grid_summon.party.summons.select(&:quick_summon) quick_summons.each do |summon| summon.update!(quick_summon: false) end - @summon.update!(quick_summon: summon_params[:quick_summon]) - return unless @summon.persisted? + @grid_summon.update!(quick_summon: summon_params[:quick_summon]) + return unless @grid_summon.persisted? - quick_summons -= [@summon] - summons = [@summon] + quick_summons + quick_summons -= [@grid_summon] + summons = [@grid_summon] + quick_summons render json: GridSummonBlueprint.render(summons, view: :nested, root: :summons) end @@ -124,7 +126,6 @@ module Api grid_summon = GridSummon.find_by('id = ?', params[:id]) return render_not_found_response('grid_summon') if grid_summon.nil? - return render_unauthorized_response if grid_summon.party.user != current_user render json: GridSummonBlueprint.render(grid_summon, view: :destroyed), status: :ok if grid_summon.destroy end @@ -182,10 +183,28 @@ module Api # user is not the owner of the party. # # @return [void] + + ## + # Finds and sets the party based on parameters. + # + # Renders an unauthorized response if the current user is not the owner. + # + # @return [void] def find_party - # BUG: I can create grid weapons even when I'm not logged in on an authenticated party - @party = Party.find(summon_params[:party_id]) - render_unauthorized_response if current_user && (party.user != current_user) + @party = Party.find_by(id: params.dig(:summon, :party_id)) || Party.find_by(id: params[:party_id]) || @grid_summon&.party + render_not_found_response('party') unless @party + end + + ## + # Finds and sets the GridSummon based on the provided parameters. + # + # Searches for a grid summon using various parameter keys and renders a not found response if it is absent. + # + # @return [void] + def find_grid_summon + grid_summon_id = params[:id] || params.dig(:summon, :id) || params.dig(:resolve, :conflicting) + @grid_summon = GridSummon.find_by(id: grid_summon_id) + render_not_found_response('grid_summon') unless @grid_summon end ## @@ -198,31 +217,6 @@ module Api @incoming_summon = Summon.find_by(id: summon_params[:summon_id]) end - ## - # Finds and sets the grid summon for update actions. - # - # Sets the @summon instance variable using the provided id parameter. - # - # @return [void] - def set - @summon = GridSummon.find_by('id = ?', summon_params[:id]) - end - - ## - # Authorizes the current request based on the party or grid summon ownership and edit key. - # - # Checks if the current user is authorized to create or update the party or grid summon. - # Renders an unauthorized response if the authorization fails. - # - # @return [void] - def authorize - # Create - unauthorized_create = @party && (@party.user != current_user || @party.edit_key != edit_key) - unauthorized_update = @summon && @summon.party && (@summon.party.user != current_user || @summon.party.edit_key != edit_key) - - render_unauthorized_response if unauthorized_create || unauthorized_update - end - ## # Builds a new GridSummon instance using permitted parameters. # @@ -282,6 +276,61 @@ module Api end end + ## + # Authorizes the current action by ensuring that the current user or provided edit key matches the party's owner. + # + # For parties associated with a user, it verifies that the current_user is the owner. + # For anonymous parties, it checks that the provided edit key matches the party's edit key. + # + # @return [void] + def authorize_party_edit! + if @party.user.present? + authorize_user_party + else + authorize_anonymous_party + end + end + + ## + # Authorizes an action for a party that belongs to a user. + # + # Renders an unauthorized response unless the current user is present and + # matches the party's user. + # + # @return [void] + def authorize_user_party + return if current_user.present? && @party.user == current_user + + render_unauthorized_response + end + + ## + # Authorizes an action for an anonymous party using an edit key. + # + # Retrieves and normalizes the provided edit key and compares it with the party's edit key. + # Renders an unauthorized response unless the keys are valid. + # + # @return [void] + def authorize_anonymous_party + provided_edit_key = edit_key.to_s.strip.force_encoding('UTF-8') + party_edit_key = @party.edit_key.to_s.strip.force_encoding('UTF-8') + return if valid_edit_key?(provided_edit_key, party_edit_key) + + render_unauthorized_response + end + + ## + # Validates that the provided edit key matches the party's edit key. + # + # @param provided_edit_key [String] the edit key provided in the request. + # @param party_edit_key [String] the edit key associated with the party. + # @return [Boolean] true if the edit keys match; false otherwise. + def valid_edit_key?(provided_edit_key, party_edit_key) + provided_edit_key.present? && + provided_edit_key.bytesize == party_edit_key.bytesize && + ActiveSupport::SecurityUtils.secure_compare(provided_edit_key, party_edit_key) + end + ## # Defines and permits the whitelisted parameters for a grid summon. # diff --git a/spec/requests/grid_summons_controller_spec.rb b/spec/requests/grid_summons_controller_spec.rb new file mode 100644 index 0000000..b5e8a4c --- /dev/null +++ b/spec/requests/grid_summons_controller_spec.rb @@ -0,0 +1,369 @@ +# frozen_string_literal: true + +require 'rails_helper' + +# This request spec tests the GridSummons API endpoints using modern RSpec techniques. +RSpec.describe 'GridSummons API', type: :request do + let(:user) { create(:user) } + let(:party) { create(:party, user: user, edit_key: 'secret') } + let(:summon) { Summon.find_by(granblue_id: '2040433000') } + let(:access_token) do + Doorkeeper::AccessToken.create!( + resource_owner_id: user.id, + expires_in: 30.days, + scopes: 'public' + ) + end + let(:headers) do + { + 'Authorization' => "Bearer #{access_token.token}", + 'Content-Type' => 'application/json' + } + end + + RSpec::Matchers.define :have_json_error_message do |expected_message| + match do |response| + JSON.parse(response.body)['error'].to_s.include?(expected_message) + end + + failure_message do |response| + "expected error message to include '#{expected_message}', but got: #{JSON.parse(response.body)['error']}" + end + end + + describe 'POST /api/v1/summons' do + let(:valid_params) do + { + summon: { + party_id: party.id, + summon_id: summon.id, + position: 0, + main: true, + friend: false, + quick_summon: false, + uncap_level: 3, + transcendence_step: 0 + } + } + end + + context 'with valid parameters' do + it 'creates a grid summon and returns status created' do + expect do + post '/api/v1/summons', params: valid_params.to_json, headers: headers + end.to change(GridSummon, :count).by(1) + expect(response).to have_http_status(:created) + json_response = JSON.parse(response.body) + expect(json_response).to have_key('grid_summon') + expect(json_response['grid_summon']).to include('position' => 0) + end + end + + context 'with invalid parameters' do + # Revised: use a non-numeric uncap_level so the grid summon is built but fails validation. + let(:invalid_params) do + { + summon: { + party_id: party.id, + summon_id: summon.id, + position: 0, + main: true, + friend: false, + quick_summon: false, + uncap_level: 'invalid', + transcendence_step: 0 + } + } + end + + it 'returns unprocessable entity status with error details' do + post '/api/v1/summons', params: invalid_params.to_json, headers: headers + expect(response).to have_http_status(:unprocessable_entity) + json_response = JSON.parse(response.body) + expect(json_response).to have_key('errors') + expect(json_response['errors']).to include('uncap_level') + end + end + end + + describe 'PUT /api/v1/grid_summons/:id' do + before do + @grid_summon = create(:grid_summon, + party: party, + summon: summon, + position: 1, + uncap_level: 3, + transcendence_step: 0) + end + + context 'with valid parameters' do + let(:update_params) do + { + summon: { + id: @grid_summon.id, + party_id: party.id, + summon_id: summon.id, + position: 1, + main: true, + friend: false, + quick_summon: false, + uncap_level: 4, + transcendence_step: 0 + } + } + end + + it 'updates the grid summon and returns the updated record' do + put "/api/v1/grid_summons/#{@grid_summon.id}", params: update_params.to_json, headers: headers + expect(response).to have_http_status(:ok) + json_response = JSON.parse(response.body) + expect(json_response).to have_key('grid_summon') + expect(json_response['grid_summon']).to include('uncap_level' => 4) + end + end + + context 'with invalid parameters' do + let(:invalid_update_params) do + { + summon: { + id: @grid_summon.id, + party_id: party.id, + summon_id: summon.id, + position: 1, + main: true, + friend: false, + quick_summon: false, + uncap_level: 'invalid', + transcendence_step: 0 + } + } + end + + it 'returns unprocessable entity status with error details' do + put "/api/v1/grid_summons/#{@grid_summon.id}", params: invalid_update_params.to_json, headers: headers + expect(response).to have_http_status(:unprocessable_entity) + json_response = JSON.parse(response.body) + expect(json_response).to have_key('errors') + expect(json_response['errors']).to include('uncap_level') + end + end + end + + describe 'POST /api/v1/summons/update_uncap' do + context 'when summon has flb true, ulb false, transcendence false (max uncap 4)' do + before do + @grid_summon = create(:grid_summon, + party: party, + summon: summon, + position: 2, + uncap_level: 3, + transcendence_step: 0) + end + + let(:update_uncap_params) do + { + summon: { + id: @grid_summon.id, + party_id: party.id, + summon_id: summon.id, + uncap_level: 5, # attempt above allowed; should be capped at 4 + transcendence_step: 0 + } + } + end + + before do + summon.update!(flb: true, ulb: false, transcendence: false) + end + + it 'caps the uncap level at 4 for the summon' do + post '/api/v1/summons/update_uncap', params: update_uncap_params.to_json, headers: headers + expect(response).to have_http_status(:ok) + json_response = JSON.parse(response.body) + expect(json_response).to have_key('grid_summon') + expect(json_response['grid_summon']).to include('uncap_level' => 4) + end + end + + context 'when summon has ulb true, transcendence false (max uncap 5)' do + before do + @grid_summon = create(:grid_summon, + party: party, + summon: summon, + position: 2, + uncap_level: 3, + transcendence_step: 0) + end + + let(:update_uncap_params) do + { + summon: { + id: @grid_summon.id, + party_id: party.id, + summon_id: summon.id, + uncap_level: 6, # attempt above allowed; should be capped at 5 + transcendence_step: 0 + } + } + end + + before do + summon.update!(flb: true, ulb: true, transcendence: false) + end + + it 'updates the uncap level to 5' do + post '/api/v1/summons/update_uncap', params: update_uncap_params.to_json, headers: headers + expect(response).to have_http_status(:ok) + json_response = JSON.parse(response.body) + expect(json_response).to have_key('grid_summon') + expect(json_response['grid_summon']).to include('uncap_level' => 5) + end + end + + context 'when summon can be transcended (max uncap 6)' do + before do + @grid_summon = create(:grid_summon, + party: party, + summon: summon, + position: 2, + uncap_level: 3, + transcendence_step: 0) + end + + let(:update_uncap_params) do + { + summon: { + id: @grid_summon.id, + party_id: party.id, + summon_id: summon.id, + uncap_level: 7, # attempt above allowed; should be capped at 6 + transcendence_step: 0 + } + } + end + + before do + summon.update!(flb: true, ulb: true, transcendence: true) + end + + it 'updates the uncap level to 6' do + post '/api/v1/summons/update_uncap', params: update_uncap_params.to_json, headers: headers + expect(response).to have_http_status(:ok) + json_response = JSON.parse(response.body) + expect(json_response).to have_key('grid_summon') + expect(json_response['grid_summon']).to include('uncap_level' => 6) + end + end + end + + describe 'POST /api/v1/summons/update_quick_summon' do + context 'when grid summon position is not in [4,5,6]' do + let!(:grid_summon) do + create(:grid_summon, + party: party, + summon: summon, + position: 2, + quick_summon: false) + end + + let(:update_quick_params) do + { + summon: { + id: grid_summon.id, + party_id: party.id, + summon_id: summon.id, + quick_summon: true + } + } + end + + it 'updates the quick summon flag and returns the updated summons array' do + post '/api/v1/summons/update_quick_summon', params: update_quick_params.to_json, headers: headers + expect(response).to have_http_status(:ok) + json_response = JSON.parse(response.body) + expect(json_response).to have_key('summons') + end + end + + context 'when grid summon position is in [4,5,6]' do + let!(:grid_summon) do + create(:grid_summon, + party: party, + summon: summon, + position: 4, + quick_summon: false) + end + + let(:update_quick_params) do + { + summon: { + id: grid_summon.id, + party_id: party.id, + summon_id: summon.id, + quick_summon: true + } + } + end + + it 'returns no content when position is in [4,5,6]' do + post '/api/v1/summons/update_quick_summon', params: update_quick_params.to_json, headers: headers + expect(response).to have_http_status(:no_content) + end + end + end + + describe 'DELETE /api/v1/grid_summons/:id (destroy action)' do + context 'when the party is owned by a logged in user' do + let!(:grid_summon) do + create(:grid_summon, + party: party, + summon: summon, + position: 3, + uncap_level: 3, + transcendence_step: 0) + end + + it 'destroys the grid summon and returns a success response' do + expect { delete "/api/v1/grid_summons/#{grid_summon.id}", headers: headers } + .to change(GridSummon, :count).by(-1) + expect(response).to have_http_status(:ok) + end + + it 'returns not found when trying to delete a non-existent grid summon' do + delete '/api/v1/grid_summons/00000000-0000-0000-0000-000000000000', headers: headers + expect(response).to have_http_status(:not_found) + end + end + + context 'when the party is anonymous (no user)' do + # For anonymous users, override the party and header edit key. + let(:headers) { super().merge('X-Edit-Key' => 'anonsecret') } + let(:party) { create(:party, user: nil, edit_key: 'anonsecret') } + let!(:grid_summon) do + create(:grid_summon, + party: party, + summon: summon, + position: 3, + uncap_level: 3, + transcendence_step: 0) + end + + it 'allows anonymous user to destroy grid summon when current_user is nil' do + # To simulate an anonymous request, we remove the Authorization header. + anonymous_headers = headers.except('Authorization') + expect { delete "/api/v1/grid_summons/#{grid_summon.id}", headers: anonymous_headers } + .to change(GridSummon, :count).by(-1) + expect(response).to have_http_status(:ok) + end + + it 'prevents deletion when a logged in user attempts to delete an anonymous grid summon' do + # When a logged in user (with an access token) tries to delete a grid summon + # that belongs to an anonymous party, authorization should fail. + auth_headers = headers.except('X-Edit-Key') + expect { delete "/api/v1/grid_summons/#{grid_summon.id}", headers: auth_headers } + .not_to change(GridSummon, :count) + expect(response).to have_http_status(:unauthorized) + end + end + end +end