diff --git a/site/config/i18n-tasks.yml b/site/config/i18n-tasks.yml
index f40e5f0cf6..f2c772f202 100644
--- a/site/config/i18n-tasks.yml
+++ b/site/config/i18n-tasks.yml
@@ -116,7 +116,6 @@ ignore_unused:
- '*.sessions.after_logout.*'
- 'api_{entreprise,particulier}.fiches_pratiques_entries.*'
- 'api_{entreprise,particulier}.public_token_magic_links.show.*'
- - 'api_{entreprise,particulier}.tokens.token.scope.*'
- 'api_{entreprise,particulier}.documentation_pages.*.{title,sections}'
- 'api_{entreprise,particulier}.token_mailer.*'
- 'api_{entreprise,particulier}.authorization_request_mailer.*'
diff --git a/site/config/initializers/api_base_urls.rb b/site/config/initializers/api_base_urls.rb
index 4cda89462d..39dfed0b6c 100644
--- a/site/config/initializers/api_base_urls.rb
+++ b/site/config/initializers/api_base_urls.rb
@@ -17,3 +17,13 @@ module APIParticulier
'https://particulier.api.gouv.fr'
end
end
+
+module DataPass
+ BASE_URL = if Rails.env.sandbox?
+ 'https://sandbox.datapass.api.gouv.fr'
+ elsif Rails.env.staging? || Rails.env.development?
+ 'https://staging.datapass.api.gouv.fr'
+ else
+ 'https://datapass.api.gouv.fr'
+ end
+end
diff --git a/site/spec/clients/data_pass_api_authentication_spec.rb b/site/spec/clients/data_pass_api_authentication_spec.rb
new file mode 100644
index 0000000000..5a18be6fc8
--- /dev/null
+++ b/site/spec/clients/data_pass_api_authentication_spec.rb
@@ -0,0 +1,29 @@
+RSpec.describe DataPassAPIAuthentication do
+ describe '#access_token' do
+ subject(:access_token) { described_class.new.access_token }
+
+ let(:auth_url) { "#{DataPass::BASE_URL}/api/oauth/token" }
+
+ before do
+ allow(AdminApientreprise).to receive(:credentials).and_return(
+ datapass_client_id: 'test_client_id',
+ datapass_client_secret: 'test_client_secret'
+ )
+
+ stub_request(:post, auth_url)
+ .with(
+ body: 'grant_type=client_credentials&client_id=test_client_id&client_secret=test_client_secret&scope=read_authorizations',
+ headers: { 'Content-Type' => 'application/x-www-form-urlencoded' }
+ )
+ .to_return(
+ status: 200,
+ headers: { 'Content-Type' => 'application/json' },
+ body: { access_token: 'data_pass_access_token' }.to_json
+ )
+ end
+
+ it 'requests a token with the read_authorizations scope' do
+ expect(access_token).to eq('data_pass_access_token')
+ end
+ end
+end
diff --git a/site/spec/clients/data_pass_api_client_spec.rb b/site/spec/clients/data_pass_api_client_spec.rb
new file mode 100644
index 0000000000..7eff0dd12b
--- /dev/null
+++ b/site/spec/clients/data_pass_api_client_spec.rb
@@ -0,0 +1,49 @@
+RSpec.describe DataPassAPIClient do
+ let(:data_pass_api_authentication) { instance_double(DataPassAPIAuthentication, access_token: 'access_token') }
+
+ before do
+ allow(DataPassAPIAuthentication).to receive(:new).and_return(data_pass_api_authentication)
+ end
+
+ describe '#definitions' do
+ subject(:definitions) { described_class.new.definitions('api_entreprise') }
+
+ let(:url) { "#{DataPass::BASE_URL}/api/v1/definitions/api_entreprise" }
+
+ context 'when the API returns a 200' do
+ let(:payload) do
+ {
+ 'id' => 'api_entreprise',
+ 'name' => 'API Entreprise',
+ 'scopes' => [
+ { 'name' => 'Data', 'value' => 'a_scope', 'group' => 'Group', 'provider' => 'INSEE', 'link' => nil }
+ ]
+ }
+ end
+
+ before do
+ stub_request(:get, url)
+ .with(headers: { 'Authorization' => 'Bearer access_token' })
+ .to_return(
+ status: 200,
+ headers: { 'Content-Type' => 'application/json' },
+ body: payload.to_json
+ )
+ end
+
+ it 'returns the parsed definition payload' do
+ expect(definitions).to eq(payload)
+ end
+ end
+
+ context 'when the API returns an error status' do
+ before do
+ stub_request(:get, url).to_return(status: 500)
+ end
+
+ it 'raises a Faraday error' do
+ expect { definitions }.to raise_error(Faraday::Error)
+ end
+ end
+ end
+end
diff --git a/site/spec/features/api_particulier/authorization_request_show_spec.rb b/site/spec/features/api_particulier/authorization_request_show_spec.rb
index 808136e006..66e108c3a8 100644
--- a/site/spec/features/api_particulier/authorization_request_show_spec.rb
+++ b/site/spec/features/api_particulier/authorization_request_show_spec.rb
@@ -135,6 +135,55 @@
expect(page).to have_text(distance_of_time_in_words(Time.zone.now, banned_token.blacklisted_at))
end
+ describe 'when DataPass provides scope definitions' do
+ let!(:scopes) { ['cnaf_quotient_familial'] }
+
+ before do
+ Rails.cache.clear
+
+ stub_request(:get, %r{#{Regexp.escape(DataPass::BASE_URL)}/api/v1/definitions/})
+ .to_return(
+ status: 200,
+ headers: { 'Content-Type' => 'application/json' },
+ body: {
+ 'scopes' => [
+ {
+ 'value' => 'cnaf_quotient_familial',
+ 'name' => 'Quotient familial CAF & MSA',
+ 'group' => 'API Quotient familial',
+ 'provider' => 'CNAF & MSA'
+ }
+ ]
+ }.to_json
+ )
+
+ visit api_particulier_authorization_request_path(id: authorization_request.id)
+ end
+
+ it 'displays the provider and label fetched from DataPass' do
+ expect(page).to have_text('CNAF & MSA')
+ expect(page).to have_text('Quotient familial CAF & MSA')
+ end
+ end
+
+ describe 'when DataPass is unreachable' do
+ let!(:scopes) { ['cnaf_quotient_familial'] }
+
+ before do
+ Rails.cache.clear
+
+ stub_request(:get, %r{#{Regexp.escape(DataPass::BASE_URL)}/api/v1/definitions/})
+ .to_timeout
+
+ visit api_particulier_authorization_request_path(id: authorization_request.id)
+ end
+
+ it 'still renders the page, falling back to a humanized version of the scope code' do
+ expect(page).to have_current_path(api_particulier_authorization_request_path(id: authorization_request.id), ignore_query: true)
+ expect(page).to have_text('Cnaf quotient familial')
+ end
+ end
+
describe 'when the user is demandeur' do
describe 'when the token has less than 90 days left' do
it 'displays the button to prolong the token' do
diff --git a/site/spec/features/api_particulier/endpoints/show_spec.rb b/site/spec/features/api_particulier/endpoints/show_spec.rb
index dd95a86a7c..ecce23d06d 100644
--- a/site/spec/features/api_particulier/endpoints/show_spec.rb
+++ b/site/spec/features/api_particulier/endpoints/show_spec.rb
@@ -53,5 +53,29 @@
expect(page).to have_css('h2#scopes + p + ul li code', text: '(cnaf_enfants)')
expect(page).to have_css('h2#scopes + p + ul li code', text: '(cnaf_adresse)')
end
+
+ it 'renders the humanized scope label fetched from DataPass, not the raw scope code' do
+ Rails.cache.clear
+
+ stub_request(:get, "#{DataPass::BASE_URL}/api/v1/definitions/api_particulier")
+ .to_return(
+ status: 200,
+ headers: { 'Content-Type' => 'application/json' },
+ body: {
+ 'scopes' => [
+ {
+ 'value' => 'cnaf_quotient_familial',
+ 'name' => 'Quotient familial CAF & MSA',
+ 'group' => 'API Quotient familial',
+ 'provider' => 'CNAF & MSA'
+ }
+ ]
+ }.to_json
+ )
+
+ visit endpoint_path(uid:)
+
+ expect(page).to have_css('h2#scopes + p + ul li strong', text: 'Quotient familial CAF & MSA')
+ end
end
end
diff --git a/site/spec/helpers_spec/scope_helper_spec.rb b/site/spec/helpers_spec/scope_helper_spec.rb
index 222663d918..0b9e2af24d 100644
--- a/site/spec/helpers_spec/scope_helper_spec.rb
+++ b/site/spec/helpers_spec/scope_helper_spec.rb
@@ -1,44 +1,127 @@
-require 'rails_helper'
-
RSpec.describe ScopeHelper do
include described_class
+ let(:scope_catalog) { instance_double(ScopeCatalog) }
+
+ before do
+ allow(ScopeCatalog).to receive(:for).and_return(scope_catalog)
+ end
+
describe '#humanize_scope' do
- it 'returns the I18n label for a known scope' do
- expect(humanize_scope('unites_legales_etablissements_insee', 'entreprise'))
- .to eq('INSEE || API Données unités légales et établissements dont les non diffusibles')
+ before do
+ allow(scope_catalog).to receive(:lookup).and_return(catalog_entry)
+ end
+
+ context 'when the api is api_entreprise (2 levels: provider || name)' do
+ let(:catalog_entry) { { provider: 'INSEE', group: 'Informations générales', name: 'Données unités légales et établissements' } }
+
+ it 'returns provider || name, ignoring group' do
+ expect(humanize_scope('unites_legales_etablissements_insee', 'api_entreprise'))
+ .to eq('INSEE || Données unités légales et établissements')
+ end
+ end
+
+ context 'when the api is api_particulier (3 levels: provider || group || name)' do
+ let(:catalog_entry) { { provider: 'CNAF & MSA', group: 'API Quotient familial', name: 'Identités allocataire et conjoint' } }
+
+ it 'returns provider || group || name' do
+ expect(humanize_scope('cnaf_allocataires', 'api_particulier'))
+ .to eq('CNAF & MSA || API Quotient familial || Identités allocataire et conjoint')
+ end
end
- it 'falls back to scope.humanize when no I18n label is defined' do
- expect(humanize_scope('totally_unknown_scope', 'entreprise'))
- .to eq('Totally unknown scope')
+ context 'when the scope is unknown to the catalog' do
+ let(:catalog_entry) { nil }
+
+ it 'falls back to the "Autres" group with scope.humanize' do
+ expect(humanize_scope('totally_unknown_scope', 'api_entreprise'))
+ .to eq('Autres || Totally unknown scope')
+ end
+ end
+
+ context 'when the resolved entry has every field blank' do
+ let(:catalog_entry) { { provider: nil, group: nil, name: nil } }
+
+ it 'still produces a splittable 3-part string for api_particulier' do
+ expect(humanize_scope('totally_blank_scope', 'api_particulier'))
+ .to eq('Autres || Autres || Totally blank scope')
+ end
+
+ it 'still produces a splittable 2-part string for api_entreprise' do
+ expect(humanize_scope('totally_blank_scope', 'api_entreprise'))
+ .to eq('Autres || Totally blank scope')
+ end
end
end
describe '#build_scopes' do
- it 'returns correct tree structure for cnaf_quotient_familial scope' do
- scopes = ['cnaf_quotient_familial']
+ context 'when the api is api_particulier' do
+ before do
+ allow(scope_catalog).to receive(:lookup).with('cnaf_quotient_familial').and_return(
+ provider: 'CNAF & MSA', group: 'API Quotient familial', name: 'Quotient familial CAF & MSA'
+ )
+ allow(scope_catalog).to receive(:lookup).with('cnaf_allocataires').and_return(
+ provider: 'CNAF & MSA', group: 'API Quotient familial', name: 'Identités allocataire et conjoint'
+ )
+ end
+
+ it 'nests scopes under provider then group' do
+ result = build_scopes(%w[cnaf_quotient_familial cnaf_allocataires], 'api_particulier')
+
+ expect(result).to eq({
+ 'CNAF & MSA' => {
+ 'API Quotient familial' => ['Quotient familial CAF & MSA', 'Identités allocataire et conjoint']
+ }
+ })
+ end
+ end
+
+ context 'when the api is api_entreprise' do
+ before do
+ allow(scope_catalog).to receive(:lookup).with('unites_legales_etablissements_insee').and_return(
+ provider: 'INSEE', group: 'Informations générales', name: 'Données unités légales et établissements'
+ )
+ end
+
+ it 'nests scopes directly under provider, ignoring group' do
+ result = build_scopes(%w[unites_legales_etablissements_insee], 'api_entreprise')
+
+ expect(result).to eq({ 'INSEE' => ['Données unités légales et établissements'] })
+ end
+ end
+
+ context 'when a scope is not found in the catalog' do
+ before do
+ allow(scope_catalog).to receive(:lookup).with('totally_unknown_scope').and_return(nil)
+ end
- result = build_scopes(scopes, 'particulier')
+ it 'still shows the scope, under the fallback "Autres" group' do
+ result = build_scopes(['totally_unknown_scope'], 'api_entreprise')
- expect(result).to eq({
- 'CNAF & MSA' => {
- 'API Quotient familial' => ['QF']
- }
- })
+ expect(result).to eq({ 'Autres' => ['Totally unknown scope'] })
+ end
end
- it 'doesnt fail when same provider has mixed scope label lengths' do
- scopes = %w[pole_emploi_identite pole_emploi_paiements]
+ context 'when an api_particulier scope has a blank group but a sibling from the same provider has a real group' do
+ before do
+ allow(scope_catalog).to receive(:lookup).with('cnaf_allocataires').and_return(
+ provider: 'CNAF & MSA', group: 'API Quotient familial', name: 'Real Label'
+ )
+ allow(scope_catalog).to receive(:lookup).with('cnaf_no_group').and_return(
+ provider: 'CNAF & MSA', group: nil, name: 'No Group Label'
+ )
+ end
- result = build_scopes(scopes, 'particulier')
+ it "keeps the nil-group scope's real name visible instead of losing it" do
+ result = build_scopes(%w[cnaf_allocataires cnaf_no_group], 'api_particulier')
- expect(result).to eq({
- 'France Travail' => {
- "API statut demandeur d'emploi" => ['Statut et identité du demandeur'],
- 'API paiements France Travail' => []
- }
- })
+ expect(result).to eq({
+ 'CNAF & MSA' => {
+ 'API Quotient familial' => ['Real Label'],
+ 'Autres' => ['No Group Label']
+ }
+ })
+ end
end
end
end
diff --git a/site/spec/mailers/api_entreprise/authorization_request_mailer_spec.rb b/site/spec/mailers/api_entreprise/authorization_request_mailer_spec.rb
index 2e8281a320..9b46963ec3 100644
--- a/site/spec/mailers/api_entreprise/authorization_request_mailer_spec.rb
+++ b/site/spec/mailers/api_entreprise/authorization_request_mailer_spec.rb
@@ -22,5 +22,5 @@
],
test_scopes: true,
scope_test_method: 'embarquement_valide_to_demandeur_is_metier_not_tech',
- scope_label: I18n.t('api_entreprise.tokens.token.scope.entreprises.label')
+ scope_label: 'INSEE || Data unités légales'
end
diff --git a/site/spec/rails_helper.rb b/site/spec/rails_helper.rb
index 928b20696d..dba92e1b8b 100644
--- a/site/spec/rails_helper.rb
+++ b/site/spec/rails_helper.rb
@@ -94,6 +94,17 @@
config.before(:each, type: :feature) do
stub_request(:get, %r{entreprise\.api\.gouv\.fr/ping}).to_return(status: 200)
stub_request(:get, %r{particulier\.api\.gouv\.fr/api/.*/ping$}).to_return(status: 200)
+ stub_request(:post, "#{DataPass::BASE_URL}/api/oauth/token").to_return(
+ status: 200,
+ headers: { 'Content-Type' => 'application/json' },
+ body: { access_token: 'test_data_pass_access_token' }.to_json
+ )
+ stub_request(:get, %r{#{Regexp.escape(DataPass::BASE_URL)}/api/v1/definitions/})
+ .to_return(
+ status: 200,
+ headers: { 'Content-Type' => 'application/json' },
+ body: { 'scopes' => [] }.to_json
+ )
allow(SimplifionsStore.instance).to receive_messages(all: [], for_endpoint: [])
end
diff --git a/site/spec/services/scope_catalog_spec.rb b/site/spec/services/scope_catalog_spec.rb
new file mode 100644
index 0000000000..28d03fdc0f
--- /dev/null
+++ b/site/spec/services/scope_catalog_spec.rb
@@ -0,0 +1,86 @@
+require 'rails_helper'
+
+RSpec.describe ScopeCatalog do
+ include ActiveSupport::Testing::TimeHelpers
+
+ describe '#lookup' do
+ subject(:lookup) { described_class.for('api_entreprise').lookup('unites_legales_etablissements_insee') }
+
+ let(:data_pass_api_client) { instance_double(DataPassAPIClient, definitions: definitions_payload) }
+ let(:definitions_payload) do
+ {
+ 'scopes' => [
+ {
+ 'value' => 'unites_legales_etablissements_insee',
+ 'name' => 'Data',
+ 'group' => 'Informations générales',
+ 'provider' => 'INSEE'
+ }
+ ]
+ }
+ end
+
+ before do
+ allow(DataPassAPIClient).to receive(:new).and_return(data_pass_api_client)
+ end
+
+ it 'returns the provider/group/name for a known scope' do
+ expect(lookup).to eq(provider: 'INSEE', group: 'Informations générales', name: 'Data')
+ end
+
+ it 'returns nil for an unknown scope' do
+ expect(described_class.for('api_entreprise').lookup('totally_unknown')).to be_nil
+ end
+
+ it 'caches the fetched catalog so a second lookup does not call the client again' do
+ described_class.for('api_entreprise').lookup('unites_legales_etablissements_insee')
+ described_class.for('api_entreprise').lookup('unites_legales_etablissements_insee')
+
+ expect(data_pass_api_client).to have_received(:definitions).once
+ end
+
+ it 'writes the stale cache entry without an expiration so it never expires on its own' do
+ allow(Rails.cache).to receive(:write).and_call_original
+
+ lookup
+
+ expect(Rails.cache).to have_received(:write).with(anything, anything, expires_in: nil)
+ end
+
+ context 'when DataPass is unreachable and no cache exists yet' do
+ before do
+ allow(data_pass_api_client).to receive(:definitions).and_raise(Faraday::TimeoutError)
+ end
+
+ it 'returns nil instead of raising' do
+ expect(lookup).to be_nil
+ end
+ end
+
+ context 'when DataPass returns an unexpected payload shape and no cache exists yet' do
+ it 'returns nil instead of raising when the payload is not a Hash' do
+ allow(data_pass_api_client).to receive(:definitions).and_return([])
+
+ expect(lookup).to be_nil
+ end
+
+ it 'returns nil instead of raising when definitions returns nil' do
+ allow(data_pass_api_client).to receive(:definitions).and_return(nil)
+
+ expect(lookup).to be_nil
+ end
+ end
+
+ context 'when DataPass is unreachable but a previous successful fetch was cached' do
+ before do
+ described_class.for('api_entreprise').lookup('unites_legales_etablissements_insee')
+ travel(described_class::CACHE_TTL + 1.minute)
+ allow(data_pass_api_client).to receive(:definitions).and_raise(Faraday::TimeoutError)
+ end
+
+ it 'serves the stale cached value instead of raising' do
+ expect(lookup).to eq(provider: 'INSEE', group: 'Informations générales', name: 'Data')
+ end
+ end
+ end
+end
diff --git a/site/spec/support/shared_examples/mailers/authorization_request_mailer.rb b/site/spec/support/shared_examples/mailers/authorization_request_mailer.rb
index f8b2bc2b4e..15692ff57e 100644
--- a/site/spec/support/shared_examples/mailers/authorization_request_mailer.rb
+++ b/site/spec/support/shared_examples/mailers/authorization_request_mailer.rb
@@ -38,7 +38,30 @@
end
describe 'when there is a token' do
- let(:authorization_request) { create(:authorization_request, :with_all_contacts, :with_tokens, scopes: ['entreprises']) }
+ let(:authorization_request) { create(:authorization_request, :with_all_contacts, :with_tokens, scopes: ['unites_legales_etablissements_insee']) }
+
+ before do
+ stub_request(:post, "#{DataPass::BASE_URL}/api/oauth/token").to_return(
+ status: 200,
+ headers: { 'Content-Type' => 'application/json' },
+ body: { access_token: 'test_data_pass_access_token' }.to_json
+ )
+ stub_request(:get, %r{#{Regexp.escape(DataPass::BASE_URL)}/api/v1/definitions/})
+ .to_return(
+ status: 200,
+ headers: { 'Content-Type' => 'application/json' },
+ body: {
+ 'scopes' => [
+ {
+ 'value' => 'unites_legales_etablissements_insee',
+ 'name' => 'Data unités légales',
+ 'group' => 'Informations générales',
+ 'provider' => 'INSEE'
+ }
+ ]
+ }.to_json
+ )
+ end
it 'display scopes' do
expect(subject.html_part.decoded).to include('Cette habilitation donne accĂšs aux API suivantes')