Merge pull request #12886 from rioug/12855-VINE-connected-app

[Citi OFN Voucher] Add VINE connected app
This commit is contained in:
Rachel Arnould
2024-10-14 15:32:09 +02:00
committed by GitHub
19 changed files with 687 additions and 10 deletions

9
.env
View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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

View File

@@ -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) }

View 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

View 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

View 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

View File

@@ -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"

View File

@@ -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;
}
}
}

View File

@@ -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

View File

@@ -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]

View File

@@ -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

View File

@@ -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

View 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

View 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

View 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

View 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

View File

@@ -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