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/app/controllers/admin/dfc_product_imports_controller.rb b/app/controllers/admin/dfc_product_imports_controller.rb new file mode 100644 index 0000000000..1b3f8dfb88 --- /dev/null +++ b/app/controllers/admin/dfc_product_imports_controller.rb @@ -0,0 +1,50 @@ +# 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 + end + + def index + # 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 = DfcRequest.new(spree_current_user).get(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 + + # 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/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/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/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/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/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..60a3f7a05a --- /dev/null +++ b/app/views/admin/dfc_product_imports/index.html.haml @@ -0,0 +1,7 @@ +- content_for :page_title do + #{t(".title")} + += render partial: 'spree/admin/shared/product_sub_menu' + +%p= t(".imported_products") += @count 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..3416a943e5 --- /dev/null +++ b/app/views/admin/product_import/_dfc_import_form.html.haml @@ -0,0 +1,16 @@ +%h3= t(".title") +%br + += form_with url: main_app.admin_dfc_product_imports_path, method: :get do |form| + = form.label :enterprise_id, t(".enterprise") + %span.required * + %br + = form.select :enterprise_id, options_from_collection_for_select(@producers, :id, :name, @producers.first&.id), { "data-controller": "tom-select", class: "primary" } + %br + %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/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/locales/en.yml b/config/locales/en.yml index 5843a86b07..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" @@ -929,6 +933,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/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/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/engines/dfc_provider/app/services/dfc_request.rb b/engines/dfc_provider/app/services/dfc_request.rb new file mode 100644 index 0000000000..8de0108d99 --- /dev/null +++ b/engines/dfc_provider/app/services/dfc_request.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +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: { + 'Content-Type' => 'application/json', + 'Authorization' => "Bearer #{@user.oidc_account.token}", + } + ) + + only_public_connections do + connection.get(url) + end + end + + 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/app/services/supplied_product_builder.rb b/engines/dfc_provider/app/services/supplied_product_builder.rb index 4e2b3d947b..31c343b524 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 @@ -87,8 +90,11 @@ class SuppliedProductBuilder < DfcBuilder end def self.taxon(supplied_product) - dfc_id = supplied_product.productType.semanticId - Spree::Taxon.find_by(dfc_id: ) + dfc_id = supplied_product.productType&.semanticId + + # 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/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/engines/dfc_provider/spec/services/supplied_product_builder_spec.rb b/engines/dfc_provider/spec/services/supplied_product_builder_spec.rb index 97c2236209..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,11 +143,19 @@ 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 + + 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) + 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") 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/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 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 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..195c8370c5 --- /dev/null +++ b/spec/system/admin/dfc_product_import_spec.rb @@ -0,0 +1,44 @@ +# 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(:supplier_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 + visit admin_product_import_path + + 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. + host = Rails.application.default_url_options[:host] + url = "http://#{host}/api/dfc/enterprises/#{enterprise.id}/catalog_items" + fill_in "catalog_url", with: url + + # 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