diff --git a/.env b/.env index fe9b06d4ff..3dc380b571 100644 --- a/.env +++ b/.env @@ -61,3 +61,12 @@ SMTP_PASSWORD="f00d" # NEW_RELIC_AGENT_ENABLED=true # NEW_RELIC_APP_NAME="Open Food Network" # NEW_RELIC_LICENSE_KEY="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + +# Database encryption configuration, required for VINE connected app +# Generate with bin/rails db:encryption:init +# ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" +# ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" +# ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + +# VINE API settings +# VINE_API_URL="https://vine-staging.openfoodnetwork.org.au/api/v1" diff --git a/.env.development b/.env.development index 68640acf21..94f1750d52 100644 --- a/.env.development +++ b/.env.development @@ -24,3 +24,8 @@ SITE_URL="0.0.0.0:3000" RACK_TIMEOUT_SERVICE_TIMEOUT="0" RACK_TIMEOUT_WAIT_TIMEOUT="0" RACK_TIMEOUT_WAIT_OVERTIME="0" + +# Database encryption configuration, required for VINE connected app +ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY="dev_primary_key" +ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY="dev_determinnistic_key" +ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT="dev_derivation_salt" diff --git a/.env.test b/.env.test index c0097a0416..d65627ce33 100644 --- a/.env.test +++ b/.env.test @@ -18,3 +18,7 @@ SITE_URL="test.host" OPENID_APP_ID="test-provider" OPENID_APP_SECRET="12345" OPENID_REFRESH_TOKEN="dummy-refresh-token" + +ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY="test_primary_key" +ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY="test_deterministic_key" +ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT="test_derivation_salt" diff --git a/app/controllers/admin/connected_apps_controller.rb b/app/controllers/admin/connected_apps_controller.rb index 3531c7371a..a5308d4984 100644 --- a/app/controllers/admin/connected_apps_controller.rb +++ b/app/controllers/admin/connected_apps_controller.rb @@ -5,12 +5,7 @@ module Admin def create authorize! :admin, enterprise - attributes = {} - attributes[:type] = connected_app_params[:type] if connected_app_params[:type] - - app = ConnectedApp.create!(enterprise_id: enterprise.id, **attributes) - app.connect(api_key: spree_current_user.spree_api_key, - channel: SessionChannel.for_request(request)) + connect render_panel end @@ -26,6 +21,47 @@ module Admin private + def create_connected_app + attributes = {} + attributes[:type] = connected_app_params[:type] if connected_app_params[:type] + + @app = ConnectedApp.create!(enterprise_id: enterprise.id, **attributes) + end + + def connect + return connect_vine if connected_app_params[:type] == "ConnectedApps::Vine" + + create_connected_app + @app.connect(api_key: spree_current_user.spree_api_key, + channel: SessionChannel.for_request(request)) + end + + def connect_vine + if vine_params_empty? + return flash[:error] = + I18n.t("admin.enterprises.form.connected_apps.vine.api_parameters_empty") + end + + create_connected_app + + jwt_service = VineJwtService.new(secret: connected_app_params[:vine_secret]) + vine_api = VineApiService.new(api_key: connected_app_params[:vine_api_key], + jwt_generator: jwt_service) + + if !@app.connect(api_key: connected_app_params[:vine_api_key], + secret: connected_app_params[:vine_secret], vine_api:) + error_message = "#{@app.errors.full_messages.to_sentence}. \ + #{I18n.t('admin.enterprises.form.connected_apps.vine.api_parameters_error')}".squish + handle_error(error_message) + end + rescue Faraday::Error => e + log_and_notify_exception(e) + handle_error(I18n.t("admin.enterprises.form.connected_apps.vine.connection_error")) + rescue KeyError => e + log_and_notify_exception(e) + handle_error(I18n.t("admin.enterprises.form.connected_apps.vine.setup_error")) + end + def enterprise @enterprise ||= Enterprise.find(params.require(:enterprise_id)) end @@ -34,8 +70,22 @@ module Admin redirect_to "#{edit_admin_enterprise_path(enterprise)}#/connected_apps_panel" end + def handle_error(message) + flash[:error] = message + @app.destroy + end + + def log_and_notify_exception(exception) + Rails.logger.error exception.inspect + Bugsnag.notify(exception) + end + + def vine_params_empty? + connected_app_params[:vine_api_key].empty? || connected_app_params[:vine_secret].empty? + end + def connected_app_params - params.permit(:type) + params.permit(:type, :vine_api_key, :vine_secret) end end end diff --git a/app/models/connected_app.rb b/app/models/connected_app.rb index 966df543b5..faf1b0abe7 100644 --- a/app/models/connected_app.rb +++ b/app/models/connected_app.rb @@ -4,13 +4,14 @@ # # Here we store keys and links to access the app. class ConnectedApp < ApplicationRecord - TYPES = ['discover_regen', 'affiliate_sales_data'].freeze + TYPES = ['discover_regen', 'affiliate_sales_data', 'vine'].freeze belongs_to :enterprise after_destroy :disconnect scope :discover_regen, -> { where(type: "ConnectedApp") } scope :affiliate_sales_data, -> { where(type: "ConnectedApps::AffiliateSalesData") } + scope :vine, -> { where(type: "ConnectedApps::Vine") } scope :connecting, -> { where(data: nil) } scope :ready, -> { where.not(data: nil) } diff --git a/app/models/connected_apps/vine.rb b/app/models/connected_apps/vine.rb new file mode 100644 index 0000000000..350d8ac6ba --- /dev/null +++ b/app/models/connected_apps/vine.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +# An enterprise can opt-in to use VINE API to manage vouchers +# +module ConnectedApps + class Vine < ConnectedApp + encrypts :data + + def connect(api_key:, secret:, vine_api:, **_opts) + response = vine_api.my_team + + return update data: { api_key:, secret: } if response.success? + + errors.add(:base, I18n.t("activerecord.errors.models.connected_apps.vine.api_request_error")) + + false + end + + def disconnect; end + end +end diff --git a/app/services/vine_api_service.rb b/app/services/vine_api_service.rb new file mode 100644 index 0000000000..8fa34ce48a --- /dev/null +++ b/app/services/vine_api_service.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require "faraday" + +class VineApiService + attr_reader :api_key, :jwt_generator + + def initialize(api_key:, jwt_generator:) + @vine_api_url = ENV.fetch("VINE_API_URL") + @api_key = api_key + @jwt_generator = jwt_generator + end + + def my_team + my_team_url = "#{@vine_api_url}/my-team" + + jwt = jwt_generator.generate_token + connection = Faraday.new( + request: { timeout: 30 }, + headers: { + 'X-Authorization': "JWT #{jwt}", + Accept: "application/json" + } + ) do |f| + f.request :json + f.response :json + f.request :authorization, 'Bearer', api_key + end + + response = connection.get(my_team_url) + + if !response.success? + Rails.logger.error "VineApiService#my_team -- response_status: #{response.status}" + Rails.logger.error "VineApiService#my_team -- response: #{response.body}" + end + + response + end +end diff --git a/app/services/vine_jwt_service.rb b/app/services/vine_jwt_service.rb new file mode 100644 index 0000000000..f65f04a7f6 --- /dev/null +++ b/app/services/vine_jwt_service.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class VineJwtService + ALGORITHM = "HS256" + ISSUER = "openfoodnetwork" + + def initialize(secret: ) + @secret = secret + end + + def generate_token + generation_time = Time.zone.now + payload = { + iss: ISSUER, + iat: generation_time.to_i, + exp: (generation_time + 1.minute).to_i, + } + + JWT.encode(payload, @secret, ALGORITHM) + end +end diff --git a/app/views/admin/enterprises/form/connected_apps/_vine.html.haml b/app/views/admin/enterprises/form/connected_apps/_vine.html.haml new file mode 100644 index 0000000000..1413e28676 --- /dev/null +++ b/app/views/admin/enterprises/form/connected_apps/_vine.html.haml @@ -0,0 +1,30 @@ +%section.connected_app + .connected-app__head + %div + %h3= t ".title" + %p= t ".tagline" + .connected-app__vine + - if connected_app.nil? + = form_with url: admin_enterprise_connected_apps_path(enterprise.id) do |f| + .connected-app__vine-content + .vine-api-key + = f.hidden_field :type, value: "ConnectedApps::Vine" + = f.label :vine_api_key, t(".vine_api_key") + %span.required * + = f.text_field :vine_api_key, { disabled: !managed_by_user?(enterprise) } + = f.label :vine_secret, t(".vine_secret") + %span.required * + = f.text_field :vine_secret, { disabled: !managed_by_user?(enterprise) } + %div + - disabled = managed_by_user?(enterprise) ? {} : { disabled: true, "data-disable-with": false } + = f.submit t(".enable"), disabled + + -# This is only seen by super-admins: + %em= t(".need_to_be_manager") unless managed_by_user?(enterprise) + - else + .connected-app__vine-content + .vine-disable + = button_to t(".disable"), admin_enterprise_connected_app_path(connected_app.id, enterprise_id: enterprise.id), method: :delete + %hr + .connected-app__description + = t ".description_html" diff --git a/app/webpacker/css/admin/connected_apps.scss b/app/webpacker/css/admin/connected_apps.scss index be3356009d..f68d902ad0 100644 --- a/app/webpacker/css/admin/connected_apps.scss +++ b/app/webpacker/css/admin/connected_apps.scss @@ -34,7 +34,9 @@ border: none; border-left: $border-radius solid $color-3; border-radius: $border-radius; - box-shadow: 0px 1px 0px rgba(0, 0, 0, 0.05), 0px 2px 2px rgba(0, 0, 0, 0.07); + box-shadow: + 0px 1px 0px rgba(0, 0, 0, 0.05), + 0px 2px 2px rgba(0, 0, 0, 0.07); margin: 2em 0; padding: 0.5em 1em; @@ -47,3 +49,22 @@ flex-shrink: 1; } } + +.connected-app__vine { + margin: 1em 0; + + .connected-app__vine-content { + display: flex; + justify-content: space-between; + align-items: end; + + .vine-api-key { + width: 100%; + margin-right: 1em; + } + + .vine-disable { + margin-left: auto; + } + } +} diff --git a/config/application.rb b/config/application.rb index a1bda0a7d5..19cd559d97 100644 --- a/config/application.rb +++ b/config/application.rb @@ -255,5 +255,16 @@ module Openfoodnetwork config.exceptions_app = self.routes config.view_component.generate.sidecar = true # Always generate components in subfolders + + # Database encryption configuration, required for VINE connected app + config.active_record.encryption.primary_key = ENV.fetch( + "ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY", nil + ) + config.active_record.encryption.deterministic_key = ENV.fetch( + "ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY", nil + ) + config.active_record.encryption.key_derivation_salt = ENV.fetch( + "ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT", nil + ) end end diff --git a/config/initializers/filter_parameter_logging.rb b/config/initializers/filter_parameter_logging.rb index e203fcee0a..b2319d12f3 100644 --- a/config/initializers/filter_parameter_logging.rb +++ b/config/initializers/filter_parameter_logging.rb @@ -1,2 +1,4 @@ +# frozen_string_literal: true + # Configure sensitive parameters which will be filtered from the log file. -Rails.application.config.filter_parameters += [:password] +Rails.application.config.filter_parameters += [:password, :vine_api_key, :vine_secret] diff --git a/config/locales/en.yml b/config/locales/en.yml index cba29dc7a3..ed089f5c34 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -128,6 +128,9 @@ en: count_on_hand: using_producer_stock_settings_but_count_on_hand_set: "must be blank because using producer stock settings" limited_stock_but_no_count_on_hand: "must be specified because forcing limited stock" + connected_apps: + vine: + api_request_error: "An error occured when connecting to Vine API" messages: confirmation: "doesn't match %{attribute}" blank: "can't be blank" @@ -783,6 +786,7 @@ en: connected_apps_enabled: discover_regen: Discover Regenerative portal affiliate_sales_data: DFC anonymised orders API for research purposes + vine: Voucher Integration Engine (VINE) update: resource: Connected app settings @@ -1446,6 +1450,26 @@ en: target="_blank">Learn more about Discover Regenerative

+ vine: + title: "Voucher Integration Engine (VINE)" + tagline: "Allow redemption of VINE vouchers in your shopfront." + enable: "Connect" + disable: "Disconnect" + need_to_be_manager: "Only managers can connect apps." + vine_api_key: "VINE API Key" + vine_secret: "VINE secret" + description_html: | +

+ To enable VINE for your enterprise, enter your API key and secret. +

+

+ VINE + +

+ api_parameters_empty: "Please enter an API key and a secret" + api_parameters_error: "Check you entered your API key and secret correctly, contact your instance manager if the error persists" + connection_error: "API connection error, please try again" + setup_error: "VINE API is not configured, please contact your instance manager" actions: edit_profile: Settings properties: Properties diff --git a/spec/fixtures/vcr_cassettes/VineApiService/_my_team/when_a_request_succeed/returns_the_response.yml b/spec/fixtures/vcr_cassettes/VineApiService/_my_team/when_a_request_succeed/returns_the_response.yml new file mode 100644 index 0000000000..99505aa934 --- /dev/null +++ b/spec/fixtures/vcr_cassettes/VineApiService/_my_team/when_a_request_succeed/returns_the_response.yml @@ -0,0 +1,53 @@ +--- +http_interactions: +- request: + method: get + uri: https://vine-staging.openfoodnetwork.org.au/api/v1/my-team + body: + encoding: US-ASCII + string: '' + headers: + X-Authorization: + - "" + Accept: + - application/json + User-Agent: + - Faraday v2.9.0 + Authorization: + - "" + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + response: + status: + code: 200 + message: OK + headers: + Server: + - nginx + Content-Type: + - application/json + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Vary: + - Accept-Encoding + Cache-Control: + - no-cache, private + Date: + - Mon, 07 Oct 2024 04:07:27 GMT + Access-Control-Allow-Origin: + - "*" + X-Frame-Options: + - SAMEORIGIN + X-Xss-Protection: + - 1; mode=block + X-Content-Type-Options: + - nosniff + body: + encoding: ASCII-8BIT + string: '{"meta":{"responseCode":200,"limit":50,"offset":0,"message":"","cached":true,"cached_at":"2024-10-07 + 04:07:27","availableRelations":["teamUsers.user","country"]},"data":{"id":9,"name":"Open + Food Network TEST","country_id":14,"created_at":"2024-10-05T03:39:50.000000Z","updated_at":"2024-10-05T03:39:50.000000Z","deleted_at":null}}' + recorded_at: Mon, 07 Oct 2024 04:07:27 GMT +recorded_with: VCR 6.2.0 diff --git a/spec/models/connected_apps/vine_spec.rb b/spec/models/connected_apps/vine_spec.rb new file mode 100644 index 0000000000..8b37afb0f5 --- /dev/null +++ b/spec/models/connected_apps/vine_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe ConnectedApps::Vine do + subject(:connected_app) { ConnectedApps::Vine.new(enterprise: create(:enterprise)) } + + let(:vine_api_key) { "12345" } + let(:secret) { "my_secret" } + let(:vine_api) { instance_double(VineApiService) } + + describe "#connect" do + it "send a request to VINE api" do + expect(vine_api).to receive(:my_team).and_return(mock_api_response(true)) + + connected_app.connect(api_key: vine_api_key, secret:, vine_api: ) + end + + context "when request succeed" do + it "store the vine api key and secret" do + allow(vine_api).to receive(:my_team).and_return(mock_api_response(true)) + + expect(connected_app.connect(api_key: vine_api_key, secret:, vine_api:)).to eq(true) + expect(connected_app.data).to eql({ "api_key" => vine_api_key, "secret" => secret }) + end + end + + context "when request fails" do + it "doesn't store the vine api key" do + allow(vine_api).to receive(:my_team).and_return(mock_api_response(false)) + + expect(connected_app.connect(api_key: vine_api_key, secret:, vine_api:)).to eq(false) + expect(connected_app.data).to be_nil + expect(connected_app.errors[:base]).to include( + "An error occured when connecting to Vine API" + ) + end + end + end + + def mock_api_response(success) + mock_response = instance_double(Faraday::Response) + allow(mock_response).to receive(:success?).and_return(success) + mock_response + end +end diff --git a/spec/requests/admin/connected_apps_controller_spec.rb b/spec/requests/admin/connected_apps_controller_spec.rb new file mode 100644 index 0000000000..05b7eefa0c --- /dev/null +++ b/spec/requests/admin/connected_apps_controller_spec.rb @@ -0,0 +1,202 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe "Admin ConnectedApp" do + let(:user) { create(:admin_user) } + let(:enterprise) { create(:enterprise, owner: user) } + let(:edit_enterprise_url) { "#{edit_admin_enterprise_url(enterprise)}#/connected_apps_panel" } + + before do + sign_in user + end + + describe "POST /admin/enterprises/:enterprise_id/connected_apps" do + context "with type ConnectedApps::Vine" do + let(:vine_api) { instance_double(VineApiService) } + + before do + allow(VineJwtService).to receive(:new).and_return(instance_double(VineJwtService)) + allow(VineApiService).to receive(:new).and_return(vine_api) + end + + it "creates a new connected app" do + allow(vine_api).to receive(:my_team).and_return(mock_api_response(true)) + + params = { + type: ConnectedApps::Vine, + vine_api_key: "12345678", + vine_secret: "my_secret" + } + post("/admin/enterprises/#{enterprise.id}/connected_apps", params: ) + + vine_app = ConnectedApps::Vine.find_by(enterprise_id: enterprise.id) + expect(vine_app).not_to be_nil + expect(vine_app.data["api_key"]).to eq("12345678") + expect(vine_app.data["secret"]).to eq("my_secret") + end + + it "redirects to enterprise edit page" do + allow(vine_api).to receive(:my_team).and_return(mock_api_response(true)) + + params = { + type: ConnectedApps::Vine, + vine_api_key: "12345678", + vine_secret: "my_secret" + } + post("/admin/enterprises/#{enterprise.id}/connected_apps", params: ) + + expect(response).to redirect_to(edit_enterprise_url) + end + + context "when api key is empty" do + it "redirects to enterprise edit page, with an error" do + params = { + type: ConnectedApps::Vine, + vine_api_key: "", + vine_secret: "my_secret" + } + post("/admin/enterprises/#{enterprise.id}/connected_apps", params: ) + + expect(response).to redirect_to(edit_enterprise_url) + expect(flash[:error]).to eq("Please enter an API key and a secret") + expect(ConnectedApps::Vine.find_by(enterprise_id: enterprise.id)).to be_nil + end + end + + context "when api secret is empty" do + it "redirects to enterprise edit page, with an error" do + params = { + type: ConnectedApps::Vine, + vine_api_key: "12345678", + vine_secret: "" + } + post("/admin/enterprises/#{enterprise.id}/connected_apps", params: ) + + expect(response).to redirect_to(edit_enterprise_url) + expect(flash[:error]).to eq("Please enter an API key and a secret") + expect(ConnectedApps::Vine.find_by(enterprise_id: enterprise.id)).to be_nil + end + end + + context "when api key or secret is not valid" do + before do + allow(vine_api).to receive(:my_team).and_return(mock_api_response(false)) + end + + it "doesn't create a new connected app" do + params = { + type: ConnectedApps::Vine, + vine_api_key: "12345678", + vine_secret: "my_secret" + } + post("/admin/enterprises/#{enterprise.id}/connected_apps", params: ) + + expect(ConnectedApps::Vine.find_by(enterprise_id: enterprise.id)).to be_nil + end + + it "redirects to enterprise edit page, with an error" do + params = { + type: ConnectedApps::Vine, + vine_api_key: "12345678", + vine_secret: "my_secret" + } + post("/admin/enterprises/#{enterprise.id}/connected_apps", params: ) + + expect(response).to redirect_to(edit_enterprise_url) + expect(flash[:error]).to eq( + "An error occured when connecting to Vine API. Check you entered your API key \ + and secret correctly, contact your instance manager if the error persists".squish + ) + end + end + + context "when VINE API is not set up properly" do + before do + allow(ENV).to receive(:fetch).and_call_original + allow(ENV).to receive(:fetch).with("VINE_API_URL").and_raise(KeyError) + allow(VineApiService).to receive(:new).and_call_original + end + + it "redirects to enterprise edit page, with an error" do + params = { + type: ConnectedApps::Vine, + vine_api_key: "12345678", + vine_secret: "my_secret" + } + post("/admin/enterprises/#{enterprise.id}/connected_apps", params: ) + + expect(response).to redirect_to(edit_enterprise_url) + expect(flash[:error]).to eq( + "VINE API is not configured, please contact your instance manager" + ) + end + + it "notifies Bugsnag" do + expect(Bugsnag).to receive(:notify) + + params = { + type: ConnectedApps::Vine, + vine_api_key: "12345678", + vine_secret: "my_secret" + } + post("/admin/enterprises/#{enterprise.id}/connected_apps", params: ) + end + end + + context "when there is a connection error" do + before do + allow(vine_api).to receive(:my_team).and_raise(Faraday::Error) + end + + it "redirects to enterprise edit page, with an error" do + params = { + type: ConnectedApps::Vine, + vine_api_key: "12345678", + vine_secret: "my_secret" + } + post("/admin/enterprises/#{enterprise.id}/connected_apps", params: ) + + expect(response).to redirect_to(edit_enterprise_url) + expect(flash[:error]).to eq("API connection error, please try again") + end + + it "notifies Bugsnag" do + expect(Bugsnag).to receive(:notify) + + params = { + type: ConnectedApps::Vine, + vine_api_key: "12345678", + vine_secret: "my_secret" + } + post("/admin/enterprises/#{enterprise.id}/connected_apps", params: ) + end + end + end + + describe "DELETE /admin/enterprises/:enterprise_id/connected_apps/:id" do + it "deletes the connected app" do + app = ConnectedApps::Vine.create!(enterprise:) + delete("/admin/enterprises/#{enterprise.id}/connected_apps/#{app.id}") + + expect(ConnectedApps::Vine.find_by(enterprise_id: enterprise.id)).to be_nil + end + + it "redirect to enterprise edit page" do + app = ConnectedApps::Vine.create!(enterprise:, + data: { + api_key: "12345", secret: "my_secret" + }) + delete("/admin/enterprises/#{enterprise.id}/connected_apps/#{app.id}") + + expect(response).to redirect_to(edit_enterprise_url) + end + end + end + + def mock_api_response(success) + mock_response = instance_double(Faraday::Response) + allow(mock_response).to receive(:success?).and_return(success) + mock_response + end +end diff --git a/spec/services/vine_api_service_spec.rb b/spec/services/vine_api_service_spec.rb new file mode 100644 index 0000000000..0614931719 --- /dev/null +++ b/spec/services/vine_api_service_spec.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe VineApiService do + subject(:vine_api) { described_class.new(api_key: vine_api_key, jwt_generator: jwt_service) } + + let(:vine_api_url) { "https://vine-staging.openfoodnetwork.org.au/api/v1" } + let(:vine_api_key) { "12345" } + let(:jwt_service) { VineJwtService.new(secret:) } + let(:secret) { "my_secret" } + + before do + allow(ENV).to receive(:fetch).and_call_original + allow(ENV).to receive(:fetch).with("VINE_API_URL").and_return(vine_api_url) + end + + describe "#my_team" do + let(:my_team_url) { "#{vine_api_url}/my-team" } + + it "send a request to the team VINE api endpoint" do + stub_request(:get, my_team_url).to_return(status: 200) + + vine_api.my_team + + expect(a_request( + :get, "https://vine-staging.openfoodnetwork.org.au/api/v1/my-team" + )).to have_been_made + end + + it "sends the VINE api key via a header" do + stub_request(:get, my_team_url).to_return(status: 200) + + vine_api.my_team + + expect(a_request(:get, "https://vine-staging.openfoodnetwork.org.au/api/v1/my-team").with( + headers: { Authorization: "Bearer #{vine_api_key}" } + )).to have_been_made + end + + it "sends JWT token via a header" do + token = "some.jwt.token" + stub_request(:get, my_team_url).to_return(status: 200) + + allow(jwt_service).to receive(:generate_token).and_return(token) + + vine_api.my_team + + expect(a_request(:get, "https://vine-staging.openfoodnetwork.org.au/api/v1/my-team").with( + headers: { 'X-Authorization': "JWT #{token}" } + )).to have_been_made + end + + context "when a request succeed", :vcr do + it "returns the response" do + response = vine_api.my_team + + expect(response.success?).to be(true) + expect(response.body).not_to be_empty + end + end + + context "when a request fails" do + it "logs the error" do + stub_request(:get, my_team_url).to_return(body: "error", status: 401) + + expect(Rails.logger).to receive(:error).twice + + response = vine_api.my_team + + expect(response.success?).to be(false) + end + + it "return the response" do + stub_request(:get, my_team_url).to_return(body: "error", status: 401) + response = vine_api.my_team + + expect(response.success?).to be(false) + expect(response.body).not_to be_empty + end + end + end +end diff --git a/spec/services/vine_jwt_service_spec.rb b/spec/services/vine_jwt_service_spec.rb new file mode 100644 index 0000000000..204dede96c --- /dev/null +++ b/spec/services/vine_jwt_service_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe VineJwtService do + describe "#generate_token" do + subject { described_class.new(secret: vine_secret) } + let(:vine_secret) { "some_secret" } + + it "generate a jwt token" do + expect(subject.generate_token).to be_a String + end + + it "includes issuing body" do + token = subject.generate_token + + payload = decode(token, vine_secret) + + expect(payload["iss"]).to eq("openfoodnetwork") + end + + it "includes issuing time" do + generate_time = Time.zone.now + Timecop.freeze(generate_time) do + token = subject.generate_token + + payload = decode(token, vine_secret) + + expect(payload["iat"].to_i).to eq(generate_time.to_i) + end + end + + it "includes expirations time" do + generate_time = Time.zone.now + Timecop.freeze(generate_time) do + token = subject.generate_token + + payload = decode(token, vine_secret) + + expect(payload["exp"].to_i).to eq((generate_time + 1.minute).to_i) + end + end + end + + def decode(token, secret) + JWT.decode( + token, + secret, + true, { algorithm: "HS256" } + ).first + end +end diff --git a/spec/support/vcr_setup.rb b/spec/support/vcr_setup.rb index bf2574a0b3..d9958751a6 100644 --- a/spec/support/vcr_setup.rb +++ b/spec/support/vcr_setup.rb @@ -45,4 +45,7 @@ VCR.configure do |config| config.filter_sensitive_data('') { |interaction| interaction.request.body.match(/"accessToken":"([^"]+)"/)&.public_send(:[], 1) } + config.filter_sensitive_data('') { |interaction| + interaction.request.headers["X-Authorization"]&.public_send(:[], 0) + } end