From 96ccea369143967ecce0ac50f252683544a64c86 Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Mon, 19 Feb 2024 17:14:50 +1100 Subject: [PATCH 01/10] Add controller to handle import of DFC products It's not doing anything yet, but this is the basic setup. --- .../admin/dfc_product_imports_controller.rb | 19 ++++++++++++++ app/models/spree/ability.rb | 2 ++ .../admin/dfc_product_imports/index.html.haml | 2 ++ .../product_import/_dfc_import_form.html.haml | 11 ++++++++ .../admin/product_import/index.html.haml | 2 ++ config/routes/admin.rb | 2 ++ spec/system/admin/dfc_product_import_spec.rb | 26 +++++++++++++++++++ 7 files changed, 64 insertions(+) create mode 100644 app/controllers/admin/dfc_product_imports_controller.rb create mode 100644 app/views/admin/dfc_product_imports/index.html.haml create mode 100644 app/views/admin/product_import/_dfc_import_form.html.haml create mode 100644 spec/system/admin/dfc_product_import_spec.rb diff --git a/app/controllers/admin/dfc_product_imports_controller.rb b/app/controllers/admin/dfc_product_imports_controller.rb new file mode 100644 index 0000000000..000dfc9bf4 --- /dev/null +++ b/app/controllers/admin/dfc_product_imports_controller.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Admin + class DfcProductImportsController < Spree::Admin::BaseController + + # Define model class for `can?` permissions: + def model_class + self.class + end + + def index + # The plan: + # + # * Fetch DFC catalog as JSON from URL. + # * First step: import all products for given enterprise. + # * Second step: render table and let user decide which ones to import. + end + end +end diff --git a/app/models/spree/ability.rb b/app/models/spree/ability.rb index 127b9c2766..037bfecdb6 100644 --- a/app/models/spree/ability.rb +++ b/app/models/spree/ability.rb @@ -239,6 +239,8 @@ module Spree can [:admin, :index, :guide, :import, :save, :save_data, :validate_data, :reset_absent_products], ProductImport::ProductImporter + can [:admin, :index], ::Admin::DfcProductImportsController + # Reports page can [:admin, :index, :show], ::Admin::ReportsController can [:admin, :show, :customers, :orders_and_distributors, :group_buys, :payments, diff --git a/app/views/admin/dfc_product_imports/index.html.haml b/app/views/admin/dfc_product_imports/index.html.haml new file mode 100644 index 0000000000..635129cd94 --- /dev/null +++ b/app/views/admin/dfc_product_imports/index.html.haml @@ -0,0 +1,2 @@ +%h2 Importing a DFC product catalog +%p Catalog size: 0 diff --git a/app/views/admin/product_import/_dfc_import_form.html.haml b/app/views/admin/product_import/_dfc_import_form.html.haml new file mode 100644 index 0000000000..5339a9a862 --- /dev/null +++ b/app/views/admin/product_import/_dfc_import_form.html.haml @@ -0,0 +1,11 @@ +%h3 Import from DFC catalog +%br + += form_with url: main_app.admin_dfc_product_imports_path, method: :get do |form| + = form.label :enterprise_id + = form.text_field :enterprise_id + %br + = form.label :catalog_url + = form.text_field :catalog_url + %br + = form.submit "Import" diff --git a/app/views/admin/product_import/index.html.haml b/app/views/admin/product_import/index.html.haml index ef4c6ed9c3..15e413fcf9 100644 --- a/app/views/admin/product_import/index.html.haml +++ b/app/views/admin/product_import/index.html.haml @@ -13,3 +13,5 @@ %br = render 'upload_form' + += render 'dfc_import_form' if spree_current_user.oidc_account.present? diff --git a/config/routes/admin.rb b/config/routes/admin.rb index 20dace8c6a..8d0a9f6705 100644 --- a/config/routes/admin.rb +++ b/config/routes/admin.rb @@ -67,6 +67,8 @@ Openfoodnetwork::Application.routes.draw do post '/product_import/save_data', to: 'product_import#save_data', as: 'product_import_save_async' post '/product_import/reset_absent', to: 'product_import#reset_absent_products', as: 'product_import_reset_async' + resources :dfc_product_imports, only: [:index] + constraints FeatureToggleConstraint.new(:admin_style_v3) do resources :products, to: 'products_v3#index', only: :index do patch :bulk_update, on: :collection diff --git a/spec/system/admin/dfc_product_import_spec.rb b/spec/system/admin/dfc_product_import_spec.rb new file mode 100644 index 0000000000..d279e5b228 --- /dev/null +++ b/spec/system/admin/dfc_product_import_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: false + +require 'system_helper' + +describe "DFC Product Import" do + let(:user) { create(:oidc_user, owned_enterprises: [enterprise]) } + let(:enterprise) { create(:enterprise) } + + before do + login_as user + end + + it "imports from given catalog" do + visit admin_product_import_path + + fill_in "enterprise_id", with: enterprise.id + + # We are testing against our own catalog for now but we want to replace + # this with the URL of another app when available. + fill_in "catalog_url", with: "/api/dfc/enterprises/#{enterprise.id}/supplied_products" + + click_button "Import" + + expect(page).to have_content "Importing a DFC product catalog" + end +end From 30e8f9eb2834a7002f950dd0bab0cad7ac858cac Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Tue, 20 Feb 2024 16:58:42 +1100 Subject: [PATCH 02/10] Importing products from DFC catalog Technical demonstration of a complete product export-import roundtrip which we could now do between OFN instances. --- .../admin/dfc_product_imports_controller.rb | 48 ++++++++++++++++++- .../admin/dfc_product_imports/index.html.haml | 3 +- .../app/services/supplied_product_builder.rb | 2 + spec/system/admin/dfc_product_import_spec.rb | 22 ++++++++- 4 files changed, 71 insertions(+), 4 deletions(-) diff --git a/app/controllers/admin/dfc_product_imports_controller.rb b/app/controllers/admin/dfc_product_imports_controller.rb index 000dfc9bf4..d76e42e704 100644 --- a/app/controllers/admin/dfc_product_imports_controller.rb +++ b/app/controllers/admin/dfc_product_imports_controller.rb @@ -1,8 +1,10 @@ # frozen_string_literal: true +require "private_address_check" +require "private_address_check/tcpsocket_ext" + module Admin class DfcProductImportsController < Spree::Admin::BaseController - # Define model class for `can?` permissions: def model_class self.class @@ -12,8 +14,52 @@ module Admin # The plan: # # * Fetch DFC catalog as JSON from URL. + enterprise = OpenFoodNetwork::Permissions.new(spree_current_user) + .managed_product_enterprises.is_primary_producer + .find(params.require(:enterprise_id)) + + catalog_url = params.require(:catalog_url) + + json_catalog = fetch_catalog(catalog_url) + graph = DfcIo.import(json_catalog) + # * First step: import all products for given enterprise. # * Second step: render table and let user decide which ones to import. + imported = graph.map do |subject| + import_product(subject, enterprise) + end + + @count = imported.compact.count + end + + private + + def fetch_catalog(url) + connection = Faraday.new( + request: { timeout: 30 }, + headers: { + 'Content-Type' => 'application/json', + 'Authorization' => "Bearer #{spree_current_user.oidc_account.token}", + } + ) + response = PrivateAddressCheck.only_public_connections do + connection.get(url) + end + + response.body + end + + # Most of this code is the same as in the DfcProvider::SuppliedProductsController. + def import_product(subject, enterprise) + return unless subject.is_a? DataFoodConsortium::Connector::SuppliedProduct + + variant = SuppliedProductBuilder.import_variant(subject, enterprise) + product = variant.product + + product.save! if product.new_record? + variant.save! if variant.new_record? + + variant end end end diff --git a/app/views/admin/dfc_product_imports/index.html.haml b/app/views/admin/dfc_product_imports/index.html.haml index 635129cd94..2b285ac8bc 100644 --- a/app/views/admin/dfc_product_imports/index.html.haml +++ b/app/views/admin/dfc_product_imports/index.html.haml @@ -1,2 +1,3 @@ %h2 Importing a DFC product catalog -%p Catalog size: 0 +%p Imported products: += @count diff --git a/engines/dfc_provider/app/services/supplied_product_builder.rb b/engines/dfc_provider/app/services/supplied_product_builder.rb index 4e2b3d947b..db5195d53f 100644 --- a/engines/dfc_provider/app/services/supplied_product_builder.rb +++ b/engines/dfc_provider/app/services/supplied_product_builder.rb @@ -87,6 +87,8 @@ class SuppliedProductBuilder < DfcBuilder end def self.taxon(supplied_product) + return unless supplied_product.productType + dfc_id = supplied_product.productType.semanticId Spree::Taxon.find_by(dfc_id: ) end diff --git a/spec/system/admin/dfc_product_import_spec.rb b/spec/system/admin/dfc_product_import_spec.rb index d279e5b228..d3aea24884 100644 --- a/spec/system/admin/dfc_product_import_spec.rb +++ b/spec/system/admin/dfc_product_import_spec.rb @@ -1,13 +1,20 @@ # frozen_string_literal: false require 'system_helper' +require_relative '../../../engines/dfc_provider/spec/support/authorization_helper' describe "DFC Product Import" do + include AuthorizationHelper + let(:user) { create(:oidc_user, owned_enterprises: [enterprise]) } let(:enterprise) { create(:enterprise) } + let(:source_product) { create(:product, supplier: enterprise) } before do login_as user + source_product # to be imported + allow(PrivateAddressCheck).to receive(:private_address?).and_return(false) + user.oidc_account.update!(token: allow_token_for(email: user.email)) end it "imports from given catalog" do @@ -17,10 +24,21 @@ describe "DFC Product Import" do # We are testing against our own catalog for now but we want to replace # this with the URL of another app when available. - fill_in "catalog_url", with: "/api/dfc/enterprises/#{enterprise.id}/supplied_products" + host = Rails.application.default_url_options[:host] + url = "http://#{host}/api/dfc/enterprises/#{enterprise.id}/catalog_items" + fill_in "catalog_url", with: url - click_button "Import" + # By feeding our own catalog to the import, we are effectively cloning the + # products. But the DFC product references the spree_product_id which + # make the importer create a variant for that product instead of creating + # a new independent product. + expect { + click_button "Import" + }.to change { + source_product.variants.count + }.by(1) expect(page).to have_content "Importing a DFC product catalog" + expect(page).to have_content "Imported products: 1" end end From d6da52929f76aecee7e6ee50f80f682d3b55dfb5 Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Wed, 13 Mar 2024 15:09:47 +1100 Subject: [PATCH 03/10] Allow local DFC import in development --- .../admin/dfc_product_imports_controller.rb | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app/controllers/admin/dfc_product_imports_controller.rb b/app/controllers/admin/dfc_product_imports_controller.rb index d76e42e704..ff5386f662 100644 --- a/app/controllers/admin/dfc_product_imports_controller.rb +++ b/app/controllers/admin/dfc_product_imports_controller.rb @@ -42,13 +42,21 @@ module Admin 'Authorization' => "Bearer #{spree_current_user.oidc_account.token}", } ) - response = PrivateAddressCheck.only_public_connections do + response = only_public_connections do connection.get(url) end response.body end + def only_public_connections + return yield if Rails.env.development? + + PrivateAddressCheck.only_public_connections do + yield + end + end + # Most of this code is the same as in the DfcProvider::SuppliedProductsController. def import_product(subject, enterprise) return unless subject.is_a? DataFoodConsortium::Connector::SuppliedProduct From 1c09b5d16c54fb431ecb41ee7adab8986b210595 Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Wed, 13 Mar 2024 16:00:21 +1100 Subject: [PATCH 04/10] Move DFC API request logic to service object I'm planning to add more to it. --- .../admin/dfc_product_imports_controller.rb | 25 +------------- .../dfc_provider/app/services/dfc_request.rb | 34 +++++++++++++++++++ 2 files changed, 35 insertions(+), 24 deletions(-) create mode 100644 engines/dfc_provider/app/services/dfc_request.rb diff --git a/app/controllers/admin/dfc_product_imports_controller.rb b/app/controllers/admin/dfc_product_imports_controller.rb index ff5386f662..1b3f8dfb88 100644 --- a/app/controllers/admin/dfc_product_imports_controller.rb +++ b/app/controllers/admin/dfc_product_imports_controller.rb @@ -20,7 +20,7 @@ module Admin catalog_url = params.require(:catalog_url) - json_catalog = fetch_catalog(catalog_url) + json_catalog = DfcRequest.new(spree_current_user).get(catalog_url) graph = DfcIo.import(json_catalog) # * First step: import all products for given enterprise. @@ -34,29 +34,6 @@ module Admin private - def fetch_catalog(url) - connection = Faraday.new( - request: { timeout: 30 }, - headers: { - 'Content-Type' => 'application/json', - 'Authorization' => "Bearer #{spree_current_user.oidc_account.token}", - } - ) - response = only_public_connections do - connection.get(url) - end - - response.body - end - - def only_public_connections - return yield if Rails.env.development? - - PrivateAddressCheck.only_public_connections do - yield - end - end - # Most of this code is the same as in the DfcProvider::SuppliedProductsController. def import_product(subject, enterprise) return unless subject.is_a? DataFoodConsortium::Connector::SuppliedProduct diff --git a/engines/dfc_provider/app/services/dfc_request.rb b/engines/dfc_provider/app/services/dfc_request.rb new file mode 100644 index 0000000000..48f666b115 --- /dev/null +++ b/engines/dfc_provider/app/services/dfc_request.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require "private_address_check" +require "private_address_check/tcpsocket_ext" + +# Request a JSON document from a DFC API with authentication. +class DfcRequest + def initialize(user) + @user = user + end + + def get(url) + connection = Faraday.new( + request: { timeout: 30 }, + headers: { + 'Content-Type' => 'application/json', + 'Authorization' => "Bearer #{@user.oidc_account.token}", + } + ) + response = only_public_connections do + connection.get(url) + end + + response.body + end + + private + + def only_public_connections(&) + return yield if Rails.env.development? + + PrivateAddressCheck.only_public_connections(&) + end +end From 2e101c5fe63a3db899fe25c902fad0a8d7baff35 Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Wed, 13 Mar 2024 16:56:28 +1100 Subject: [PATCH 05/10] Refresh OIDC token and try again Access tokens are only valid for half an hour. So if requesting a DFC API fails, it's likely due to an expired token and we refresh it. --- .env.test | 1 + .../dfc_provider/app/services/dfc_request.rb | 45 +++++++- .../spec/services/dfc_request_spec.rb | 53 +++++++++ .../refreshes_the_access_token_on_fail.yml | 105 ++++++++++++++++++ spec/support/vcr_setup.rb | 15 +++ 5 files changed, 214 insertions(+), 5 deletions(-) create mode 100644 engines/dfc_provider/spec/services/dfc_request_spec.rb create mode 100644 spec/fixtures/vcr_cassettes/DfcRequest/refreshes_the_access_token_on_fail.yml diff --git a/.env.test b/.env.test index 535d37a9e0..09881648aa 100644 --- a/.env.test +++ b/.env.test @@ -14,3 +14,4 @@ SITE_URL="test.host" OPENID_APP_ID="test-provider" OPENID_APP_SECRET="12345" +OPENID_REFRESH_TOKEN="dummy-refresh-token" diff --git a/engines/dfc_provider/app/services/dfc_request.rb b/engines/dfc_provider/app/services/dfc_request.rb index 48f666b115..8de0108d99 100644 --- a/engines/dfc_provider/app/services/dfc_request.rb +++ b/engines/dfc_provider/app/services/dfc_request.rb @@ -4,12 +4,31 @@ require "private_address_check" require "private_address_check/tcpsocket_ext" # Request a JSON document from a DFC API with authentication. +# +# All DFC API interactions are authenticated via OIDC tokens. If the user's +# access token is expired, we try to get a new one with the user's refresh +# token. class DfcRequest def initialize(user) @user = user end def get(url) + response = request(url) + + return response.body if response.status == 200 + + return "" if @user.oidc_account.updated_at > 15.minutes.ago + + refresh_access_token! + + response = request(url) + response.body + end + + private + + def request(url) connection = Faraday.new( request: { timeout: 30 }, headers: { @@ -17,18 +36,34 @@ class DfcRequest 'Authorization' => "Bearer #{@user.oidc_account.token}", } ) - response = only_public_connections do + + only_public_connections do connection.get(url) end - - response.body end - private - def only_public_connections(&) return yield if Rails.env.development? PrivateAddressCheck.only_public_connections(&) end + + def refresh_access_token! + strategy = OmniAuth::Strategies::OpenIDConnect.new( + Rails.application, + Devise.omniauth_configs[:openid_connect].options + # Don't try to call `Devise.omniauth(:openid_connect)` first. + # It results in an empty config hash and we lose our config. + ) + client = strategy.client + client.token_endpoint = strategy.config.token_endpoint + client.refresh_token = @user.oidc_account.refresh_token + + token = client.access_token! + + @user.oidc_account.update!( + token: token.access_token, + refresh_token: token.refresh_token + ) + end end diff --git a/engines/dfc_provider/spec/services/dfc_request_spec.rb b/engines/dfc_provider/spec/services/dfc_request_spec.rb new file mode 100644 index 0000000000..a4f0421fad --- /dev/null +++ b/engines/dfc_provider/spec/services/dfc_request_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" + +describe DfcRequest do + subject(:api) { DfcRequest.new(user) } + + let(:user) { build(:oidc_user) } + let(:account) { user.oidc_account } + + it "gets a DFC document" do + stub_request(:get, "http://example.net/api"). + to_return(status: 200, body: '{"@context":"/"}') + + expect(api.get("http://example.net/api")).to eq '{"@context":"/"}' + end + + it "refreshes the access token on fail", vcr: true do + # Live VCR recordings require the following secret ENV variables: + # - OPENID_APP_ID + # - OPENID_APP_SECRET + # - OPENID_REFRESH_TOKEN + # You can set them in the .env.test.local file. + + stub_request(:get, "http://example.net/api"). + to_return(status: 401) + + # A refresh is only attempted if the token is stale. + account.refresh_token = ENV.fetch("OPENID_REFRESH_TOKEN") + account.updated_at = 1.day.ago + + expect { + api.get("http://example.net/api") + }.to change { + account.token + }.and change { + account.refresh_token + } + end + + it "doesn't try to refresh the token when it's still fresh" do + stub_request(:get, "http://example.net/api"). + to_return(status: 401) + + user.oidc_account.updated_at = 1.minute.ago + + expect(api.get("http://example.net/api")).to eq "" + + # Trying to reach the OIDC server via network request to refresh the token + # would raise errors because we didn't setup Webmock or VCR. + # The absence of errors makes this test pass. + end +end diff --git a/spec/fixtures/vcr_cassettes/DfcRequest/refreshes_the_access_token_on_fail.yml b/spec/fixtures/vcr_cassettes/DfcRequest/refreshes_the_access_token_on_fail.yml new file mode 100644 index 0000000000..9a14071f73 --- /dev/null +++ b/spec/fixtures/vcr_cassettes/DfcRequest/refreshes_the_access_token_on_fail.yml @@ -0,0 +1,105 @@ +--- +http_interactions: +- request: + method: get + uri: https://login.lescommuns.org/auth/realms/data-food-consortium/.well-known/openid-configuration + body: + encoding: US-ASCII + string: '' + headers: + User-Agent: + - SWD 2.0.3 + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 200 + message: OK + headers: + Date: + - Fri, 15 Mar 2024 05:44:06 GMT + Content-Type: + - application/json;charset=UTF-8 + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Vary: + - Accept-Encoding + Set-Cookie: + - AUTH_SESSION_ID=1710481447.162.5206.870756|6055218c9898cae39f8ffd531999e49a; + Path=/; Secure; HttpOnly + Cache-Control: + - no-cache, must-revalidate, no-transform, no-store + Referrer-Policy: + - no-referrer + Strict-Transport-Security: + - max-age=15724800; includeSubDomains + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - SAMEORIGIN + X-Xss-Protection: + - 1; mode=block + body: + encoding: ASCII-8BIT + string: '{"issuer":"https://login.lescommuns.org/auth/realms/data-food-consortium","authorization_endpoint":"https://login.lescommuns.org/auth/realms/data-food-consortium/protocol/openid-connect/auth","token_endpoint":"https://login.lescommuns.org/auth/realms/data-food-consortium/protocol/openid-connect/token","introspection_endpoint":"https://login.lescommuns.org/auth/realms/data-food-consortium/protocol/openid-connect/token/introspect","userinfo_endpoint":"https://login.lescommuns.org/auth/realms/data-food-consortium/protocol/openid-connect/userinfo","end_session_endpoint":"https://login.lescommuns.org/auth/realms/data-food-consortium/protocol/openid-connect/logout","frontchannel_logout_session_supported":true,"frontchannel_logout_supported":true,"jwks_uri":"https://login.lescommuns.org/auth/realms/data-food-consortium/protocol/openid-connect/certs","check_session_iframe":"https://login.lescommuns.org/auth/realms/data-food-consortium/protocol/openid-connect/login-status-iframe.html","grant_types_supported":["authorization_code","implicit","refresh_token","password","client_credentials","urn:openid:params:grant-type:ciba","urn:ietf:params:oauth:grant-type:device_code"],"acr_values_supported":["0","1"],"response_types_supported":["code","none","id_token","token","id_token + token","code id_token","code token","code id_token token"],"subject_types_supported":["public","pairwise"],"id_token_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512"],"id_token_encryption_alg_values_supported":["RSA-OAEP","RSA-OAEP-256","RSA1_5"],"id_token_encryption_enc_values_supported":["A256GCM","A192GCM","A128GCM","A128CBC-HS256","A192CBC-HS384","A256CBC-HS512"],"userinfo_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512","none"],"userinfo_encryption_alg_values_supported":["RSA-OAEP","RSA-OAEP-256","RSA1_5"],"userinfo_encryption_enc_values_supported":["A256GCM","A192GCM","A128GCM","A128CBC-HS256","A192CBC-HS384","A256CBC-HS512"],"request_object_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512","none"],"request_object_encryption_alg_values_supported":["RSA-OAEP","RSA-OAEP-256","RSA1_5"],"request_object_encryption_enc_values_supported":["A256GCM","A192GCM","A128GCM","A128CBC-HS256","A192CBC-HS384","A256CBC-HS512"],"response_modes_supported":["query","fragment","form_post","query.jwt","fragment.jwt","form_post.jwt","jwt"],"registration_endpoint":"https://login.lescommuns.org/auth/realms/data-food-consortium/clients-registrations/openid-connect","token_endpoint_auth_methods_supported":["private_key_jwt","client_secret_basic","client_secret_post","tls_client_auth","client_secret_jwt"],"token_endpoint_auth_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512"],"introspection_endpoint_auth_methods_supported":["private_key_jwt","client_secret_basic","client_secret_post","tls_client_auth","client_secret_jwt"],"introspection_endpoint_auth_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512"],"authorization_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512"],"authorization_encryption_alg_values_supported":["RSA-OAEP","RSA-OAEP-256","RSA1_5"],"authorization_encryption_enc_values_supported":["A256GCM","A192GCM","A128GCM","A128CBC-HS256","A192CBC-HS384","A256CBC-HS512"],"claims_supported":["aud","sub","iss","auth_time","name","given_name","family_name","preferred_username","email","acr"],"claim_types_supported":["normal"],"claims_parameter_supported":true,"scopes_supported":["openid","microprofile-jwt","phone","roles","profile","email","address","web-origins","acr","offline_access"],"request_parameter_supported":true,"request_uri_parameter_supported":true,"require_request_uri_registration":true,"code_challenge_methods_supported":["plain","S256"],"tls_client_certificate_bound_access_tokens":true,"revocation_endpoint":"https://login.lescommuns.org/auth/realms/data-food-consortium/protocol/openid-connect/revoke","revocation_endpoint_auth_methods_supported":["private_key_jwt","client_secret_basic","client_secret_post","tls_client_auth","client_secret_jwt"],"revocation_endpoint_auth_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512"],"backchannel_logout_supported":true,"backchannel_logout_session_supported":true,"device_authorization_endpoint":"https://login.lescommuns.org/auth/realms/data-food-consortium/protocol/openid-connect/auth/device","backchannel_token_delivery_modes_supported":["poll","ping"],"backchannel_authentication_endpoint":"https://login.lescommuns.org/auth/realms/data-food-consortium/protocol/openid-connect/ext/ciba/auth","backchannel_authentication_request_signing_alg_values_supported":["PS384","ES384","RS384","ES256","RS256","ES512","PS256","PS512","RS512"],"require_pushed_authorization_requests":false,"pushed_authorization_request_endpoint":"https://login.lescommuns.org/auth/realms/data-food-consortium/protocol/openid-connect/ext/par/request","mtls_endpoint_aliases":{"token_endpoint":"https://login.lescommuns.org/auth/realms/data-food-consortium/protocol/openid-connect/token","revocation_endpoint":"https://login.lescommuns.org/auth/realms/data-food-consortium/protocol/openid-connect/revoke","introspection_endpoint":"https://login.lescommuns.org/auth/realms/data-food-consortium/protocol/openid-connect/token/introspect","device_authorization_endpoint":"https://login.lescommuns.org/auth/realms/data-food-consortium/protocol/openid-connect/auth/device","registration_endpoint":"https://login.lescommuns.org/auth/realms/data-food-consortium/clients-registrations/openid-connect","userinfo_endpoint":"https://login.lescommuns.org/auth/realms/data-food-consortium/protocol/openid-connect/userinfo","pushed_authorization_request_endpoint":"https://login.lescommuns.org/auth/realms/data-food-consortium/protocol/openid-connect/ext/par/request","backchannel_authentication_endpoint":"https://login.lescommuns.org/auth/realms/data-food-consortium/protocol/openid-connect/ext/ciba/auth"},"authorization_response_iss_parameter_supported":true}' + recorded_at: Fri, 15 Mar 2024 05:44:05 GMT +- request: + method: post + uri: https://login.lescommuns.org/auth/realms/data-food-consortium/protocol/openid-connect/token + body: + encoding: UTF-8 + string: grant_type=refresh_token&refresh_token= + headers: + User-Agent: + - Rack::OAuth2 (2.2.1) + Authorization: + - "" + Content-Type: + - application/x-www-form-urlencoded + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 200 + message: OK + headers: + Date: + - Fri, 15 Mar 2024 05:44:07 GMT + Content-Type: + - application/json + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Vary: + - Accept-Encoding + Set-Cookie: + - AUTH_SESSION_ID=1710481448.492.2309.531618|6055218c9898cae39f8ffd531999e49a; + Path=/; Secure; HttpOnly + Cache-Control: + - no-store + Pragma: + - no-cache + Referrer-Policy: + - no-referrer + Strict-Transport-Security: + - max-age=15724800; includeSubDomains + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - SAMEORIGIN + X-Xss-Protection: + - 1; mode=block + body: + encoding: ASCII-8BIT + string: '{"access_token":"","expires_in":1800,"refresh_expires_in":28510621,"refresh_token":"","token_type":"Bearer","id_token":"","not-before-policy":0,"session_state":"989db9a7-584c-4eeb-bff5-db77b53e8def","scope":"openid + profile email"}' + recorded_at: Fri, 15 Mar 2024 05:44:07 GMT +recorded_with: VCR 6.2.0 diff --git a/spec/support/vcr_setup.rb b/spec/support/vcr_setup.rb index 01cc2d9033..1b554f52f2 100644 --- a/spec/support/vcr_setup.rb +++ b/spec/support/vcr_setup.rb @@ -16,6 +16,9 @@ VCR.configure do |config| STRIPE_ACCOUNT STRIPE_CLIENT_ID STRIPE_ENDPOINT_SECRET + OPENID_APP_ID + OPENID_APP_SECRET + OPENID_REFRESH_TOKEN ].each do |env_var| config.filter_sensitive_data("") { ENV.fetch(env_var, nil) } end @@ -25,4 +28,16 @@ VCR.configure do |config| config.filter_sensitive_data('') { |interaction| interaction.response.body.match(/"client_secret": "(pi_.+)"/)&.public_send(:[], 1) } + config.filter_sensitive_data('') { |interaction| + interaction.request.headers['Authorization']&.public_send(:[], 0) + } + config.filter_sensitive_data('') { |interaction| + interaction.response.body.match(/"access_token":"([^"]+)"/)&.public_send(:[], 1) + } + config.filter_sensitive_data('') { |interaction| + interaction.response.body.match(/"id_token":"([^"]+)"/)&.public_send(:[], 1) + } + config.filter_sensitive_data('') { |interaction| + interaction.response.body.match(/"refresh_token":"([^"]+)"/)&.public_send(:[], 1) + } end From d47d3eba8f6a61b6aa3332934a3cb2912d74e6af Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Fri, 5 Jan 2024 16:17:37 +1100 Subject: [PATCH 06/10] Add SemanticLink model for variants We want to link variants/products to external DFC SuppliedProducts to trigger supplier orders when local stock is exhausted. This is the first step to enable the link. --- app/models/semantic_link.rb | 8 ++++++++ app/models/spree/variant.rb | 1 + db/migrate/20240105043228_create_semantic_links.rb | 12 ++++++++++++ db/schema.rb | 9 +++++++++ spec/models/semantic_link_spec.rb | 8 ++++++++ spec/models/spree/variant_spec.rb | 2 ++ 6 files changed, 40 insertions(+) create mode 100644 app/models/semantic_link.rb create mode 100644 db/migrate/20240105043228_create_semantic_links.rb create mode 100644 spec/models/semantic_link_spec.rb diff --git a/app/models/semantic_link.rb b/app/models/semantic_link.rb new file mode 100644 index 0000000000..ea8fa0bdba --- /dev/null +++ b/app/models/semantic_link.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +# Link a Spree::Variant to an external DFC SuppliedProduct. +class SemanticLink < ApplicationRecord + belongs_to :variant, class_name: "Spree::Variant" + + validates :semantic_id, presence: true +end diff --git a/app/models/spree/variant.rb b/app/models/spree/variant.rb index d91498e310..add27c8ad2 100644 --- a/app/models/spree/variant.rb +++ b/app/models/spree/variant.rb @@ -56,6 +56,7 @@ module Spree has_many :exchanges, through: :exchange_variants has_many :variant_overrides, dependent: :destroy has_many :inventory_items, dependent: :destroy + has_many :semantic_links, dependent: :delete_all localize_number :price, :weight diff --git a/db/migrate/20240105043228_create_semantic_links.rb b/db/migrate/20240105043228_create_semantic_links.rb new file mode 100644 index 0000000000..89ac707783 --- /dev/null +++ b/db/migrate/20240105043228_create_semantic_links.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class CreateSemanticLinks < ActiveRecord::Migration[7.0] + def change + create_table :semantic_links do |t| + t.references :variant, null: false, foreign_key: { to_table: :spree_variants } + t.string :semantic_id, null: false + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 0ab2fb2f2e..5c9ef39bf5 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -400,6 +400,14 @@ ActiveRecord::Schema[7.0].define(version: 2024_02_13_044159) do t.datetime "updated_at", precision: nil, null: false end + create_table "semantic_links", force: :cascade do |t| + t.bigint "variant_id", null: false + t.string "semantic_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["variant_id"], name: "index_semantic_links_on_variant_id" + end + create_table "sessions", id: :serial, force: :cascade do |t| t.string "session_id", limit: 255, null: false t.text "data" @@ -1168,6 +1176,7 @@ ActiveRecord::Schema[7.0].define(version: 2024_02_13_044159) do add_foreign_key "proxy_orders", "spree_orders", column: "order_id", name: "order_id_fk" add_foreign_key "proxy_orders", "subscriptions", name: "proxy_orders_subscription_id_fk" add_foreign_key "report_rendering_options", "spree_users", column: "user_id" + add_foreign_key "semantic_links", "spree_variants", column: "variant_id" add_foreign_key "spree_addresses", "spree_countries", column: "country_id", name: "spree_addresses_country_id_fk" add_foreign_key "spree_addresses", "spree_states", column: "state_id", name: "spree_addresses_state_id_fk" add_foreign_key "spree_inventory_units", "spree_orders", column: "order_id", name: "spree_inventory_units_order_id_fk", on_delete: :cascade diff --git a/spec/models/semantic_link_spec.rb b/spec/models/semantic_link_spec.rb new file mode 100644 index 0000000000..ac0eae743f --- /dev/null +++ b/spec/models/semantic_link_spec.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe SemanticLink, type: :model do + it { is_expected.to belong_to :variant } + it { is_expected.to validate_presence_of(:semantic_id) } +end diff --git a/spec/models/spree/variant_spec.rb b/spec/models/spree/variant_spec.rb index b2e93e01ba..696b4822d7 100644 --- a/spec/models/spree/variant_spec.rb +++ b/spec/models/spree/variant_spec.rb @@ -6,6 +6,8 @@ require 'spree/localized_number' describe Spree::Variant do subject(:variant) { build(:variant) } + it { is_expected.to have_many :semantic_links } + context "validations" do it "should validate price is greater than 0" do variant.price = -1 From b5c47b099e5594cc99a834f16e6ba95bea3c550b Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Fri, 15 Mar 2024 14:09:49 +1100 Subject: [PATCH 07/10] Store semantic link when importing DFC products --- engines/dfc_provider/app/services/supplied_product_builder.rb | 3 +++ .../spec/services/supplied_product_builder_spec.rb | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/engines/dfc_provider/app/services/supplied_product_builder.rb b/engines/dfc_provider/app/services/supplied_product_builder.rb index db5195d53f..d722179d22 100644 --- a/engines/dfc_provider/app/services/supplied_product_builder.rb +++ b/engines/dfc_provider/app/services/supplied_product_builder.rb @@ -38,6 +38,9 @@ class SuppliedProductBuilder < DfcBuilder product.supplier = supplier product.ensure_standard_variant product.variants.first + end.tap do |variant| + link = supplied_product.semanticId + variant.semantic_links.new(semantic_id: link) if link.present? end end diff --git a/engines/dfc_provider/spec/services/supplied_product_builder_spec.rb b/engines/dfc_provider/spec/services/supplied_product_builder_spec.rb index 97c2236209..ccc618998f 100644 --- a/engines/dfc_provider/spec/services/supplied_product_builder_spec.rb +++ b/engines/dfc_provider/spec/services/supplied_product_builder_spec.rb @@ -163,6 +163,10 @@ describe SuppliedProductBuilder do it "creates a new Spree::Product and variant" do expect(imported_variant).to be_a(Spree::Variant) expect(imported_variant.id).to be_nil + expect(imported_variant.semantic_links.size).to eq 1 + + link = imported_variant.semantic_links[0] + expect(link.semantic_id).to eq "https://example.net/tomato" imported_product = imported_variant.product expect(imported_product).to be_a(Spree::Product) From 3af7fa7521017779b55fcafe02e8e07dbc5eefa8 Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Fri, 15 Mar 2024 14:35:37 +1100 Subject: [PATCH 08/10] Offer nice select box for enterprise id --- .../admin/product_import_controller.rb | 2 ++ .../product_import/_dfc_import_form.html.haml | 17 +++++++++++------ config/locales/en.yml | 5 +++++ spec/system/admin/dfc_product_import_spec.rb | 4 ++-- 4 files changed, 20 insertions(+), 8 deletions(-) diff --git a/app/controllers/admin/product_import_controller.rb b/app/controllers/admin/product_import_controller.rb index 181870f3b2..c71e3ad015 100644 --- a/app/controllers/admin/product_import_controller.rb +++ b/app/controllers/admin/product_import_controller.rb @@ -10,6 +10,8 @@ module Admin @product_categories = Spree::Taxon.order('name ASC').pluck(:name).uniq @tax_categories = Spree::TaxCategory.order('name ASC').pluck(:name) @shipping_categories = Spree::ShippingCategory.order('name ASC').pluck(:name) + @producers = OpenFoodNetwork::Permissions.new(spree_current_user). + managed_product_enterprises.is_primary_producer.by_name.to_a end def import diff --git a/app/views/admin/product_import/_dfc_import_form.html.haml b/app/views/admin/product_import/_dfc_import_form.html.haml index 5339a9a862..3416a943e5 100644 --- a/app/views/admin/product_import/_dfc_import_form.html.haml +++ b/app/views/admin/product_import/_dfc_import_form.html.haml @@ -1,11 +1,16 @@ -%h3 Import from DFC catalog +%h3= t(".title") %br = form_with url: main_app.admin_dfc_product_imports_path, method: :get do |form| - = form.label :enterprise_id - = form.text_field :enterprise_id + = form.label :enterprise_id, t(".enterprise") + %span.required * %br - = form.label :catalog_url - = form.text_field :catalog_url + = form.select :enterprise_id, options_from_collection_for_select(@producers, :id, :name, @producers.first&.id), { "data-controller": "tom-select", class: "primary" } %br - = form.submit "Import" + %br + = form.label :catalog_url, t(".catalog_url") + %br + = form.text_field :catalog_url, size: 60 + %br + %br + = form.submit t(".import") diff --git a/config/locales/en.yml b/config/locales/en.yml index 5843a86b07..96b6ee7de5 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -929,6 +929,11 @@ en: product_categories: Product Categories tax_categories: Tax Categories shipping_categories: Shipping Categories + dfc_import_form: + title: "Import from DFC catalog" + enterprise: "Enterprise" + catalog_url: "DFC catalog URL" + import: "Import" import: review: Review import: Import diff --git a/spec/system/admin/dfc_product_import_spec.rb b/spec/system/admin/dfc_product_import_spec.rb index d3aea24884..195c8370c5 100644 --- a/spec/system/admin/dfc_product_import_spec.rb +++ b/spec/system/admin/dfc_product_import_spec.rb @@ -7,7 +7,7 @@ describe "DFC Product Import" do include AuthorizationHelper let(:user) { create(:oidc_user, owned_enterprises: [enterprise]) } - let(:enterprise) { create(:enterprise) } + let(:enterprise) { create(:supplier_enterprise) } let(:source_product) { create(:product, supplier: enterprise) } before do @@ -20,7 +20,7 @@ describe "DFC Product Import" do it "imports from given catalog" do visit admin_product_import_path - fill_in "enterprise_id", with: enterprise.id + select enterprise.name, from: "Enterprise" # We are testing against our own catalog for now but we want to replace # this with the URL of another app when available. From d2d2db8489a07001fa0fe6208d299d59bd0eeb2f Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Fri, 15 Mar 2024 15:03:26 +1100 Subject: [PATCH 09/10] Assign random product category on import if missing Failing in this case may be desired in some circumstances but most of the time we want compatibility and easy interoperability even when not all data matches. --- .../app/services/supplied_product_builder.rb | 7 +++--- .../services/supplied_product_builder_spec.rb | 22 ++++--------------- 2 files changed, 8 insertions(+), 21 deletions(-) diff --git a/engines/dfc_provider/app/services/supplied_product_builder.rb b/engines/dfc_provider/app/services/supplied_product_builder.rb index d722179d22..31c343b524 100644 --- a/engines/dfc_provider/app/services/supplied_product_builder.rb +++ b/engines/dfc_provider/app/services/supplied_product_builder.rb @@ -90,10 +90,11 @@ class SuppliedProductBuilder < DfcBuilder end def self.taxon(supplied_product) - return unless supplied_product.productType + dfc_id = supplied_product.productType&.semanticId - dfc_id = supplied_product.productType.semanticId - Spree::Taxon.find_by(dfc_id: ) + # Every product needs a primary taxon to be valid. So if we don't have + # one or can't find it we just take a random one. + Spree::Taxon.find_by(dfc_id:) || Spree::Taxon.first end private_class_method :product_type, :taxon diff --git a/engines/dfc_provider/spec/services/supplied_product_builder_spec.rb b/engines/dfc_provider/spec/services/supplied_product_builder_spec.rb index ccc618998f..08db382860 100644 --- a/engines/dfc_provider/spec/services/supplied_product_builder_spec.rb +++ b/engines/dfc_provider/spec/services/supplied_product_builder_spec.rb @@ -64,14 +64,6 @@ describe SuppliedProductBuilder do expect(product.productType).to eq soft_drink end - - context "when no taxon set" do - let(:taxon) { nil } - - it "returns nil" do - expect(product.productType).to be_nil - end - end end it "assigns an image_url type" do @@ -131,16 +123,6 @@ describe SuppliedProductBuilder do expect(product.primary_taxon).to eq(taxon) end - - describe "when no matching taxon" do - let(:product_type) { DfcLoader.connector.PRODUCT_TYPES.DRINK } - - it "set the taxon to nil" do - product = builder.import_product(supplied_product) - - expect(product.primary_taxon).to be_nil - end - end end end @@ -161,7 +143,10 @@ describe SuppliedProductBuilder do let(:product_type) { DfcLoader.connector.PRODUCT_TYPES.VEGETABLE.NON_LOCAL_VEGETABLE } it "creates a new Spree::Product and variant" do + create(:taxon) + expect(imported_variant).to be_a(Spree::Variant) + expect(imported_variant).to be_valid expect(imported_variant.id).to be_nil expect(imported_variant.semantic_links.size).to eq 1 @@ -170,6 +155,7 @@ describe SuppliedProductBuilder do imported_product = imported_variant.product expect(imported_product).to be_a(Spree::Product) + expect(imported_product).to be_valid expect(imported_product.id).to be_nil expect(imported_product.name).to eq("Tomato") expect(imported_product.description).to eq("Awesome tomato") From 8efc215a14826d6a522a93261ddf189cef6133e1 Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Fri, 15 Mar 2024 15:36:56 +1100 Subject: [PATCH 10/10] Include product submenu on product import confirmation --- app/views/admin/dfc_product_imports/index.html.haml | 8 ++++++-- config/locales/en.yml | 4 ++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/app/views/admin/dfc_product_imports/index.html.haml b/app/views/admin/dfc_product_imports/index.html.haml index 2b285ac8bc..60a3f7a05a 100644 --- a/app/views/admin/dfc_product_imports/index.html.haml +++ b/app/views/admin/dfc_product_imports/index.html.haml @@ -1,3 +1,7 @@ -%h2 Importing a DFC product catalog -%p Imported products: +- content_for :page_title do + #{t(".title")} + += render partial: 'spree/admin/shared/product_sub_menu' + +%p= t(".imported_products") = @count diff --git a/config/locales/en.yml b/config/locales/en.yml index 96b6ee7de5..70e19ded48 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -757,6 +757,10 @@ en: user_guide: User Guide map: Map + dfc_product_imports: + index: + title: "Importing a DFC product catalog" + imported_products: "Imported products:" enterprise_fees: index: title: "Enterprise Fees"