From 8fb32ef227a761fcebc8dfab0025cf345aeac5f4 Mon Sep 17 00:00:00 2001 From: Elias Date: Sat, 13 Jun 2026 06:27:38 +0300 Subject: [PATCH 1/4] feat: add POST /clone-store/ensure-api-key to mint missing publishable keys This commit introduces a new API endpoint, POST /clone-store/ensure-api-key, which allows clients to request the provisioning of missing publishable API keys for cloned stores. The endpoint accepts a store ID and checks if the associated publishable key exists. If the key is missing, it generates a new one and returns it in the response. This enhancement ensures that cloned stores have the necessary credentials to operate seamlessly without manual intervention. --- .../v2/platform/clone_stores_controller.rb | 42 ++++++++++++++++ .../clone_store/store_api_key_provisioner.rb | 24 ++++++++++ config/routes.rb | 1 + .../platform/clone_stores_controller_spec.rb | 48 +++++++++++++++++++ .../store_api_key_provisioner_spec.rb | 28 +++++++++++ 5 files changed, 143 insertions(+) create mode 100644 app/services/spree/olitt/clone_store/store_api_key_provisioner.rb create mode 100644 spec/services/spree/olitt/clone_store/store_api_key_provisioner_spec.rb diff --git a/app/controllers/spree/api/v2/platform/clone_stores_controller.rb b/app/controllers/spree/api/v2/platform/clone_stores_controller.rb index c70a2d3..df71ef3 100644 --- a/app/controllers/spree/api/v2/platform/clone_stores_controller.rb +++ b/app/controllers/spree/api/v2/platform/clone_stores_controller.rb @@ -24,8 +24,50 @@ def show render_clone_request_status(params[:clone_request_id]) end + def ensure_api_key + store = resolve_ensure_api_key_store + return render_ensure_api_key_store_not_found if store.nil? + + api_key = Spree::Olitt::CloneStore::StoreApiKeyProvisioner.call(store) + render json: ensure_api_key_payload(store, api_key), status: :ok + end + private + def resolve_ensure_api_key_store + Spree.current_store_finder.new(url: params[:url].presence).execute + end + + def render_ensure_api_key_store_not_found + render json: { + errors: ['Store not found for the provided url'], + meta: { status: 'not_found' } + }, status: :not_found + end + + def ensure_api_key_payload(store, api_key) + { + data: { id: store.id, type: 'store' }, + status: 'completed', + meta: { + status: 'completed', + public_api_key: serialize_public_api_key(api_key) + } + } + end + + def serialize_public_api_key(api_key) + return nil if api_key.nil? + + { + id: api_key.id, + name: api_key.name, + key_type: api_key.key_type, + token: api_key.token, + store_id: api_key.store_id + } + end + def authorize_clone_store_request! if api_key_header_present? authenticate_secret_key! diff --git a/app/services/spree/olitt/clone_store/store_api_key_provisioner.rb b/app/services/spree/olitt/clone_store/store_api_key_provisioner.rb new file mode 100644 index 0000000..e4d489b --- /dev/null +++ b/app/services/spree/olitt/clone_store/store_api_key_provisioner.rb @@ -0,0 +1,24 @@ +module Spree + module Olitt + module CloneStore + class StoreApiKeyProvisioner + def self.call(store) + new(store).call + end + + def initialize(store) + @store = store + end + + def call + return nil unless @store.respond_to?(:api_keys) + + @store.api_keys.active.publishable.first || + @store.api_keys.create!(name: 'Storefront key', key_type: 'publishable') + rescue ActiveRecord::RecordNotUnique + @store.api_keys.active.publishable.first + end + end + end + end +end diff --git a/config/routes.rb b/config/routes.rb index 82d2701..12024ff 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -3,6 +3,7 @@ namespace :v2 do namespace :platform do post '/clone-store', to: 'clone_stores#create' + post '/clone-store/ensure-api-key', to: 'clone_stores#ensure_api_key' get '/clone-store/:clone_request_id', to: 'clone_stores#show' end end diff --git a/spec/controllers/spree/api/v2/platform/clone_stores_controller_spec.rb b/spec/controllers/spree/api/v2/platform/clone_stores_controller_spec.rb index 359ec55..1668d39 100644 --- a/spec/controllers/spree/api/v2/platform/clone_stores_controller_spec.rb +++ b/spec/controllers/spree/api/v2/platform/clone_stores_controller_spec.rb @@ -94,4 +94,52 @@ expect(JSON.parse(response.body)).to eq('error' => 'Valid secret API key required') end end + + describe '#ensure_api_key' do + let!(:live_store) do + create(:store, default: false, name: 'Live Store', url: 'live.example.com', code: 'live-store') + end + + before do + allow(Spree.current_store_finder).to receive(:new).and_return( + instance_double(Spree.current_store_finder, execute: live_store) + ) + end + + it 'mints a publishable key for the resolved store and returns it' do + expect(live_store.api_keys.active.publishable.count).to eq(0) + + post :ensure_api_key, params: { url: 'live.example.com' }, format: :json + + expect(response).to have_http_status(:ok) + payload = JSON.parse(response.body) + expect(payload.dig('data', 'id')).to eq(live_store.id) + expect(payload.dig('meta', 'status')).to eq('completed') + expect(payload.dig('meta', 'public_api_key', 'token')).to be_present + expect(payload.dig('meta', 'public_api_key', 'store_id')).to eq(live_store.id) + expect(live_store.api_keys.active.publishable.count).to eq(1) + end + + it 'is idempotent and returns the same key on a second call' do + post :ensure_api_key, params: { url: 'live.example.com' }, format: :json + first_token = JSON.parse(response.body).dig('meta', 'public_api_key', 'token') + + post :ensure_api_key, params: { url: 'live.example.com' }, format: :json + second_token = JSON.parse(response.body).dig('meta', 'public_api_key', 'token') + + expect(second_token).to eq(first_token) + expect(live_store.api_keys.active.publishable.count).to eq(1) + end + + it 'returns not found when no store resolves for the url' do + allow(Spree.current_store_finder).to receive(:new).and_return( + instance_double(Spree.current_store_finder, execute: nil) + ) + + post :ensure_api_key, params: { url: 'missing.example.com' }, format: :json + + expect(response).to have_http_status(:not_found) + expect(JSON.parse(response.body).dig('meta', 'status')).to eq('not_found') + end + end end \ No newline at end of file diff --git a/spec/services/spree/olitt/clone_store/store_api_key_provisioner_spec.rb b/spec/services/spree/olitt/clone_store/store_api_key_provisioner_spec.rb new file mode 100644 index 0000000..b3f84b4 --- /dev/null +++ b/spec/services/spree/olitt/clone_store/store_api_key_provisioner_spec.rb @@ -0,0 +1,28 @@ +require 'spec_helper' + +describe Spree::Olitt::CloneStore::StoreApiKeyProvisioner do + describe '.call' do + let(:store) do + create(:store, default: false, url: 'shop.example.com', code: 'shop-store') + end + + it 'creates a publishable api key when the store has none' do + expect(store.api_keys.active.publishable.count).to eq(0) + + api_key = described_class.call(store) + + expect(api_key).to be_present + expect(api_key.key_type).to eq('publishable') + expect(api_key.store_id).to eq(store.id) + expect(store.api_keys.active.publishable.count).to eq(1) + end + + it 'returns the existing key instead of creating a second one' do + first_key = described_class.call(store) + second_key = described_class.call(store) + + expect(second_key.id).to eq(first_key.id) + expect(store.api_keys.active.publishable.count).to eq(1) + end + end +end From 9c2d966a2f93a53b017ae88436b32a36bd879415 Mon Sep 17 00:00:00 2001 From: Elias Date: Sat, 13 Jun 2026 06:56:28 +0300 Subject: [PATCH 2/4] feat(tests): add duplicate store resolution for API key minting adds a test to ensure that when two stores share the same URL, the API key is minted for the oldest store. this verifies correct behavior in the #ensure_api_key method for duplicate store scenarios. --- .../platform/clone_stores_controller_spec.rb | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/spec/controllers/spree/api/v2/platform/clone_stores_controller_spec.rb b/spec/controllers/spree/api/v2/platform/clone_stores_controller_spec.rb index 1668d39..03404f9 100644 --- a/spec/controllers/spree/api/v2/platform/clone_stores_controller_spec.rb +++ b/spec/controllers/spree/api/v2/platform/clone_stores_controller_spec.rb @@ -142,4 +142,25 @@ expect(JSON.parse(response.body).dig('meta', 'status')).to eq('not_found') end end + + describe '#ensure_api_key duplicate store resolution' do + let!(:older_store) do + create(:store, default: false, name: 'Older Store', url: 'conflict.example.com', code: 'older-store') + end + + let!(:newer_store) do + create(:store, default: false, name: 'Newer Store', url: 'conflict.example.com', code: 'newer-store') + end + + it 'mints the key on the oldest store when two stores share the same url' do + post :ensure_api_key, params: { url: 'conflict.example.com' }, format: :json + + expect(response).to have_http_status(:ok) + payload = JSON.parse(response.body) + expect(payload.dig('data', 'id')).to eq(older_store.id) + expect(payload.dig('meta', 'public_api_key', 'store_id')).to eq(older_store.id) + expect(older_store.reload.api_keys.active.publishable.count).to eq(1) + expect(newer_store.reload.api_keys.active.publishable.count).to eq(0) + end + end end \ No newline at end of file From d2f8e8ae1c36136b800c789c5c8e1d5fc41aa698 Mon Sep 17 00:00:00 2001 From: Elias Date: Sat, 13 Jun 2026 08:04:38 +0300 Subject: [PATCH 3/4] fix: harden ensure-api-key endpoint Order publishable key fetch by created_at desc for deterministic results on stores with data drift. Strip and blank-check the url param before passing to the store finder. Serialize store id as string for JSON:API compliance. --- .../spree/api/v2/platform/clone_stores_controller.rb | 7 +++++-- .../spree/olitt/clone_store/store_api_key_provisioner.rb | 4 ++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/app/controllers/spree/api/v2/platform/clone_stores_controller.rb b/app/controllers/spree/api/v2/platform/clone_stores_controller.rb index df71ef3..7fece58 100644 --- a/app/controllers/spree/api/v2/platform/clone_stores_controller.rb +++ b/app/controllers/spree/api/v2/platform/clone_stores_controller.rb @@ -35,7 +35,10 @@ def ensure_api_key private def resolve_ensure_api_key_store - Spree.current_store_finder.new(url: params[:url].presence).execute + url = params[:url].to_s.strip + return nil if url.blank? + + Spree.current_store_finder.new(url: url).execute end def render_ensure_api_key_store_not_found @@ -47,7 +50,7 @@ def render_ensure_api_key_store_not_found def ensure_api_key_payload(store, api_key) { - data: { id: store.id, type: 'store' }, + data: { id: store.id.to_s, type: 'store' }, status: 'completed', meta: { status: 'completed', diff --git a/app/services/spree/olitt/clone_store/store_api_key_provisioner.rb b/app/services/spree/olitt/clone_store/store_api_key_provisioner.rb index e4d489b..1a19f21 100644 --- a/app/services/spree/olitt/clone_store/store_api_key_provisioner.rb +++ b/app/services/spree/olitt/clone_store/store_api_key_provisioner.rb @@ -13,10 +13,10 @@ def initialize(store) def call return nil unless @store.respond_to?(:api_keys) - @store.api_keys.active.publishable.first || + @store.api_keys.active.publishable.order(created_at: :desc).first || @store.api_keys.create!(name: 'Storefront key', key_type: 'publishable') rescue ActiveRecord::RecordNotUnique - @store.api_keys.active.publishable.first + @store.api_keys.active.publishable.order(created_at: :desc).first end end end From 73e65cbced834393c7a99bd4a3fcf0b0598e23ed Mon Sep 17 00:00:00 2001 From: Elias Date: Sat, 13 Jun 2026 08:04:38 +0300 Subject: [PATCH 4/4] fix: harden ensure-api-key endpoint Order publishable key fetch by created_at desc for deterministic results on stores with data drift. Strip and blank-check the url param before passing to the store finder. Serialize store id as string for JSON:API compliance. --- .../spree/api/v2/platform/clone_stores_controller_spec.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/controllers/spree/api/v2/platform/clone_stores_controller_spec.rb b/spec/controllers/spree/api/v2/platform/clone_stores_controller_spec.rb index 03404f9..a0d40f1 100644 --- a/spec/controllers/spree/api/v2/platform/clone_stores_controller_spec.rb +++ b/spec/controllers/spree/api/v2/platform/clone_stores_controller_spec.rb @@ -113,7 +113,7 @@ expect(response).to have_http_status(:ok) payload = JSON.parse(response.body) - expect(payload.dig('data', 'id')).to eq(live_store.id) + expect(payload.dig('data', 'id')).to eq(live_store.id.to_s) expect(payload.dig('meta', 'status')).to eq('completed') expect(payload.dig('meta', 'public_api_key', 'token')).to be_present expect(payload.dig('meta', 'public_api_key', 'store_id')).to eq(live_store.id) @@ -157,7 +157,7 @@ expect(response).to have_http_status(:ok) payload = JSON.parse(response.body) - expect(payload.dig('data', 'id')).to eq(older_store.id) + expect(payload.dig('data', 'id')).to eq(older_store.id.to_s) expect(payload.dig('meta', 'public_api_key', 'store_id')).to eq(older_store.id) expect(older_store.reload.api_keys.active.publishable.count).to eq(1) expect(newer_store.reload.api_keys.active.publishable.count).to eq(0)