Merge pull request #13513 from mkllnk/add-cqcm-staging-server

Add CQCM staging server to platforms to share data with
This commit is contained in:
Maikel
2025-10-13 13:42:12 +11:00
committed by GitHub
34 changed files with 759 additions and 91 deletions

View File

@@ -47,7 +47,7 @@ Metrics/BlockNesting:
Exclude:
- 'app/models/spree/payment/processing.rb'
# Offense count: 47
# Offense count: 48
# Configuration parameters: CountComments, Max, CountAsOne.
Metrics/ClassLength:
Exclude:
@@ -88,6 +88,7 @@ Metrics/ClassLength:
- 'app/services/cart_service.rb'
- 'app/services/order_cycles/form_service.rb'
- 'app/services/orders/sync_service.rb'
- 'app/services/permissions/order.rb'
- 'app/services/sets/product_set.rb'
- 'engines/order_management/app/services/order_management/order/updater.rb'
- 'lib/open_food_network/enterprise_fee_calculator.rb'
@@ -98,7 +99,6 @@ Metrics/ClassLength:
- 'lib/reporting/reports/enterprise_fee_summary/enterprise_fees_with_tax_report_by_producer.rb'
- 'lib/reporting/reports/enterprise_fee_summary/scope.rb'
- 'lib/reporting/reports/xero_invoices/base.rb'
- 'app/services/permissions/order.rb'
# Offense count: 30
# Configuration parameters: AllowedMethods, AllowedPatterns, Max.
@@ -129,14 +129,13 @@ Metrics/CyclomaticComplexity:
- 'lib/spree/localized_number.rb'
- 'spec/models/product_importer_spec.rb'
# Offense count: 23
# Offense count: 22
# Configuration parameters: CountComments, Max, CountAsOne, AllowedMethods, AllowedPatterns.
Metrics/MethodLength:
Exclude:
- 'app/controllers/admin/enterprises_controller.rb'
- 'app/controllers/payment_gateways/paypal_controller.rb'
- 'app/controllers/spree/orders_controller.rb'
- 'app/helpers/spree/admin/navigation_helper.rb'
- 'app/models/spree/ability.rb'
- 'app/models/spree/gateway/pay_pal_express.rb'
- 'app/models/spree/order/checkout.rb'
@@ -149,7 +148,7 @@ Metrics/MethodLength:
- 'lib/spree/localized_number.rb'
- 'lib/tasks/sample_data/product_factory.rb'
# Offense count: 47
# Offense count: 10
# Configuration parameters: CountComments, Max, CountAsOne.
Metrics/ModuleLength:
Exclude:
@@ -174,7 +173,7 @@ Metrics/ParameterLists:
- 'spec/support/controller_requests_helper.rb'
- 'spec/system/admin/reports_spec.rb'
# Offense count: 3
# Offense count: 4
# Configuration parameters: AllowedMethods, AllowedPatterns, Max.
Metrics/PerceivedComplexity:
Exclude:
@@ -182,6 +181,27 @@ Metrics/PerceivedComplexity:
- 'app/models/spree/ability.rb'
- 'app/models/spree/order/checkout.rb'
# Offense count: 1
# Configuration parameters: EnforcedStyle, AllowedPatterns.
# SupportedStyles: snake_case, camelCase
Naming/MethodName:
Exclude:
- 'engines/dfc_provider/lib/dfc_provider/catalog_item.rb'
# Offense count: 1
# Configuration parameters: MinNameLength, AllowNamesEndingInNumbers, AllowedNames, ForbiddenNames.
# AllowedNames: as, at, by, cc, db, id, if, in, io, ip, of, on, os, pp, to
Naming/MethodParameterName:
Exclude:
- 'engines/dfc_provider/lib/dfc_provider/catalog_item.rb'
# Offense count: 3
# Configuration parameters: EnforcedStyle, AllowedIdentifiers, AllowedPatterns.
# SupportedStyles: snake_case, camelCase
Naming/VariableName:
Exclude:
- 'engines/dfc_provider/lib/dfc_provider/catalog_item.rb'
# Offense count: 1
# Configuration parameters: TransactionMethods.
Rails/TransactionExitStatement:

View File

@@ -0,0 +1,13 @@
# frozen_string_literal: true
class WellKnownController < ApplicationController
layout nil
def dfc
base = "https://github.com/datafoodconsortium/taxonomies/releases/latest/download/scopes.rdf#"
render json: {
"#{base}ReadEnterprise" => "/api/dfc/enterprises/",
"#{base}ReadProducts" => "/api/dfc/supplied_products/",
}
end
end

View File

@@ -50,7 +50,7 @@ module Admin
end
def dfc_platforms_available?
DfcProvider::PlatformsController::PLATFORM_IDS.keys.any? do |id|
ApiUser::PLATFORMS.keys.any? do |id|
feature?(id, spree_current_user)
end
end

View File

@@ -29,6 +29,9 @@ Openfoodnetwork::Application.routes.draw do
get '/orders/:id/token/:token' => 'spree/orders#show', :as => :token_order
get '/payments/:id/authorize' => 'payments#redirect_to_authorize', as: "authorize_payment"
# Well known paths
get "/.well-known/dfc/", to: "well_known#dfc"
resource :cart, controller: "cart" do
post :populate
end

View File

@@ -9,13 +9,11 @@ module DfcProvider
def index
require_permission "ReadProducts"
enterprises = current_user.enterprises.map do |enterprise|
EnterpriseBuilder.enterprise(enterprise)
end
catalog_items = enterprises.flat_map(&:catalogItems)
enterprise = EnterpriseBuilder.enterprise(current_enterprise)
catalog_items = enterprise.catalogItems
render json: DfcIo.export(
*enterprises,
enterprise,
*catalog_items,
*catalog_items.map(&:product),
*catalog_items.map(&:product).flat_map(&:isVariantOf),
@@ -24,7 +22,7 @@ module DfcProvider
end
def show
catalog_item = DfcBuilder.catalog_item(variant)
catalog_item = CatalogItemBuilder.catalog_item(variant)
offers = catalog_item.offers
render json: DfcIo.export(catalog_item, *offers)
end

View File

@@ -3,7 +3,22 @@
# Controller used to provide the CatalogItem API for the DFC application
module DfcProvider
class EnterprisesController < DfcProvider::ApplicationController
before_action :check_enterprise
before_action :check_enterprise, except: :index
def index
enterprises = current_user.enterprises.map do |enterprise|
EnterpriseBuilder.enterprise(enterprise)
end
render json: DfcIo.export(
*enterprises,
*enterprises.map(&:mainContact),
*enterprises.flat_map(&:localizations),
*enterprises.flat_map(&:suppliedProducts),
*enterprises.flat_map(&:catalogItems),
*enterprises.flat_map(&:socialMedias),
)
end
def show
enterprise = EnterpriseBuilder.enterprise(current_enterprise)

View File

@@ -2,12 +2,6 @@
module DfcProvider
class PlatformsController < DfcProvider::ApplicationController
# List of platform identifiers.
# local ID => semantic ID
PLATFORM_IDS = {
'cqcm-dev' => "https://api.proxy-dev.cqcm.startinblox.com/profile",
}.freeze
prepend_before_action :move_authenticity_token
before_action :check_enterprise
@@ -30,21 +24,16 @@ module DfcProvider
scopes_to_delete = current_scopes - requested_scopes
scopes_to_create = requested_scopes - current_scopes
DfcPermission.where(
user: current_user,
enterprise: current_enterprise,
scope: scopes_to_delete,
grantee: key,
).delete_all
dfc_permissions(key).where(scope: scopes_to_delete).delete_all
scopes_to_create.each do |scope|
DfcPermission.create!(
user: current_user,
enterprise: current_enterprise,
scope:,
grantee: key,
)
dfc_permissions(key).create!(scope:)
end
urls = DfcProvider::Engine.routes.url_helpers
enterprise_url = urls.enterprise_url(current_enterprise.id)
ProxyNotifier.new.refresh(key, enterprise_url)
render json: platform(key)
end
@@ -65,13 +54,15 @@ module DfcProvider
end
def available_platforms
PLATFORM_IDS.keys.select(&method(:feature?))
ApiUser::PLATFORMS.keys.select do |platform|
feature?(platform, current_user)
end
end
def platform(key)
{
'@type': "dfc-t:Platform",
'@id': PLATFORM_IDS[key],
'@id': ApiUser.platform_url(key),
localId: key,
'dfc-t:hasAssignedScopes': {
'@type': "rdf:List",
@@ -90,11 +81,15 @@ module DfcProvider
end
def granted_scopes(platform_id)
dfc_permissions(platform_id).pluck(:scope)
end
def dfc_permissions(platform_id)
DfcPermission.where(
user: current_user,
enterprise: current_enterprise,
grantee: platform_id,
).pluck(:scope)
)
end
# The DFC Permission Module is sending tokens in the Authorization header.

View File

@@ -4,11 +4,33 @@
# SuppliedProducts are products that are managed by an enterprise.
module DfcProvider
class SuppliedProductsController < DfcProvider::ApplicationController
before_action :check_enterprise
before_action :check_enterprise, except: :index
rescue_from JSON::LD::JsonLdError::LoadingDocumentFailed, with: -> do
head :bad_request
end
def index
# WARNING!
#
# For DFC platforms accessing this with scoped permissions:
# We rely on the ReadEnterprise scope to list enterprises and
# assume that the ReadProducts scope has been granted as well.
#
# This will be correct for the first iteration of the DFC Permissions
# module but needs to be revised later.
enterprises = current_user.enterprises.map do |enterprise|
EnterpriseBuilder.enterprise(enterprise)
end
catalog_items = enterprises.flat_map(&:catalogItems)
render json: DfcIo.export(
*catalog_items,
*catalog_items.map(&:product),
*catalog_items.map(&:product).flat_map(&:isVariantOf),
*catalog_items.flat_map(&:offers),
)
end
def create
supplied_product = import&.first

View File

@@ -8,7 +8,9 @@ class AddressBuilder < DfcBuilder
postalCode: address.zipcode,
city: address.city,
country: address.country.name,
region: address.state.name
region: address.state.name,
latitude: address.latitude,
longitude: address.longitude,
)
end
end

View File

@@ -2,9 +2,29 @@
# Authorised user or client using the API
class ApiUser
CLIENT_MAP = {
"https://waterlooregionfood.ca/portal/profile" => "cqcm-dev",
PLATFORMS = {
'cqcm-dev' => {
id: "https://api.proxy-dev.cqcm.startinblox.com/profile",
tokens: "https://kc.cqcm.startinblox.com/realms/startinblox/protocol/openid-connect/token",
},
'cqcm-stg' => {
id: "https://api.proxy-stg.cqcm.startinblox.com/profile",
tokens: "https://kc.cqcm.startinblox.com/realms/startinblox/protocol/openid-connect/token",
},
'cqcm' => {
id: "https://carte.cqcm.coop/profile",
tokens: "https://authentification.cqcm.coop/realms/cqcm/protocol/openid-connect/token",
},
}.freeze
CLIENT_MAP = PLATFORMS.keys.index_by { |key| PLATFORMS.dig(key, :id) }.freeze
def self.platform_url(platform)
PLATFORMS.dig(platform, :id)
end
def self.token_endpoint(platform)
PLATFORMS.dig(platform, :tokens)
end
def self.from_client_id(client_id)
id = CLIENT_MAP[client_id]

View File

@@ -18,6 +18,13 @@ class AuthorizationControl
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqtvdb3BdHoLnNeMLaWd7nugPwdRAJJpdSySTtttEQY2/v1Q3byJ/kReSNGrUNkPVkOeDN3milgN5Apz+sNCwbtzOCulyFMmvuIOZFBqz5tcgwjZinSwpGBXpn6ehXyCET2LlcfLYAPA9axtaNg9wBLIHoxIPWpa2LcZstogyZY/yKUZXQTDqM5B5TyUkPN89xHFdq8SQuXPasbpYl7mGhZHkTDHiKZ9VK7K5tqsEZTD9dCuTGMKsthbOrlDnc9bAJ3PyKLRdib21Y1GGlTozo4Y/1q448E/DFp5rVC6jG6JFnsEnP0WVn+6qz7yxI7IfUU2YSAGgtGYaQkWtEfED0QIDAQAB
-----END PUBLIC KEY-----
KEY
# Copied from: https:///authentification.cqcm.coop/realms/cqcm
"https:///authentification.cqcm.coop/realms/cqcm" => <<~KEY,
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAhz7dK3xQAWL+u++E/64T1OHEvnFrZRLzgCmw0leib3JL/XbaE4Jbd3fs2+zc3+dCwvCuLEKKO9Hc9wg79ifjtMKFfZDE1Ba+qhw7J9tYnu7TBtaxKuWUCdtwuultEdW+NFndaUvhD/TdyjDkRiO98mgvUbm2A3q/zyDmoUpR2IEfevkMSz8MnxUo1bDTJIyoYoKwnbToI1E9RVx2uYsYKk24Pfd+r6oTbi7TxA6Ia4EiREFki2gNIAdp66IqF0Gxyd+nGlkIbQGrW+9xynU4ar3ZNq/P8EZFdO57AdEvC3ZAzpTvOVcQ0cQ4XbRSYWQHyZ8jnjggpeddTGSqVlgx1wIDAQAB
-----END PUBLIC KEY-----
KEY
}.freeze
def self.public_key(token)

View File

@@ -1,6 +1,23 @@
# frozen_string_literal: true
class CatalogItemBuilder < DfcBuilder
def self.catalog_item(variant)
id = urls.enterprise_catalog_item_url(
enterprise_id: variant.supplier_id,
id: variant.id,
)
supplier_url = urls.enterprise_url(variant.supplier_id)
product = SuppliedProductBuilder.supplied_product(variant)
DfcProvider::CatalogItem.new(
id, product:,
sku: variant.sku,
stockLimitation: stock_limitation(variant),
offers: [OfferBuilder.build(variant)],
managedBy: supplier_url,
)
end
def self.apply_stock(item, variant)
limit = item&.stockLimitation

View File

@@ -1,21 +1,6 @@
# frozen_string_literal: true
class DfcBuilder
def self.catalog_item(variant)
id = urls.enterprise_catalog_item_url(
enterprise_id: variant.supplier_id,
id: variant.id,
)
product = SuppliedProductBuilder.supplied_product(variant)
DataFoodConsortium::Connector::CatalogItem.new(
id, product:,
sku: variant.sku,
stockLimitation: stock_limitation(variant),
offers: [OfferBuilder.build(variant)],
)
end
# The DFC sees "empty" stock as unlimited.
# http://static.datafoodconsortium.org/conception/DFC%20-%20Business%20rules.pdf
def self.stock_limitation(variant)

View File

@@ -6,7 +6,7 @@ class EnterpriseBuilder < DfcBuilder
# in the DFC standard.
variants = enterprise.supplied_variants.to_a
catalog_items = variants.map(&method(:catalog_item))
catalog_items = variants.map(&CatalogItemBuilder.method(:catalog_item))
supplied_products = catalog_items.map(&:product)
address = AddressBuilder.address(enterprise.address)

View File

@@ -0,0 +1,61 @@
# frozen_string_literal: true
require "private_address_check"
require "private_address_check/tcpsocket_ext"
# Call a webhook to notify a data proxy about changes in our data.
class ProxyNotifier
def refresh(platform, enterprise_url)
PrivateAddressCheck.only_public_connections do
notify_proxy(platform, enterprise_url)
end
end
def request_token(platform)
connection = Faraday.new(
request: { timeout: 5 },
) do |f|
f.request :url_encoded
f.response :json
f.response :raise_error
end
url = ApiUser.token_endpoint(platform)
data = {
grant_type: "client_credentials",
client_id: ENV.fetch("OPENID_APP_ID", nil),
client_secret: ENV.fetch("OPENID_APP_SECRET", nil),
scope: "WriteEnterprise",
}
response = connection.post(url, data)
response.body["access_token"]
end
def notify_proxy(platform, enterprise_url)
token = request_token(platform)
data = {
eventType: "refresh",
enterpriseUrlid: enterprise_url,
scope: "ReadEnterprise",
}
connection = Faraday.new(
request: { timeout: 10 },
headers: {
'Authorization' => "Bearer #{token}",
}
) do |f|
f.request :json
f.response :json
f.response :raise_error
end
connection.post(webhook_url(platform), data)
end
def webhook_url(platform)
platform_url = ApiUser.platform_url(platform)
URI.parse(platform_url).tap do |url|
url.path = "/djangoldp-dfc/webhook/"
end
end
end

View File

@@ -2,7 +2,7 @@
DfcProvider::Engine.routes.draw do
resources :addresses, only: [:show]
resources :enterprises, only: [:show] do
resources :enterprises, only: [:index, :show] do
resources :catalog_items, only: [:index, :show, :update]
resources :offers, only: [:show, :update]
resources :platforms, only: [:index, :show, :update]
@@ -13,6 +13,7 @@ DfcProvider::Engine.routes.draw do
resources :affiliated_by, only: [:create, :destroy], module: 'enterprise_groups'
end
resources :persons, only: [:show]
resources :supplied_products, only: [:index]
resources :product_groups, only: [:show]
resource :affiliate_sales_data, only: [:show]

View File

@@ -9,6 +9,7 @@ require "dfc_provider/engine"
# Custom data types
require "dfc_provider/supplied_product"
require "dfc_provider/address"
require "dfc_provider/catalog_item"
require "dfc_provider/coordination"
# 🙈 Monkey-patch a better inspector for semantic objects

View File

@@ -0,0 +1,16 @@
# frozen_string_literal: true
# Temporary solution.
module DfcProvider
class CatalogItem < DataFoodConsortium::Connector::CatalogItem
attr_accessor :managedBy
def initialize(semantic_id, managedBy: "", **properties)
super(semantic_id, **properties)
@managedBy = managedBy
registerSemanticProperty("dfc-b:managedBy", &method("managedBy"))
.valueSetter = method("managedBy=")
end
end
end

View File

@@ -80,6 +80,23 @@ RSpec.describe "CatalogItems", swagger_doc: "dfc.yaml" do
run_test!
end
context "with a second enterprise" do
let(:enterprise_id) { 10_000 }
before do
create(
:distributor_enterprise,
id: 10_001, owner: user, name: "Fred's Icecream", description: "Yum",
address: build(:address, id: 40_001),
)
end
run_test! do
expect(response.body).to include "Apple"
expect(response.body).not_to include "Icecream"
end
end
context "with default enterprise id" do
let(:enterprise_id) { "default" }

View File

@@ -15,7 +15,22 @@ RSpec.describe "Enterprises", swagger_doc: "dfc.yaml" do
email_address: "hello@example.org",
phone: "0404 444 000 200",
website: "https://openfoodnetwork.org",
address: build(:address, id: 40_000, address1: "42 Doveton Street"),
address:,
)
end
let(:address) {
build(
:address,
id: 40_000, address1: "42 Doveton Street",
latitude: -25.345376, longitude: 131.0312006,
)
}
let!(:other_enterprise) do
create(
:distributor_enterprise,
id: 10_001, owner: user, abn: "123 457", name: "Fred's Icecream",
description: "We use our strawberries to make icecream.",
address: build(:address, id: 40_001, address1: "42 Doveton Street"),
)
end
let!(:enterprise_group) do
@@ -46,6 +61,63 @@ RSpec.describe "Enterprises", swagger_doc: "dfc.yaml" do
before { login_as user }
path "/api/dfc/enterprises" do
get "List enterprises" do
produces "application/json"
response "200", "successful" do
context "as platform user" do
include_context "authenticated as platform"
context "without permissions" do
run_test! do
expect(response.body).to eq ""
end
end
context "with access to one enterprise" do
before do
DfcPermission.create!(
user:, enterprise_id: enterprise.id,
scope: "ReadEnterprise", grantee: "cqcm-dev",
)
end
run_test! do
expect(response.body).to include "Fred's Farm"
expect(response.body).not_to include "Fred's Icecream"
end
end
context "with access to two enterprises" do
before do
DfcPermission.create!(
user:, enterprise_id: enterprise.id,
scope: "ReadEnterprise", grantee: "cqcm-dev",
)
DfcPermission.create!(
user:, enterprise_id: other_enterprise.id,
scope: "ReadEnterprise", grantee: "cqcm-dev",
)
end
run_test! do
expect(response.body).to include "Fred's Farm"
expect(response.body).to include "Fred's Icecream"
end
end
end
context "as user owning two enterprises" do
run_test! do
expect(response.body).to include "Fred's Farm"
expect(response.body).to include "Fred's Icecream"
end
end
end
end
end
path "/api/dfc/enterprises/{id}" do
get "Show enterprise" do
parameter name: :id, in: :path, type: :string
@@ -91,18 +163,6 @@ RSpec.describe "Enterprises", swagger_doc: "dfc.yaml" do
"dfc-b:affiliates" => "http://test.host/api/dfc/enterprise_groups/60000",
"dfc-b:websitePage" => "https://openfoodnetwork.org",
)
# Insert static value to keep documentation deterministic:
response.body.gsub!(
%r{active_storage/[0-9A-Za-z/=-]*/logo-white.png},
"active_storage/url/logo-white.png",
).gsub!(
%r{active_storage/[0-9A-Za-z/=-]*/logo.png},
"active_storage/url/logo.png",
).gsub!(
%r{active_storage/[0-9A-Za-z/=-]*/promo.png},
"active_storage/url/promo.png",
)
end
end
end

View File

@@ -93,6 +93,12 @@ RSpec.describe "Platforms", swagger_doc: "dfc.yaml" do
example.metadata[:operation][:parameters].first[:schema][:example]
end
before do
stub_request(:post, "https://kc.cqcm.startinblox.com/realms/startinblox/protocol/openid-connect/token")
.and_return(body: { access_token: "testtoken" }.to_json)
stub_request(:post, "https://api.proxy-dev.cqcm.startinblox.com/djangoldp-dfc/webhook/")
end
run_test! do
expect(json_response["@id"]).to eq "https://api.proxy-dev.cqcm.startinblox.com/profile"
end

View File

@@ -14,7 +14,11 @@ RSpec.describe "SuppliedProducts", swagger_doc: "dfc.yaml" do
)
}
let(:variant) {
build(:base_variant, id: 10_001, unit_value: 1, primary_taxon: taxon, supplier: enterprise)
build(
:base_variant,
id: 10_001, sku: "BP", unit_value: 1,
primary_taxon: taxon, supplier: enterprise,
)
}
let(:taxon) {
build(
@@ -34,6 +38,47 @@ RSpec.describe "SuppliedProducts", swagger_doc: "dfc.yaml" do
before { login_as user }
path "/api/dfc/supplied_products" do
get "Index SuppliedProducts" do
produces "application/json"
response "200", "success" do
context "as platform user" do
include_context "authenticated as platform"
context "without permissions" do
run_test! do
expect(response.body).to eq ""
end
end
context "with access to products" do
before do
DfcPermission.create!(
user:, enterprise_id: 10_000,
scope: "ReadEnterprise", grantee: "cqcm-dev",
)
DfcPermission.create!(
user:, enterprise_id: 10_000,
scope: "ReadProducts", grantee: "cqcm-dev",
)
end
run_test! do
expect(response.body).to include "Pesto"
end
end
end
context "as user owning two enterprises" do
run_test! do
expect(response.body).to include "Pesto"
end
end
end
end
end
path "/api/dfc/enterprises/{enterprise_id}/supplied_products" do
parameter name: :enterprise_id, in: :path, type: :string
@@ -167,10 +212,6 @@ RSpec.describe "SuppliedProducts", swagger_doc: "dfc.yaml" do
"supplied_products/#{variant_id}",
"supplied_products/10001"
)
.gsub!(
%r{active_storage/[0-9A-Za-z/=-]*/logo-white.png},
"active_storage/url/logo-white.png",
)
end
end
end
@@ -195,12 +236,6 @@ RSpec.describe "SuppliedProducts", swagger_doc: "dfc.yaml" do
expect(json_response["ofn:spree_product_id"]).to eq 90_000
expect(json_response["dfc-b:hasType"]).to eq("dfc-pt:processed-vegetable")
expect(json_response["ofn:image"]).to include("logo-white.png")
# Insert static value to keep documentation deterministic:
response.body.gsub!(
%r{active_storage/[0-9A-Za-z/=-]*/logo-white.png},
"active_storage/url/logo-white.png",
)
end
end

View File

@@ -8,6 +8,7 @@ RSpec.describe AddressBuilder do
build(
:address,
id: 1, address1: "Paradise 15", zipcode: "0001", city: "Goosnargh",
latitude: -25.345376, longitude: 131.0312006,
state: build(:state, name: "Victoria")
)
}
@@ -38,5 +39,10 @@ RSpec.describe AddressBuilder do
it "assigns a region" do
expect(result.region).to eq "Victoria"
end
it "assigns coordinates" do
expect(result.latitude).to eq(-25.345376)
expect(result.longitude).to eq 131.0312006
end
end
end

View File

@@ -2,15 +2,17 @@
require_relative "../spec_helper"
RSpec.describe DfcBuilder do
RSpec.describe CatalogItemBuilder do
let(:variant) { build(:variant) }
describe ".catalog_item" do
it "assigns a semantic id" do
before do
variant.id = 5
variant.supplier_id = 7
end
item = DfcBuilder.catalog_item(variant)
it "assigns a semantic id" do
item = CatalogItemBuilder.catalog_item(variant)
expect(item.semanticId).to eq(
"http://test.host/api/dfc/enterprises/7/catalog_items/5"
@@ -18,15 +20,20 @@ RSpec.describe DfcBuilder do
end
it "refers to a supplied product" do
variant.id = 5
variant.supplier_id = 7
item = DfcBuilder.catalog_item(variant)
item = CatalogItemBuilder.catalog_item(variant)
expect(item.product.semanticId).to eq(
"http://test.host/api/dfc/enterprises/7/supplied_products/5"
)
end
it "refers to the supplier" do
item = CatalogItemBuilder.catalog_item(variant)
expect(item.managedBy).to eq(
"http://test.host/api/dfc/enterprises/7"
)
end
end
describe ".apply_stock" do

View File

@@ -0,0 +1,28 @@
# frozen_string_literal: true
require_relative "../spec_helper"
# These tests depend on valid OpenID Connect client credentials in your
# `.env.test.local` file.
#
# OPENID_APP_ID="..."
# OPENID_APP_SECRET="..."
RSpec.describe ProxyNotifier do
let(:platform) { "cqcm-dev" }
let(:enterprise_url) { "http://ofn.example.net/api/dfc/enterprises/10000" }
it "receives an access token", :vcr do
token = subject.request_token(platform)
expect(token).to be_a String
expect(token.length).to be > 20
end
it "notifies the proxy", :vcr do
# The test server is not reachable by the notified server.
# If you don't have valid credentials, you'll get an unauthorized error.
# Correctly authenticated, the server fails to update its data.
expect {
subject.refresh(platform, enterprise_url)
}.to raise_error Faraday::ServerError
end
end

View File

@@ -3,12 +3,18 @@
# Authenticate via Authoriztion token
RSpec.shared_context "authenticated as platform" do
let(:Authorization) {
"Bearer #{file_fixture('startinblox_access_token.jwt').read}"
"Bearer #{access_token}"
}
let(:access_token) {
file_fixture("startinblox_access_token.jwt").read
}
before do
payload = JWT.decode(access_token, nil, false, { algorithm: "RS256" }).first
issued_at = Time.zone.at(payload["iat"])
# Once upon a time when the access token hadn't expired yet...
travel_to(Date.parse("2025-06-13"))
travel_to(issued_at)
# Reset any login via session cookie.
login_as nil

View File

@@ -62,7 +62,13 @@ module OpenFoodNetwork
Enable the inventory.
DESC
"cqcm-dev" => <<~DESC,
Show DFC Permissions interface to share data with CQCM dev platform.
Show DFC Permissions interface with development platform.
DESC
"cqcm-stg" => <<~DESC,
Show DFC Permissions interface to share data with CQCM staging platform.
DESC
"cqcm" => <<~DESC,
Show DFC Permissions interface to share data with CQCM.
DESC
}.merge(conditional_features).freeze;

View File

@@ -1 +1 @@
eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJRRlJES1daQ2hzMllQYnRqYl9yQUtwQzNzMFo0T2FCMEtFQ056NnlxWHQ0In0.eyJleHAiOjE3NDk3ODk3MDcsImlhdCI6MTc0OTc4OTQwNywianRpIjoiOWE4ODU4NDAtODhjNy00OTliLWIyOGUtMmE5ZmViM2EyNmU0IiwiaXNzIjoiaHR0cHM6Ly9rYy5jcWNtLnN0YXJ0aW5ibG94LmNvbS9yZWFsbXMvc3RhcnRpbmJsb3giLCJhdWQiOiJhY2NvdW50Iiwic3ViIjoiNWZlM2MyNmMtMjczNi00OGE0LWI2Y2YtYTllM2JjZmNkZjAwIiwidHlwIjoiQmVhcmVyIiwiYXpwIjoiaHR0cHM6Ly93YXRlcmxvb3JlZ2lvbmZvb2QuY2EvcG9ydGFsL3Byb2ZpbGUiLCJyZWFsbV9hY2Nlc3MiOnsicm9sZXMiOlsib2ZmbGluZV9hY2Nlc3MiLCJ1bWFfYXV0aG9yaXphdGlvbiIsImRlZmF1bHQtcm9sZXMtc3RhcnRpbmJsb3giXX0sInJlc291cmNlX2FjY2VzcyI6eyJhY2NvdW50Ijp7InJvbGVzIjpbIm1hbmFnZS1hY2NvdW50IiwibWFuYWdlLWFjY291bnQtbGlua3MiLCJ2aWV3LXByb2ZpbGUiXX19LCJzY29wZSI6IlJlYWRFbnRlcnByaXNlIFJlYWRQcm9kdWN0cyIsImNsaWVudEhvc3QiOiIxNzIuMTguMC4xIiwiY2xpZW50QWRkcmVzcyI6IjE3Mi4xOC4wLjEiLCJjbGllbnRfaWQiOiJodHRwczovL3dhdGVybG9vcmVnaW9uZm9vZC5jYS9wb3J0YWwvcHJvZmlsZSJ9.Ln7wY0_ptRAza7M8w3yXU02TvluH028uaoJ5VHiN9-PnakokzHve7SCuSd1hvVikYAivWFIBRP97vwfpb_DW-d9Afk_XcQqcA0L36ynUIZ69X5uQ2zakEW0kB6pwqd8AL8tlWVUg2PixBXJ6daJcgWNF7RlKXg6wgy4JYL_VxD3VJjST911-z4_TMuQ2OC-3SJNwNv3BspSmUXm7F6y8xGFN7wuCPjU90WIiZ_vxTbVdM0zNtBM0uMJFeFv2_ZzoJIIiNHYLWtD3LrKcXePLSejpo-DPVWR_lGdDdM7BmzOHPKZ9KMaV-oa3lYNYC5shhJOpoB3vHngtdYdv8jq7Cg
eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJRRlJES1daQ2hzMllQYnRqYl9yQUtwQzNzMFo0T2FCMEtFQ056NnlxWHQ0In0.eyJleHAiOjE3NTg2ODY1OTUsImlhdCI6MTc1ODY4NjI5NSwianRpIjoiMjhjZmZkOGItNWNlNi00ZjgxLWFiYjUtMjY0NTg4MjhhM2E3IiwiaXNzIjoiaHR0cHM6Ly9rYy5jcWNtLnN0YXJ0aW5ibG94LmNvbS9yZWFsbXMvc3RhcnRpbmJsb3giLCJhdWQiOiJhY2NvdW50Iiwic3ViIjoiNWZlM2MyNmMtMjczNi00OGE0LWI2Y2YtYTllM2JjZmNkZjAwIiwidHlwIjoiQmVhcmVyIiwiYXpwIjoiaHR0cHM6Ly9hcGkucHJveHktZGV2LmNxY20uc3RhcnRpbmJsb3guY29tL3Byb2ZpbGUiLCJyZWFsbV9hY2Nlc3MiOnsicm9sZXMiOlsib2ZmbGluZV9hY2Nlc3MiLCJ1bWFfYXV0aG9yaXphdGlvbiIsImRlZmF1bHQtcm9sZXMtc3RhcnRpbmJsb3giXX0sInJlc291cmNlX2FjY2VzcyI6eyJhY2NvdW50Ijp7InJvbGVzIjpbIm1hbmFnZS1hY2NvdW50IiwibWFuYWdlLWFjY291bnQtbGlua3MiLCJ2aWV3LXByb2ZpbGUiXX19LCJzY29wZSI6IlJlYWRFbnRlcnByaXNlIiwiY2xpZW50SG9zdCI6IjE3Mi4xOC4wLjEiLCJjbGllbnRBZGRyZXNzIjoiMTcyLjE4LjAuMSIsImNsaWVudF9pZCI6Imh0dHBzOi8vYXBpLnByb3h5LWRldi5jcWNtLnN0YXJ0aW5ibG94LmNvbS9wcm9maWxlIn0.DH0o4GJxKJumSbuLwdk3wz0DdwUvBM9NH6E07lkP3s1iJ23bMIE_p4gsL44RQHjB05nZWDXYrwyIJK8vLlrd8oRcZCzHgBHMQ1_-8G_JFb6s8IW_q7ROZqPwm7Wknt5fSiE7Tf3NXR2Xr6afm4f8BAcQDd2i7LjIGHomEt0pG8Q3HWzSpXJ9scJ_9enXRZTd02JLOnargKdpK9VPfGO8HjxDMip_W-aGKQ89-3XF-q3ZjC-rOxK7ZzOEbT-YE_M3nrfVeX9BnwX38vAk97UKhsLGFtupsSD3aoS6bZb2Axv3cn6e0IJ3G2iPXy36WSc_WVnhRUt8H5E7YDeHJpzTZg

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,52 @@
---
http_interactions:
- request:
method: post
uri: https://kc.cqcm.startinblox.com/realms/startinblox/protocol/openid-connect/token
body:
encoding: UTF-8
string: client_id=https%3A%2F%2Fstaging.openfoodnetwork.org.uk%2F&client_secret=<HIDDEN-OPENID_APP_SECRET>&grant_type=client_credentials&scope=WriteEnterprise
headers:
User-Agent:
- Faraday v2.9.0
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:
Server:
- nginx/1.22.1
Date:
- Fri, 19 Sep 2025 06:09:58 GMT
Content-Type:
- application/json
Content-Length:
- '1726'
Connection:
- keep-alive
Cache-Control:
- no-store
Pragma:
- no-cache
Referrer-Policy:
- no-referrer
Strict-Transport-Security:
- max-age=31536000; includeSubDomains
X-Content-Type-Options:
- nosniff
X-Frame-Options:
- SAMEORIGIN
X-Xss-Protection:
- 1; mode=block
body:
encoding: UTF-8
string: '{"access_token":"<HIDDEN-OPENID-TOKEN>","expires_in":300,"refresh_expires_in":0,"token_type":"Bearer","not-before-policy":0,"scope":"WriteEnterprise
profile email"}'
recorded_at: Fri, 19 Sep 2025 06:09:58 GMT
recorded_with: VCR 6.2.0

View File

@@ -0,0 +1,16 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe "/.well-known/" do
describe "dfc/" do
it "publishes our endpoints" do
get "/.well-known/dfc/"
expect(response).to have_http_status :ok
expect(response.body).to include "ReadEnterprise"
expect(response.content_type).to eq "application/json; charset=utf-8"
expect(response.parsed_body.count).to eq 2
end
end
end

View File

@@ -80,6 +80,12 @@ RSpec.configure do |config|
next if response&.body.blank?
# Replace random values from generated strings for a deterministic documentation.
response.body.gsub!(
%r{/rails/active_storage/[0-9A-Za-z/=-]*/([^/.]+).png},
'/rails/active_storage/url/\1.png',
)
# Include response as example in the documentation.
example.metadata[:response][:content] ||= {}
example.metadata[:response][:content].deep_merge!(

View File

@@ -11,6 +11,9 @@ RSpec.describe "DFC Permissions", feature: "cqcm-dev", vcr: true do
before do
login_as enterprise.owner
# Disable data proxy webhook which can't reach our test server.
allow_any_instance_of(ProxyNotifier).to receive(:refresh)
end
it "is not visible when no platform is enabled" do

View File

@@ -407,6 +407,92 @@ paths:
dfc-b:hasCity: Herndon
dfc-b:hasCountry: Australia
dfc-b:region: Victoria
"/api/dfc/enterprises":
get:
summary: List enterprises
tags:
- Enterprises
responses:
'200':
description: successful
content:
application/json:
examples:
test_example:
value:
"@context": https://www.datafoodconsortium.org
"@graph":
- "@id": http://test.host/api/dfc/enterprises/10001
"@type": dfc-b:Enterprise
dfc-b:hasAddress: http://test.host/api/dfc/addresses/40001
dfc-b:name: Fred's Icecream
dfc-b:hasDescription: We use our strawberries to make icecream.
dfc-b:VATnumber: 123 457
dfc-b:hasMainContact: http://test.host/api/dfc/enterprises/10001#mainContact
ofn:long_description: "<p>Hello, world!</p><p>This is a paragraph.</p>"
- "@id": http://test.host/api/dfc/enterprises/10000
"@type": dfc-b:Enterprise
dfc-b:hasAddress: http://test.host/api/dfc/addresses/40000
dfc-b:hasPhoneNumber: 0404 444 000 200
dfc-b:email: hello@example.org
dfc-b:websitePage: https://openfoodnetwork.org
dfc-b:hasSocialMedia: http://test.host/api/dfc/enterprises/10000/social_medias/facebook
dfc-b:logo: http://test.host/rails/active_storage/url/logo.png
dfc-b:name: Fred's Farm
dfc-b:hasDescription: This is an awesome enterprise
dfc-b:VATnumber: 123 456
dfc-b:manages: http://test.host/api/dfc/enterprises/10000/catalog_items/10001
dfc-b:supplies: http://test.host/api/dfc/enterprises/10000/supplied_products/10001
dfc-b:hasMainContact: http://test.host/api/dfc/enterprises/10000#mainContact
ofn:long_description: "<p>Hello, world!</p><p>This is a paragraph.</p>"
ofn:contact_name: Fred Farmer
ofn:logo_url: http://test.host/rails/active_storage/url/logo.png
ofn:promo_image_url: http://test.host/rails/active_storage/url/promo.png
- "@id": http://test.host/api/dfc/enterprises/10001#mainContact
"@type": dfc-b:Person
- "@id": http://test.host/api/dfc/enterprises/10000#mainContact
"@type": dfc-b:Person
dfc-b:firstName: Fred
dfc-b:familyName: Farmer
- "@id": http://test.host/api/dfc/addresses/40001
"@type": dfc-b:Address
dfc-b:hasStreet: 42 Doveton Street
dfc-b:hasPostalCode: '20170'
dfc-b:hasCity: Herndon
dfc-b:hasCountry: Australia
dfc-b:region: Victoria
- "@id": http://test.host/api/dfc/addresses/40000
"@type": dfc-b:Address
dfc-b:hasStreet: 42 Doveton Street
dfc-b:hasPostalCode: '20170'
dfc-b:hasCity: Herndon
dfc-b:hasCountry: Australia
dfc-b:latitude: -25.345376
dfc-b:longitude: 131.0312006
dfc-b:region: Victoria
- "@id": http://test.host/api/dfc/enterprises/10000/supplied_products/10001
"@type": dfc-b:SuppliedProduct
dfc-b:name: Apple - 1g
dfc-b:description: Round
dfc-b:hasQuantity:
"@type": dfc-b:QuantitativeValue
dfc-b:hasUnit: dfc-m:Gram
dfc-b:value: 1.0
dfc-b:image: http://test.host/rails/active_storage/url/logo-white.png
dfc-b:isVariantOf: http://test.host/api/dfc/product_groups/90000
ofn:spree_product_id: 90000
ofn:spree_product_uri: http://test.host/api/dfc/enterprises/10000?spree_product_id=90000
ofn:image: http://test.host/rails/active_storage/url/logo-white.png
- "@id": http://test.host/api/dfc/enterprises/10000/catalog_items/10001
"@type": dfc-b:CatalogItem
dfc-b:references: http://test.host/api/dfc/enterprises/10000/supplied_products/10001
dfc-b:sku: APP
dfc-b:stockLimitation: 5
dfc-b:offeredThrough: http://test.host/api/dfc/enterprises/10000/offers/10001
- "@id": http://test.host/api/dfc/enterprises/10000/social_medias/facebook
"@type": dfc-b:SocialMedia
dfc-b:name: facebook
dfc-b:URL: https://facebook.com/user
"/api/dfc/enterprises/{id}":
get:
summary: Show enterprise
@@ -457,6 +543,8 @@ paths:
dfc-b:hasPostalCode: '20170'
dfc-b:hasCity: Herndon
dfc-b:hasCountry: Australia
dfc-b:latitude: -25.345376
dfc-b:longitude: 131.0312006
dfc-b:region: Victoria
- "@id": http://test.host/api/dfc/enterprises/10000/supplied_products/10001
"@type": dfc-b:SuppliedProduct
@@ -726,6 +814,52 @@ paths:
dfc-b:URL: https://facebook.com/user
'404':
description: not found
"/api/dfc/supplied_products":
get:
summary: Index SuppliedProducts
tags:
- SuppliedProducts
responses:
'200':
description: success
content:
application/json:
examples:
test_example:
value:
"@context": https://www.datafoodconsortium.org
"@graph":
- "@id": http://test.host/api/dfc/enterprises/10000/catalog_items/10001
"@type": dfc-b:CatalogItem
dfc-b:references: http://test.host/api/dfc/enterprises/10000/supplied_products/10001
dfc-b:sku: BP
dfc-b:stockLimitation: 5
dfc-b:offeredThrough: http://test.host/api/dfc/enterprises/10000/offers/10001
- "@id": http://test.host/api/dfc/enterprises/10000/supplied_products/10001
"@type": dfc-b:SuppliedProduct
dfc-b:name: Pesto - 1g
dfc-b:description: Basil Pesto
dfc-b:hasType: dfc-pt:processed-vegetable
dfc-b:hasQuantity:
"@type": dfc-b:QuantitativeValue
dfc-b:hasUnit: dfc-m:Gram
dfc-b:value: 1.0
dfc-b:image: http://test.host/rails/active_storage/url/logo-white.png
dfc-b:isVariantOf: http://test.host/api/dfc/product_groups/90000
ofn:spree_product_id: 90000
ofn:spree_product_uri: http://test.host/api/dfc/enterprises/10000?spree_product_id=90000
ofn:image: http://test.host/rails/active_storage/url/logo-white.png
- "@id": http://test.host/api/dfc/product_groups/90000
"@type": dfc-b:SuppliedProduct
dfc-b:name: Pesto
dfc-b:hasVariant: http://test.host/api/dfc/enterprises/10000/supplied_products/10001
- "@id": http://test.host/api/dfc/enterprises/10000/offers/10001
"@type": dfc-b:Offer
dfc-b:hasPrice:
"@type": dfc-b:Price
dfc-b:value: 19.99
dfc-b:hasUnit: dfc-m:AustralianDollar
dfc-b:stockLimitation: 5
"/api/dfc/enterprises/{enterprise_id}/supplied_products":
parameters:
- name: enterprise_id