diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index f02c12d6..cba8ec61 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @crrood @wboereboom @AlexandrosMor @michaelpaul +* @Adyen/api-libraries-reviewers diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..5cc0ee2a --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,41 @@ +name: "CodeQL" + +on: + push: + branches: [ "develop", "main" ] + pull_request: + branches: [ "develop" ] + schedule: + - cron: "40 12 * * 0" + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ javascript ] + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + queries: +security-and-quality + + - name: Autobuild + uses: github/codeql-action/autobuild@v2 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 + with: + category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml index 89165a38..d02ec3d7 100644 --- a/.github/workflows/ruby.yml +++ b/.github/workflows/ruby.yml @@ -12,7 +12,7 @@ jobs: ruby: [2.5, 2.6, 2.7, '3.0', head] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - uses: ruby/setup-ruby@v1 with: ruby-version: ${{ matrix.ruby }} diff --git a/.github/workflows/rubygems_release.yml b/.github/workflows/rubygems_release.yml index e895053f..ff6d0c8c 100644 --- a/.github/workflows/rubygems_release.yml +++ b/.github/workflows/rubygems_release.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v3 - name: Release Gem on RubyGems if: contains(github.ref, 'refs/tags/v') diff --git a/lib/adyen/client.rb b/lib/adyen/client.rb index 6396fea1..78676a24 100644 --- a/lib/adyen/client.rb +++ b/lib/adyen/client.rb @@ -144,6 +144,13 @@ def call_adyen_api(service, action, request_data, headers, version, with_applica raise connection_error, "Connection to #{url} failed" end end + if action.fetch(:method) == "delete" + begin + response = conn.delete + rescue Faraday::ConnectionFailed => connection_error + raise connection_error, "Connection to #{url} failed" + end + end if action.fetch(:method) == "patch" begin response = conn.patch do |req| @@ -169,11 +176,16 @@ def call_adyen_api(service, action, request_data, headers, version, with_applica when 401 raise Adyen::AuthenticationError.new("Invalid API authentication; https://docs.adyen.com/user-management/how-to-get-the-api-key", request_data) when 403 - raise Adyen::PermissionError.new("Missing user permissions; https://docs.adyen.com/user-management/user-roles", request_data) + raise Adyen::PermissionError.new("Missing user permissions; https://docs.adyen.com/user-management/user-roles", request_data, response.body) end - - formatted_response = AdyenResult.new(response.body, response.headers, response.status) - + + # delete has no response.body (unless it throws an error) + if response.body == nil + formatted_response = AdyenResult.new("{}", response.headers, response.status) + else + formatted_response = AdyenResult.new(response.body, response.headers, response.status) + end + formatted_response end diff --git a/lib/adyen/errors.rb b/lib/adyen/errors.rb index c980d7b7..f20707ac 100644 --- a/lib/adyen/errors.rb +++ b/lib/adyen/errors.rb @@ -70,8 +70,8 @@ def initialize(msg, request) end class PermissionError < AdyenError - def initialize(msg, request) - super(request, nil, msg, 403) + def initialize(msg, request, response) + super(request, response, msg, 403) end end diff --git a/lib/adyen/services/checkout.rb b/lib/adyen/services/checkout.rb index 0f6f4e65..3fa21456 100644 --- a/lib/adyen/services/checkout.rb +++ b/lib/adyen/services/checkout.rb @@ -2,7 +2,7 @@ module Adyen class Checkout < Service - DEFAULT_VERSION = 68 + DEFAULT_VERSION = 70 def initialize(client, version = DEFAULT_VERSION) service = "Checkout" @@ -13,7 +13,7 @@ def initialize(client, version = DEFAULT_VERSION) ] with_application_info = [ - :payment_session, + :payment_session ] super(client, version, service, method_names, with_application_info) @@ -42,7 +42,7 @@ def payment_links(*args) else action = "paymentLinks" args[1] ||= {} # optional headers arg - @client.call_adyen_api(@service, action, args[0], args[1], @version, true) + @client.call_adyen_api(@service, action, args[0], args[1], @version) end end @@ -75,6 +75,10 @@ def apple_pay def modifications @modifications ||= Adyen::Modifications.new(@client, @version) end + + def stored_payment_methods + @stored_payment_methods ||= Adyen::StoredPaymentMethods.new(@client, @version) + end end class CheckoutDetail < Service @@ -93,6 +97,16 @@ def result(request, headers = {}) action = "payments/result" @client.call_adyen_api(@service, action, request, headers, @version) end + + def donations(request, headers = {}) + action = "donations" + @client.call_adyen_api(@service, action, request, headers, @version) + end + + def card_details(request, headers = {}) + action = "cardDetails" + @client.call_adyen_api(@service, action, request, headers, @version) + end end class CheckoutLink < Service @@ -104,12 +118,12 @@ def initialize(client, version = DEFAULT_VERSION) def get(linkId, headers = {}) action = { method: 'get', url: "paymentLinks/" + linkId } - @client.call_adyen_api(@service, action, {}, headers, @version, true) + @client.call_adyen_api(@service, action, {}, headers, @version) end def update(linkId, request, headers = {}) action = { method: 'patch', url: "paymentLinks/" + linkId } - @client.call_adyen_api(@service, action, request, headers, @version, false) + @client.call_adyen_api(@service, action, request, headers, @version) end end @@ -161,12 +175,12 @@ def initialize(client, version = DEFAULT_VERSION) def capture(linkId, request, headers = {}) action = "payments/" + linkId + "/captures" - @client.call_adyen_api(@service, action, request, headers, @version, false) + @client.call_adyen_api(@service, action, request, headers, @version) end def cancel(linkId, request, headers = {}) action = "payments/" + linkId + "/cancels" - @client.call_adyen_api(@service, action, request, headers, @version, false) + @client.call_adyen_api(@service, action, request, headers, @version) end def genericCancel(request, headers = {}) @@ -176,17 +190,35 @@ def genericCancel(request, headers = {}) def refund(linkId, request, headers = {}) action = "payments/" + linkId + "/refunds" - @client.call_adyen_api(@service, action, request, headers, @version, false) + @client.call_adyen_api(@service, action, request, headers, @version) end def reversal(linkId, request, headers = {}) action = "payments/" + linkId + "/reversals" - @client.call_adyen_api(@service, action, request, headers, @version, false) + @client.call_adyen_api(@service, action, request, headers, @version) end def amountUpdate(linkId, request, headers = {}) action = "payments/" + linkId + "/amountUpdates" - @client.call_adyen_api(@service, action, request, headers, @version, false) + @client.call_adyen_api(@service, action, request, headers, @version) + end + end + + class StoredPaymentMethods < Service + def initialize(client, version = DEFAULT_VERSION) + @service = "Checkout" + @client = client + @version = version + end + + def get(query_array={}, headers = {}) + action = { method: 'get', url: "storedPaymentMethods" + create_query_string(query_array)} + @client.call_adyen_api(@service, action, {}, headers, @version) + end + + def delete(recurringId, query_array={}, headers = {}) + action = { method: 'delete', url: "storedPaymentMethods/%s" % recurringId + create_query_string(query_array)} + @client.call_adyen_api(@service, action, {}, headers, @version) end end -end +end \ No newline at end of file diff --git a/lib/adyen/services/service.rb b/lib/adyen/services/service.rb index 746d754b..be1981c2 100644 --- a/lib/adyen/services/service.rb +++ b/lib/adyen/services/service.rb @@ -24,5 +24,10 @@ def initialize(client, version, service, method_names, with_application_info = [ end end end + + # create query parameter from an array + def create_query_string(arr) + "?" + URI.encode_www_form(arr) + end end end diff --git a/lib/adyen/utils/hmac_validator.rb b/lib/adyen/utils/hmac_validator.rb index 75a02702..188afa7d 100644 --- a/lib/adyen/utils/hmac_validator.rb +++ b/lib/adyen/utils/hmac_validator.rb @@ -22,16 +22,15 @@ def calculate_notification_hmac(notification_request_item, hmac_key) end def data_to_sign(notification_request_item) - NOTIFICATION_VALIDATION_KEYS.map { |key| fetch(notification_request_item, key).to_s } - .map { |value| value.gsub('\\', '\\\\').gsub(':', '\\:') } + data = NOTIFICATION_VALIDATION_KEYS.map { |key| fetch(notification_request_item, key).to_s } .join(DATA_SEPARATOR) + return data end private def fetch(hash, keys) value = hash - keys.to_s.split('.').each do |key| value = if key.to_i.to_s == key value[key.to_i] diff --git a/lib/adyen/version.rb b/lib/adyen/version.rb index d7e05b4a..c562191d 100644 --- a/lib/adyen/version.rb +++ b/lib/adyen/version.rb @@ -1,4 +1,4 @@ module Adyen NAME = "adyen-ruby-api-library" - VERSION = "6.2.0".freeze -end + VERSION = "6.3.0".freeze +end \ No newline at end of file diff --git a/spec/checkout_spec.rb b/spec/checkout_spec.rb index 412fd7b9..36e592c2 100644 --- a/spec/checkout_spec.rb +++ b/spec/checkout_spec.rb @@ -597,6 +597,56 @@ to eq("12345") end + it "makes a get storedPaymentMethods call" do + response_body = json_from_file("mocks/responses/Checkout/stored_payment_methods.json") + + url = @shared_values[:client].service_url(@shared_values[:service], "storedPaymentMethods?merchantAccount=TestMerchantAccount&shopperReference=test-1234", @shared_values[:client].checkout.version) + WebMock.stub_request(:get, url). + with( + headers: { + "x-api-key" => @shared_values[:client].api_key + } + ). + to_return( + body: response_body + ) + + result = @shared_values[:client].checkout.stored_payment_methods.get({"merchantAccount" => "TestMerchantAccount", "shopperReference" => "test-1234"}) + response_hash = result.response + + expect(result.status). + to eq(200) + expect(response_hash). + to eq(JSON.parse(response_body)) + expect(response_hash). + to be_a Adyen::HashWithAccessors + expect(response_hash). + to be_a_kind_of Hash + expect(response_hash["shopperReference"]). + to eq("test-1234") + end + + it "makes a delete storedPaymentMethods call" do + response_body = json_from_file("mocks/responses/Checkout/stored_payment_methods.json") + + url = @shared_values[:client].service_url(@shared_values[:service], "storedPaymentMethods/RL8FW7WZM6KXWD82?merchantAccount=TestMerchantAccount&shopperReference=test-1234", @shared_values[:client].checkout.version) + WebMock.stub_request(:delete, url). + with( + headers: { + "x-api-key" => @shared_values[:client].api_key + } + ). + to_return( + body: response_body + ) + + result = @shared_values[:client].checkout.stored_payment_methods.delete("RL8FW7WZM6KXWD82", {"merchantAccount" => "TestMerchantAccount", "shopperReference" => "test-1234"}) + response_hash = result.response + + expect(result.status). + to eq(200) + end + # create client for automated tests client = create_client(:api_key) diff --git a/spec/client_spec.rb b/spec/client_spec.rb index e2c72349..591bbb77 100644 --- a/spec/client_spec.rb +++ b/spec/client_spec.rb @@ -106,7 +106,7 @@ mock_response = Faraday::Response.new(status: 200) expect(Adyen::AdyenResult).to receive(:new) - expect(Faraday).to receive(:new).with("http://localhost:3001/v68/payments/details", connection_options).and_return(mock_faraday_connection) + expect(Faraday).to receive(:new).with("http://localhost:3001/v70/payments/details", connection_options).and_return(mock_faraday_connection) expect(mock_faraday_connection).to receive(:post).and_return(mock_response) client.checkout.payments.details(request_body) end diff --git a/spec/errors_spec.rb b/spec/errors_spec.rb index 21cacfe5..3ca523c4 100644 --- a/spec/errors_spec.rb +++ b/spec/errors_spec.rb @@ -33,7 +33,7 @@ expect(Adyen::AdyenError.new(@shared_values[:request], nil, nil, 'code').to_s).to eq("Adyen::AdyenError code:code, request:#{@shared_values[:request]}") end it 'uses the proper error class name' do - expect(Adyen::PermissionError.new('message', @shared_values[:request]).to_s).to eq("Adyen::PermissionError code:403, msg:message, request:#{@shared_values[:request]}") + expect(Adyen::PermissionError.new('message', @shared_values[:request], 'response').to_s).to eq("Adyen::PermissionError code:403, msg:message, request:#{@shared_values[:request]}, response:response") end end describe '#masking' do diff --git a/spec/mocks/responses/Checkout/stored_payment_methods.json b/spec/mocks/responses/Checkout/stored_payment_methods.json new file mode 100644 index 00000000..a6df9a3f --- /dev/null +++ b/spec/mocks/responses/Checkout/stored_payment_methods.json @@ -0,0 +1 @@ +{"merchantAccount":"TestMerchantAccount", "shopperReference":"test-1234"} \ No newline at end of file diff --git a/spec/mocks/responses/Webhooks/backslash_notification.json b/spec/mocks/responses/Webhooks/backslash_notification.json new file mode 100644 index 00000000..f183465e --- /dev/null +++ b/spec/mocks/responses/Webhooks/backslash_notification.json @@ -0,0 +1,41 @@ +{ + "additionalData": { + "acquirerCode": "TestPmmAcquirer", + "acquirerReference": "DZMKWLXW6N6", + "authCode": "076181", + "avsResult": "5 No AVS data provided", + "avsResultRaw": "5", + "cardSummary": "1111", + "checkout.cardAddedBrand": "visa", + "cvcResult": "1 Matches", + "cvcResultRaw": "M", + "expiryDate": "03/2030", + "hmacSignature": "nIgT81gaB5oJpn2jPXupDq68iRo2wUlBsuYjtYfwKqo=", + "paymentMethod": "visa", + "refusalReasonRaw": "AUTHORISED", + "retry.attempt1.acquirer": "TestPmmAcquirer", + "retry.attempt1.acquirerAccount": "TestPmmAcquirerAccount", + "retry.attempt1.avsResultRaw": "5", + "retry.attempt1.rawResponse": "AUTHORISED", + "retry.attempt1.responseCode": "Approved", + "retry.attempt1.scaExemptionRequested": "lowValue", + "scaExemptionRequested": "lowValue" + }, + "amount": { + "currency": "EUR", + "value": 1000 + }, + "eventCode": "AUTHORISATION", + "eventDate": "2023-01-09T16:27:29+01:00", + "merchantAccountCode": "AntoniStroinski", + "merchantReference": "\\\\slashes are fun", + "operations": [ + "CANCEL", + "CAPTURE", + "REFUND" + ], + "paymentMethod": "visa", + "pspReference": "T7FD4VM4D3RZNN82", + "reason": "076181:1111:03/2030", + "success": "true" +} \ No newline at end of file diff --git a/spec/mocks/responses/Webhooks/colon_notification.json b/spec/mocks/responses/Webhooks/colon_notification.json new file mode 100644 index 00000000..3e717c8c --- /dev/null +++ b/spec/mocks/responses/Webhooks/colon_notification.json @@ -0,0 +1,41 @@ +{ + "additionalData": { + "acquirerCode": "TestPmmAcquirer", + "acquirerReference": "8NQH5BNF58M", + "authCode": "039404", + "avsResult": "5 No AVS data provided", + "avsResultRaw": "5", + "cardSummary": "1111", + "checkout.cardAddedBrand": "visa", + "cvcResult": "1 Matches", + "cvcResultRaw": "M", + "expiryDate": "03/2030", + "hmacSignature": "2EQYm7YJpKO4EtHSPu55SQTyWf8dkW5u2nD1tJFpViA=", + "paymentMethod": "visa", + "refusalReasonRaw": "AUTHORISED", + "retry.attempt1.acquirer": "TestPmmAcquirer", + "retry.attempt1.acquirerAccount": "TestPmmAcquirerAccount", + "retry.attempt1.avsResultRaw": "5", + "retry.attempt1.rawResponse": "AUTHORISED", + "retry.attempt1.responseCode": "Approved", + "retry.attempt1.scaExemptionRequested": "lowValue", + "scaExemptionRequested": "lowValue" + }, + "amount": { + "currency": "EUR", + "value": 1000 + }, + "eventCode": "AUTHORISATION", + "eventDate": "2023-01-10T13:40:54+01:00", + "merchantAccountCode": "AntoniStroinski", + "merchantReference": ":slashes are fun", + "operations": [ + "CANCEL", + "CAPTURE", + "REFUND" + ], + "paymentMethod": "visa", + "pspReference": "M8NB66SBZSGLNK82", + "reason": "039404:1111:03/2030", + "success": "true" + } \ No newline at end of file diff --git a/spec/mocks/responses/Webhooks/forwardslash_notification.json b/spec/mocks/responses/Webhooks/forwardslash_notification.json new file mode 100644 index 00000000..674ab7df --- /dev/null +++ b/spec/mocks/responses/Webhooks/forwardslash_notification.json @@ -0,0 +1,41 @@ +{ + "amount": { + "value": 1000, + "currency": "EUR" + }, + "reason": "087330:1111:03/2030", + "success": "true", + "eventCode": "AUTHORISATION", + "eventDate": "2023-01-10T13:37:30+01:00", + "operations": [ + "CANCEL", + "CAPTURE", + "REFUND" + ], + "pspReference": "X3GWNS6KJ8NKGK82", + "paymentMethod": "visa", + "additionalData": { + "authCode": "087330", + "avsResult": "5 No AVS data provided", + "cvcResult": "1 Matches", + "expiryDate": "03/2030", + "cardSummary": "1111", + "acquirerCode": "TestPmmAcquirer", + "avsResultRaw": "5", + "cvcResultRaw": "M", + "hmacSignature": "9Z0xdpG9Xi3zcmXv14t/BvMBut77O/Xq9D4CQXSDUi4=", + "paymentMethod": "visa", + "refusalReasonRaw": "AUTHORISED", + "acquirerReference": "HHCCC326PH6", + "scaExemptionRequested": "lowValue", + "checkout.cardAddedBrand": "visa", + "retry.attempt1.acquirer": "TestPmmAcquirer", + "retry.attempt1.rawResponse": "AUTHORISED", + "retry.attempt1.avsResultRaw": "5", + "retry.attempt1.responseCode": "Approved", + "retry.attempt1.acquirerAccount": "TestPmmAcquirerAccount", + "retry.attempt1.scaExemptionRequested": "lowValue" + }, + "merchantReference": "//slashes are fun", + "merchantAccountCode": "AntoniStroinski" + } \ No newline at end of file diff --git a/spec/mocks/responses/Webhooks/mixed_notification.json b/spec/mocks/responses/Webhooks/mixed_notification.json new file mode 100644 index 00000000..5b1c53e7 --- /dev/null +++ b/spec/mocks/responses/Webhooks/mixed_notification.json @@ -0,0 +1,41 @@ +{ + "additionalData": { + "acquirerCode": "TestPmmAcquirer", + "acquirerReference": "J8DXDJ2PV6P", + "authCode": "052095", + "avsResult": "5 No AVS data provided", + "avsResultRaw": "5", + "cardSummary": "1111", + "checkout.cardAddedBrand": "visa", + "cvcResult": "1 Matches", + "cvcResultRaw": "M", + "expiryDate": "03/2030", + "hmacSignature": "CZErGCNQaSsxbaQfZaJlakqo7KPP+mIa8a+wx3yNs9A=", + "paymentMethod": "visa", + "refusalReasonRaw": "AUTHORISED", + "retry.attempt1.acquirer": "TestPmmAcquirer", + "retry.attempt1.acquirerAccount": "TestPmmAcquirerAccount", + "retry.attempt1.avsResultRaw": "5", + "retry.attempt1.rawResponse": "AUTHORISED", + "retry.attempt1.responseCode": "Approved", + "retry.attempt1.scaExemptionRequested": "lowValue", + "scaExemptionRequested": "lowValue" + }, + "amount": { + "currency": "EUR", + "value": 1000 + }, + "eventCode": "AUTHORISATION", + "eventDate": "2023-01-10T13:42:29+01:00", + "merchantAccountCode": "AntoniStroinski", + "merchantReference": "\\:/\\/slashes are fun", + "operations": [ + "CANCEL", + "CAPTURE", + "REFUND" + ], + "paymentMethod": "visa", + "pspReference": "ZVWN7D3WSMK2WN82", + "reason": "052095:1111:03/2030", + "success": "true" + } \ No newline at end of file diff --git a/spec/utils/hmac_validator_spec.rb b/spec/utils/hmac_validator_spec.rb index 9fcb258e..d97099bd 100644 --- a/spec/utils/hmac_validator_spec.rb +++ b/spec/utils/hmac_validator_spec.rb @@ -28,12 +28,6 @@ expect(data_to_sign).to eq '7914073381342284::TestMerchant:TestPayment-1407325143704:1130:EUR:AUTHORISATION:true' end - it 'should get correct data with escaped characters' do - notification_request_item['merchantAccountCode'] = 'Test:\\Merchant' - data_to_sign = validator.data_to_sign(notification_request_item) - expect(data_to_sign).to eq '7914073381342284::Test\\:\\Merchant:TestPayment-1407325143704:1130:EUR:AUTHORISATION:true' - end - it 'should encrypt properly' do encrypted = validator.calculate_notification_hmac(notification_request_item, key) expect(encrypted).to eq expected_sign @@ -48,5 +42,25 @@ expect(validator.valid_notification_hmac?(notification_request_item, key)).to be false end + + it 'should validate backslashes correctly' do + webhook = JSON.parse(json_from_file("mocks/responses/Webhooks/backslash_notification.json")) + expect(validator.valid_notification_hmac?(webhook, '74F490DD33F7327BAECC88B2947C011FC02D014A473AAA33A8EC93E4DC069174')).to be true + end + + it 'should validate colons correctly' do + webhook = JSON.parse(json_from_file("mocks/responses/Webhooks/colon_notification.json")) + expect(validator.valid_notification_hmac?(webhook, '74F490DD33F7327BAECC88B2947C011FC02D014A473AAA33A8EC93E4DC069174')).to be true + end + + it 'should validate forward slashes correctly' do + webhook = JSON.parse(json_from_file("mocks/responses/Webhooks/forwardslash_notification.json")) + expect(validator.valid_notification_hmac?(webhook, '74F490DD33F7327BAECC88B2947C011FC02D014A473AAA33A8EC93E4DC069174')).to be true + end + + it 'should validate mix of slashes and colon correctly' do + webhook = JSON.parse(json_from_file("mocks/responses/Webhooks/mixed_notification.json")) + expect(validator.valid_notification_hmac?(webhook, '74F490DD33F7327BAECC88B2947C011FC02D014A473AAA33A8EC93E4DC069174')).to be true + end end end