diff --git a/Guardfile b/Guardfile index cfc7704f977..30bc36330cf 100644 --- a/Guardfile +++ b/Guardfile @@ -30,4 +30,7 @@ guard :rspec, cmd: 'bundle exec rspec' do "spec/requests/#{m[1]}_controller_spec.rb" ] end + + # Run schema check for any change in Graphql folder + watch(%r{^app/graphql/(.+)\.rb$}) { |m| "spec/graphql/lago_api_schema_spec.rb" } end diff --git a/app/graphql/mutations/api_keys/rotate.rb b/app/graphql/mutations/api_keys/rotate.rb index 283f89773c5..14cc280810a 100644 --- a/app/graphql/mutations/api_keys/rotate.rb +++ b/app/graphql/mutations/api_keys/rotate.rb @@ -11,13 +11,20 @@ class Rotate < BaseMutation graphql_name 'RotateApiKey' description 'Create new ApiKey while expiring provided' - argument :id, ID, required: true + input_object_class Types::ApiKeys::RotateInput type Types::ApiKeys::Object - def resolve(id:) + def resolve(id:, name: nil, expires_at: nil) api_key = current_organization.api_keys.find_by(id:) - result = ::ApiKeys::RotateService.call(api_key) + + result = ::ApiKeys::RotateService.call( + api_key:, + params: { + name:, + expires_at: + } + ) result.success? ? result.api_key : result_error(result) end diff --git a/app/graphql/types/api_keys/rotate_input.rb b/app/graphql/types/api_keys/rotate_input.rb new file mode 100644 index 00000000000..71e87a0c134 --- /dev/null +++ b/app/graphql/types/api_keys/rotate_input.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module ApiKeys + class RotateInput < Types::BaseInputObject + graphql_name 'RotateApiKeyInput' + + argument :expires_at, GraphQL::Types::ISO8601DateTime, required: false + argument :id, ID, required: true + argument :name, String, required: false + end + end +end diff --git a/app/graphql/types/organizations/current_organization_type.rb b/app/graphql/types/organizations/current_organization_type.rb index 92b75e205c4..e54f59aa975 100644 --- a/app/graphql/types/organizations/current_organization_type.rb +++ b/app/graphql/types/organizations/current_organization_type.rb @@ -26,6 +26,7 @@ class CurrentOrganizationType < BaseOrganizationType field :zipcode, String field :api_key, String, permission: 'developers:keys:manage' + field :hmac_key, String, permission: 'developers:keys:manage' field :webhook_url, String, permission: 'developers:manage' field :document_number_prefix, String, null: false diff --git a/app/services/api_keys/rotate_service.rb b/app/services/api_keys/rotate_service.rb index f7f00bdb2d1..915ba9efe3c 100644 --- a/app/services/api_keys/rotate_service.rb +++ b/app/services/api_keys/rotate_service.rb @@ -2,19 +2,25 @@ module ApiKeys class RotateService < BaseService - def initialize(api_key) + def initialize(api_key:, params:) @api_key = api_key + @params = params super end def call return result.not_found_failure!(resource: 'api_key') unless api_key - new_api_key = api_key.organization.api_keys.new + if params[:expires_at].present? && !License.premium? + return result.forbidden_failure!(code: "cannot_rotate_with_provided_date") + end + + expires_at = params[:expires_at] || Time.current + new_api_key = api_key.organization.api_keys.new(name: params[:name]) ActiveRecord::Base.transaction do new_api_key.save! - api_key.update!(expires_at: Time.current) + api_key.update!(expires_at:) end ApiKeyMailer.with(api_key:).rotated.deliver_later @@ -27,6 +33,6 @@ def call private - attr_reader :api_key + attr_reader :api_key, :params end end diff --git a/schema.graphql b/schema.graphql index 6e085039018..04ba87c0353 100644 --- a/schema.graphql +++ b/schema.graphql @@ -3109,6 +3109,7 @@ type CurrentOrganization { euTaxManagement: Boolean! finalizeZeroAmountInvoice: Boolean! gocardlessPaymentProviders: [GocardlessProvider!] + hmacKey: String id: ID! legalName: String legalNumber: String @@ -6846,7 +6847,9 @@ input RotateApiKeyInput { A unique identifier for the client performing the mutation. """ clientMutationId: String + expiresAt: ISO8601DateTime id: ID! + name: String } enum RoundingFunctionEnum { diff --git a/schema.json b/schema.json index 4c990615f73..a59d95988f7 100644 --- a/schema.json +++ b/schema.json @@ -12259,6 +12259,20 @@ ] }, + { + "name": "hmacKey", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null, + "args": [ + + ] + }, { "name": "id", "description": null, @@ -35546,11 +35560,11 @@ "fields": null, "inputFields": [ { - "name": "clientMutationId", - "description": "A unique identifier for the client performing the mutation.", + "name": "expiresAt", + "description": null, "type": { "kind": "SCALAR", - "name": "String", + "name": "ISO8601DateTime", "ofType": null }, "defaultValue": null, @@ -35572,6 +35586,30 @@ "defaultValue": null, "isDeprecated": false, "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null } ], "enumValues": null diff --git a/spec/graphql/mutations/api_keys/rotate_spec.rb b/spec/graphql/mutations/api_keys/rotate_spec.rb index 3ab0bca43dd..ecfc13da259 100644 --- a/spec/graphql/mutations/api_keys/rotate_spec.rb +++ b/spec/graphql/mutations/api_keys/rotate_spec.rb @@ -9,20 +9,22 @@ current_organization: membership.organization, permissions: required_permission, query:, - variables: {input: {id: api_key.id}} + variables: {input: {id: api_key.id, expiresAt: expires_at, name:}} ) end let(:query) do <<-GQL mutation($input: RotateApiKeyInput!) { - rotateApiKey(input: $input) { id value createdAt } + rotateApiKey(input: $input) { id value name createdAt expiresAt } } GQL end let(:required_permission) { 'developers:keys:manage' } let!(:membership) { create(:membership) } + let(:expires_at) { generate(:future_date).iso8601 } + let(:name) { Faker::Lorem.words.join(' ') } it_behaves_like 'requires current user' it_behaves_like 'requires current organization' @@ -31,17 +33,22 @@ context 'when api key with such ID exists in the current organization' do let(:api_key) { membership.organization.api_keys.first } + around { |test| lago_premium!(&test) } + it 'expires the api key' do - expect { result }.to change { api_key.reload.expires_at }.from(nil).to(Time) + expect { result } + .to change { api_key.reload.expires_at&.iso8601 } + .to(expires_at) end it 'returns newly created api key' do api_key_response = result['data']['rotateApiKey'] - new_api_key = membership.organization.api_keys.last + new_api_key = membership.organization.api_keys.order(:created_at).last aggregate_failures do expect(api_key_response['id']).to eq(new_api_key.id) expect(api_key_response['value']).to eq(new_api_key.value) + expect(api_key_response['name']).to eq(name) expect(api_key_response['createdAt']).to eq(new_api_key.created_at.iso8601) expect(api_key_response['expiresAt']).to be_nil end diff --git a/spec/graphql/types/api_keys/rotate_input_spec.rb b/spec/graphql/types/api_keys/rotate_input_spec.rb new file mode 100644 index 00000000000..04f6ced7822 --- /dev/null +++ b/spec/graphql/types/api_keys/rotate_input_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Types::ApiKeys::RotateInput do + subject { described_class } + + it { is_expected.to accept_argument(:id).of_type('ID!') } + it { is_expected.to accept_argument(:name).of_type('String') } + it { is_expected.to accept_argument(:expires_at).of_type('ISO8601DateTime') } +end diff --git a/spec/graphql/types/organizations/current_organization_type_spec.rb b/spec/graphql/types/organizations/current_organization_type_spec.rb index f0fc0ee0991..2950055d292 100644 --- a/spec/graphql/types/organizations/current_organization_type_spec.rb +++ b/spec/graphql/types/organizations/current_organization_type_spec.rb @@ -25,6 +25,7 @@ it { is_expected.to have_field(:zipcode).of_type('String') } it { is_expected.to have_field(:api_key).of_type('String').with_permission('developers:keys:manage') } + it { is_expected.to have_field(:hmac_key).of_type('String').with_permission('developers:keys:manage') } it { is_expected.to have_field(:webhook_url).of_type('String').with_permission('developers:manage') } it { is_expected.to have_field(:timezone).of_type('TimezoneEnum') } diff --git a/spec/services/api_keys/rotate_service_spec.rb b/spec/services/api_keys/rotate_service_spec.rb index 0255b5d31bd..01a96069841 100644 --- a/spec/services/api_keys/rotate_service_spec.rb +++ b/spec/services/api_keys/rotate_service_spec.rb @@ -4,32 +4,107 @@ RSpec.describe ApiKeys::RotateService, type: :service do describe '#call' do - subject(:service_result) { described_class.call(api_key) } + subject(:service_result) { described_class.call(api_key:, params:) } + + let(:params) { {expires_at:, name:} } + let(:name) { Faker::Lorem.words.join(' ') } context 'when API key is provided' do - let(:api_key) { create(:api_key) } + let!(:api_key) { create(:api_key) } let(:organization) { api_key.organization } - it 'expires the API key' do - expect { service_result }.to change(api_key, :expires_at).from(nil).to(Time) - end + context 'when preferred expiration date is provided' do + let(:expires_at) { generate(:future_date) } + + context 'with premium organization' do + around { |test| lago_premium!(&test) } - it 'creates a new API key for organization' do - expect { service_result }.to change(ApiKey, :count).by(1) + it 'expires the API key with preferred date' do + expect { service_result } + .to change { api_key.reload.expires_at&.iso8601 } + .to(expires_at.iso8601) + end - expect(service_result.api_key) - .to be_persisted - .and have_attributes(organization:) + it 'creates a new API key for organization' do + expect { service_result }.to change(ApiKey, :count).by(1) + + expect(service_result.api_key) + .to be_persisted.and have_attributes(organization:, name:) + end + + it 'sends an API key rotated email' do + expect { service_result } + .to have_enqueued_mail(ApiKeyMailer, :rotated).with hash_including(params: {api_key:}) + end + end + + context 'with free organization' do + it 'does not creates a new API key for organization' do + expect { service_result }.not_to change(ApiKey, :count) + end + + it 'does not send an API key rotated email' do + expect { service_result }.not_to have_enqueued_mail(ApiKeyMailer, :rotated) + end + + it 'returns an error' do + aggregate_failures do + expect(service_result).not_to be_success + expect(service_result.error).to be_a(BaseService::ForbiddenFailure) + expect(service_result.error.code).to eq('cannot_rotate_with_provided_date') + end + end + end end - it 'sends an API key rotated email' do - expect { service_result } - .to have_enqueued_mail(ApiKeyMailer, :rotated).with hash_including(params: {api_key:}) + context 'when preferred expiration date is missing' do + let(:expires_at) { nil } + + before { freeze_time } + + context 'with premium organization' do + around { |test| lago_premium!(&test) } + + it 'expires the API key with current time' do + expect { service_result }.to change(api_key, :expires_at).to(Time.current) + end + + it 'creates a new API key for organization' do + expect { service_result }.to change(ApiKey.unscoped, :count).by(1) + + expect(service_result.api_key) + .to be_persisted.and have_attributes(organization:, name:) + end + + it 'sends an API key rotated email' do + expect { service_result } + .to have_enqueued_mail(ApiKeyMailer, :rotated).with hash_including(params: {api_key:}) + end + end + + context 'with free organization' do + it 'expires the API key with current time' do + expect { service_result }.to change(api_key, :expires_at).to(Time.current) + end + + it 'creates a new API key for organization' do + expect { service_result }.to change(ApiKey.unscoped, :count).by(1) + + expect(service_result.api_key) + .to be_persisted.and have_attributes(organization:, name:) + end + + it 'sends an API key rotated email' do + expect { service_result } + .to have_enqueued_mail(ApiKeyMailer, :rotated).with hash_including(params: {api_key:}) + end + end end end context 'when API key is missing' do let(:api_key) { nil } + let(:expires_at) { double } it 'does not creates a new API key for organization' do expect { service_result }.not_to change(ApiKey, :count)