mirror of
https://github.com/openfoodfoundation/openfoodnetwork
synced 2026-03-01 02:03:22 +00:00
Merge pull request #12886 from rioug/12855-VINE-connected-app
[Citi OFN Voucher] Add VINE connected app
This commit is contained in:
9
.env
9
.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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) }
|
||||
|
||||
21
app/models/connected_apps/vine.rb
Normal file
21
app/models/connected_apps/vine.rb
Normal file
@@ -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
|
||||
39
app/services/vine_api_service.rb
Normal file
39
app/services/vine_api_service.rb
Normal file
@@ -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
|
||||
21
app/services/vine_jwt_service.rb
Normal file
21
app/services/vine_jwt_service.rb
Normal file
@@ -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
|
||||
@@ -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"
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"><b>Learn more about Discover Regenerative</b>
|
||||
<i class="icon-external-link"></i></a>
|
||||
</p>
|
||||
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: |
|
||||
<p>
|
||||
To enable VINE for your enterprise, enter your API key and secret.
|
||||
</p>
|
||||
<p>
|
||||
<a href="#" target="_blank"><b>VINE</b>
|
||||
<i class="icon-external-link"></i></a>
|
||||
</p>
|
||||
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
|
||||
|
||||
@@ -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:
|
||||
- "<HIDDEN-VINE-TOKEN>"
|
||||
Accept:
|
||||
- application/json
|
||||
User-Agent:
|
||||
- Faraday v2.9.0
|
||||
Authorization:
|
||||
- "<HIDDEN-AUTHORIZATION-HEADER>"
|
||||
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
|
||||
46
spec/models/connected_apps/vine_spec.rb
Normal file
46
spec/models/connected_apps/vine_spec.rb
Normal file
@@ -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
|
||||
202
spec/requests/admin/connected_apps_controller_spec.rb
Normal file
202
spec/requests/admin/connected_apps_controller_spec.rb
Normal file
@@ -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
|
||||
83
spec/services/vine_api_service_spec.rb
Normal file
83
spec/services/vine_api_service_spec.rb
Normal file
@@ -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
|
||||
52
spec/services/vine_jwt_service_spec.rb
Normal file
52
spec/services/vine_jwt_service_spec.rb
Normal file
@@ -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
|
||||
@@ -45,4 +45,7 @@ VCR.configure do |config|
|
||||
config.filter_sensitive_data('<HIDDEN-OPENID-TOKEN>') { |interaction|
|
||||
interaction.request.body.match(/"accessToken":"([^"]+)"/)&.public_send(:[], 1)
|
||||
}
|
||||
config.filter_sensitive_data('<HIDDEN-VINE-TOKEN>') { |interaction|
|
||||
interaction.request.headers["X-Authorization"]&.public_send(:[], 0)
|
||||
}
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user