Merge pull request #6462 from betagouv/admin_can_add_api_part_token
Un administrateur peut ajouter un jeton api particulier à une procédure
This commit is contained in:
commit
86147ec165
18 changed files with 447 additions and 2 deletions
|
@ -0,0 +1,45 @@
|
|||
module NewAdministrateur
|
||||
class JetonParticulierController < AdministrateurController
|
||||
before_action :retrieve_procedure
|
||||
|
||||
def api_particulier
|
||||
end
|
||||
|
||||
def show
|
||||
end
|
||||
|
||||
def update
|
||||
@procedure.api_particulier_token = token
|
||||
|
||||
if @procedure.invalid?
|
||||
flash.now.alert = @procedure.errors.full_messages
|
||||
render :show
|
||||
elsif scopes.empty?
|
||||
flash.now.alert = t('.no_scopes_token')
|
||||
render :show
|
||||
else
|
||||
@procedure.save
|
||||
|
||||
redirect_to admin_procedure_api_particulier_jeton_path(procedure_id: @procedure.id),
|
||||
notice: t('.token_ok')
|
||||
end
|
||||
|
||||
rescue APIParticulier::Error::Unauthorized
|
||||
flash.now.alert = t('.not_found_token')
|
||||
render :show
|
||||
rescue APIParticulier::Error::HttpError
|
||||
flash.now.alert = t('.network_error')
|
||||
render :show
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def scopes
|
||||
@scopes ||= APIParticulier::API.new(token).scopes
|
||||
end
|
||||
|
||||
def token
|
||||
params[:procedure][:api_particulier_token]
|
||||
end
|
||||
end
|
||||
end
|
34
app/lib/api_particulier/api.rb
Normal file
34
app/lib/api_particulier/api.rb
Normal file
|
@ -0,0 +1,34 @@
|
|||
class APIParticulier::API
|
||||
include APIParticulier::Error
|
||||
|
||||
INTROSPECT_RESOURCE_NAME = "introspect"
|
||||
|
||||
TIMEOUT = 20
|
||||
|
||||
def initialize(token)
|
||||
@token = token
|
||||
end
|
||||
|
||||
def scopes
|
||||
get(INTROSPECT_RESOURCE_NAME)[:scopes]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def get(resource_name, params = {})
|
||||
url = [API_PARTICULIER_URL, resource_name].join("/")
|
||||
|
||||
response = Typhoeus.get(url,
|
||||
headers: { accept: "application/json", "X-API-Key": @token },
|
||||
params: params,
|
||||
timeout: TIMEOUT)
|
||||
|
||||
if response.success?
|
||||
JSON.parse(response.body, symbolize_names: true)
|
||||
elsif response.code == 401
|
||||
raise Unauthorized.new(response)
|
||||
else
|
||||
raise RequestFailed.new(response)
|
||||
end
|
||||
end
|
||||
end
|
32
app/lib/api_particulier/error.rb
Normal file
32
app/lib/api_particulier/error.rb
Normal file
|
@ -0,0 +1,32 @@
|
|||
module APIParticulier
|
||||
module Error
|
||||
class HttpError < ::StandardError
|
||||
def initialize(response)
|
||||
connect_time = response.connect_time
|
||||
curl_message = response.return_message
|
||||
http_error_code = response.code
|
||||
datetime = response.headers.fetch('Date', DateTime.current.inspect)
|
||||
total_time = response.total_time
|
||||
|
||||
uri = URI.parse(response.effective_url)
|
||||
url = "#{uri.host}#{uri.path}"
|
||||
|
||||
msg = <<~TEXT
|
||||
url: #{url}
|
||||
HTTP error code: #{http_error_code}
|
||||
#{response.body}
|
||||
curl message: #{curl_message}
|
||||
total time: #{total_time}
|
||||
connect time: #{connect_time}
|
||||
datetime: #{datetime}
|
||||
TEXT
|
||||
|
||||
super(msg)
|
||||
end
|
||||
end
|
||||
|
||||
class RequestFailed < HttpError; end
|
||||
|
||||
class Unauthorized < HttpError; end
|
||||
end
|
||||
end
|
|
@ -267,7 +267,7 @@ class Procedure < ApplicationRecord
|
|||
if: -> { new_record? || created_at > Date.new(2020, 11, 13) }
|
||||
|
||||
validates :api_entreprise_token, jwt_token: true, allow_blank: true
|
||||
validates :api_particulier_token, format: { with: /\A[A-Za-z0-9\-_=.]{15,}\z/, message: "n'est pas un jeton valide" }, allow_blank: true
|
||||
validates :api_particulier_token, format: { with: /\A[A-Za-z0-9\-_=.]{15,}\z/ }, allow_blank: true
|
||||
|
||||
before_save :update_juridique_required
|
||||
after_initialize :ensure_path_exists
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
= render partial: 'new_administrateur/breadcrumbs',
|
||||
locals: { steps: [link_to('Démarches', admin_procedures_path),
|
||||
link_to(@procedure.libelle, admin_procedure_path(@procedure)),
|
||||
Procedure.human_attribute_name(:jeton_api_particulier)] }
|
||||
|
||||
.container
|
||||
.flex
|
||||
= link_to admin_procedure_api_particulier_jeton_path, class: 'card-admin' do
|
||||
- if @procedure.api_particulier_token.blank?
|
||||
%div
|
||||
%span.icon.clock
|
||||
%p.card-admin-status-todo= t('.needs_configuration')
|
||||
- else
|
||||
%div
|
||||
%span.icon.accept
|
||||
%p.card-admin-status-accept= t('.already_configured')
|
||||
%div
|
||||
%p.card-admin-title
|
||||
= Procedure.human_attribute_name(:jeton_api_particulier)
|
||||
%p.button= t('views.shared.actions.edit')
|
|
@ -0,0 +1,22 @@
|
|||
= render partial: 'new_administrateur/breadcrumbs',
|
||||
locals: { steps: [link_to('Démarches', admin_procedures_path),
|
||||
link_to(@procedure.libelle, admin_procedure_path(@procedure)),
|
||||
link_to(Procedure.human_attribute_name(:jeton_api_particulier), admin_procedure_api_particulier_path(@procedure)),
|
||||
'Jeton'] }
|
||||
|
||||
.container
|
||||
%h1.page-title
|
||||
= t('.configure_token')
|
||||
|
||||
.container
|
||||
%h1
|
||||
= form_with model: @procedure, url: admin_procedure_api_particulier_jeton_path, local: true, html: { class: 'form' } do |f|
|
||||
%p.explication
|
||||
= t('.api_particulier_description_html', app_name: APPLICATION_NAME)
|
||||
|
||||
= f.label :api_particulier_token
|
||||
.desc.mb-2
|
||||
%p= t('.token_description')
|
||||
= f.password_field :api_particulier_token, class: 'form-control', required: :required
|
||||
.text-right
|
||||
= f.button t('views.shared.actions.save'), class: 'button primary send'
|
|
@ -194,10 +194,25 @@
|
|||
%span.icon.clock
|
||||
%p.card-admin-status-todo À configurer
|
||||
%div
|
||||
%p.card-admin-title Jeton
|
||||
%p.card-admin-title Jeton Entreprise
|
||||
%p.card-admin-subtitle Configurer le jeton API entreprise
|
||||
%p.button Modifier
|
||||
|
||||
- if feature_enabled?(:api_particulier)
|
||||
= link_to admin_procedure_api_particulier_path(@procedure), class: 'card-admin' do
|
||||
- if @procedure.api_particulier_token.present?
|
||||
%div
|
||||
%span.icon.accept
|
||||
%p.card-admin-status-accept= t('.ready')
|
||||
- else
|
||||
%div
|
||||
%span.icon.clock
|
||||
%p.card-admin-status-todo= t('.needs_configuration')
|
||||
%div
|
||||
%p.card-admin-title= Procedure.human_attribute_name(:api_particulier_token)
|
||||
%p.card-admin-subtitle= t('.configure_api_particulier_token')
|
||||
%p.button= t('views.shared.actions.edit')
|
||||
|
||||
= link_to monavis_admin_procedure_path(@procedure), class: 'card-admin' do
|
||||
- if @procedure.monavis_embed.present?
|
||||
%div
|
||||
|
|
|
@ -75,3 +75,6 @@ DS_ENV="staging"
|
|||
|
||||
# Désactivé l'OTP pour SuperAdmin
|
||||
# SUPER_ADMIN_OTP_ENABLED = "disabled" # "enabled" par défaut
|
||||
|
||||
# API Particulier https://api.gouv.fr/les-api/api-particulier
|
||||
# API_PARTICULIER_URL="https://particulier.api.gouv.fr/api"
|
||||
|
|
|
@ -27,6 +27,7 @@ end
|
|||
features = [
|
||||
:administrateur_routage,
|
||||
:administrateur_web_hook,
|
||||
:api_particulier,
|
||||
:dossier_pdf_vide,
|
||||
:expert_not_allowed_to_invite,
|
||||
:hide_instructeur_email,
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
# API URLs
|
||||
API_ENTREPRISE_URL = ENV.fetch("API_ENTREPRISE_URL", "https://entreprise.api.gouv.fr/v2")
|
||||
API_EDUCATION_URL = ENV.fetch("API_EDUCATION_URL", "https://data.education.gouv.fr/api/records/1.0")
|
||||
API_PARTICULIER_URL = ENV.fetch("API_PARTICULIER_URL", "https://particulier.api.gouv.fr/api")
|
||||
HELPSCOUT_API_URL = ENV.fetch("HELPSCOUT_API_URL", "https://api.helpscout.net/v2")
|
||||
PIPEDRIVE_API_URL = ENV.fetch("PIPEDRIVE_API_URL", "https://api.pipedrive.com/v1")
|
||||
SENDINBLUE_API_URL = ENV.fetch("SENDINBLUE_API_URL", "https://in-automate.sendinblue.com/api/v2")
|
||||
|
|
|
@ -97,6 +97,9 @@ fr:
|
|||
first: Premier
|
||||
truncate: '…'
|
||||
shared:
|
||||
actions:
|
||||
save: Enregistrer
|
||||
edit: Modifier
|
||||
greetings:
|
||||
hello: Bonjour,
|
||||
best_regards: Bonne journée,
|
||||
|
@ -366,3 +369,22 @@ fr:
|
|||
no_establishment: "Aucun établissement n’est associé à ce dossier"
|
||||
identity_saved: "Identité enregistrée"
|
||||
no_longer_available: "L’attestation n'est plus disponible sur ce dossier."
|
||||
new_administrateur:
|
||||
jeton_particulier:
|
||||
show:
|
||||
configure_token: "Configurer le jeton API Particulier"
|
||||
api_particulier_description_html: "%{app_name} utilise <a href=\"https://api.gouv.fr/les-api/api-particulier\">API Particulier</a> qui permet de récupérer les données familiales (CAF).<br />Renseignez ici le <a href=\"https://api.gouv.fr/les-api/api-particulier/demande-acces\">jeton API Particulier</a> propre à votre démarche."
|
||||
token_description: "Il doit contenir au minimum 15 caractères."
|
||||
update:
|
||||
token_ok: "Le jeton a bien été mis à jour"
|
||||
no_scopes_token: "Mise à jour impossible : le jeton n'a pas acces aux données.<br /><br />Vérifier le auprès de <a href='https://datapass.api.gouv.fr/'>https://datapass.api.gouv.fr/</a>"
|
||||
not_found_token: "Mise à jour impossible : le jeton n'a pas été trouvé ou n'est pas actif<br /><br />Vérifier le auprès de <a href='https://datapass.api.gouv.fr/'>https://datapass.api.gouv.fr/</a>"
|
||||
network_error: "Mise à jour impossible : une erreur réseau est survenue"
|
||||
procedures:
|
||||
show:
|
||||
ready: "Validé"
|
||||
needs_configuration: "À configurer"
|
||||
configure_api_particulier_token: "Configurer le jeton API particulier"
|
||||
api_particulier:
|
||||
already_configured: "Déjà rempli"
|
||||
needs_configuration: "À remplir"
|
||||
|
|
|
@ -16,3 +16,11 @@ fr:
|
|||
aasm_state/hidden: Suprimée
|
||||
declarative_with_state/en_instruction: En instruction
|
||||
declarative_with_state/accepte: Accepté
|
||||
api_particulier_token: Jeton API Particulier
|
||||
errors:
|
||||
models:
|
||||
procedure:
|
||||
attributes:
|
||||
api_particulier_token:
|
||||
invalid: 'n’a pas le bon format'
|
||||
|
||||
|
|
|
@ -397,6 +397,12 @@ Rails.application.routes.draw do
|
|||
put :experts_require_administrateur_invitation
|
||||
end
|
||||
|
||||
get :api_particulier, controller: 'jeton_particulier'
|
||||
|
||||
resource 'api_particulier', only: [] do
|
||||
resource 'jeton', only: [:show, :update], controller: 'jeton_particulier'
|
||||
end
|
||||
|
||||
put 'clone'
|
||||
put 'archive'
|
||||
get 'publication' => 'procedures#publication', as: :publication
|
||||
|
|
|
@ -0,0 +1,76 @@
|
|||
describe NewAdministrateur::JetonParticulierController, type: :controller do
|
||||
let(:admin) { create(:administrateur) }
|
||||
let(:procedure) { create(:procedure, administrateur: admin) }
|
||||
|
||||
before do
|
||||
stub_const("API_PARTICULIER_URL", "https://particulier.api.gouv.fr/api")
|
||||
|
||||
sign_in(admin.user)
|
||||
end
|
||||
|
||||
describe "GET #api_particulier" do
|
||||
let(:procedure) { create :procedure, :with_service, administrateur: admin }
|
||||
|
||||
render_views
|
||||
|
||||
subject { get :api_particulier, params: { procedure_id: procedure.id } }
|
||||
|
||||
it { is_expected.to have_http_status(:success) }
|
||||
it { expect(subject.body).to have_content('Jeton API particulier') }
|
||||
end
|
||||
|
||||
describe "GET #show" do
|
||||
subject { get :show, params: { procedure_id: procedure.id } }
|
||||
|
||||
it { is_expected.to have_http_status(:success) }
|
||||
end
|
||||
|
||||
describe "PATCH #update" do
|
||||
let(:params) { { procedure_id: procedure.id, procedure: { api_particulier_token: token } } }
|
||||
|
||||
subject { patch :update, params: params }
|
||||
|
||||
context "when jeton has a valid shape" do
|
||||
let(:token) { "d7e9c9f4c3ca00caadde31f50fd4521a" }
|
||||
before do
|
||||
VCR.use_cassette(cassette) do
|
||||
subject
|
||||
end
|
||||
end
|
||||
|
||||
context "and the api response is a success" do
|
||||
let(:cassette) { "api_particulier/success/introspect" }
|
||||
|
||||
it { expect(flash.alert).to be_nil }
|
||||
it { expect(flash.notice).to eq("Le jeton a bien été mis à jour") }
|
||||
it { expect(procedure.reload.api_particulier_token).to eql(token) }
|
||||
end
|
||||
|
||||
context "and the api response is a success but with an empty scopes" do
|
||||
let(:cassette) { "api_particulier/success/introspect_empty_scopes" }
|
||||
|
||||
it { expect(flash.alert).to include("le jeton n'a pas acces aux données") }
|
||||
it { expect(flash.notice).to be_nil }
|
||||
it { expect(procedure.reload.api_particulier_token).not_to eql(token) }
|
||||
end
|
||||
|
||||
context "and the api response is not unauthorized" do
|
||||
let(:cassette) { "api_particulier/unauthorized/introspect" }
|
||||
|
||||
it { expect(flash.alert).to include("Mise à jour impossible : le jeton n'a pas été trouvé ou n'est pas actif") }
|
||||
it { expect(flash.notice).to be_nil }
|
||||
it { expect(procedure.reload.api_particulier_token).not_to eql(token) }
|
||||
end
|
||||
end
|
||||
|
||||
context "when jeton is invalid and no network call is made" do
|
||||
let(:token) { "jet0n 1nvalide" }
|
||||
|
||||
before { subject }
|
||||
|
||||
it { expect(flash.alert.first).to include("pas le bon format") }
|
||||
it { expect(flash.notice).to be_nil }
|
||||
it { expect(procedure.reload.api_particulier_token).not_to eql(token) }
|
||||
end
|
||||
end
|
||||
end
|
44
spec/fixtures/cassettes/api_particulier/success/introspect.yml
vendored
Normal file
44
spec/fixtures/cassettes/api_particulier/success/introspect.yml
vendored
Normal file
|
@ -0,0 +1,44 @@
|
|||
---
|
||||
http_interactions:
|
||||
- request:
|
||||
method: get
|
||||
uri: https://particulier.api.gouv.fr/api/introspect
|
||||
body:
|
||||
encoding: US-ASCII
|
||||
string: ''
|
||||
headers:
|
||||
User-Agent:
|
||||
- demarches-simplifiees.fr
|
||||
Accept:
|
||||
- application/json
|
||||
X-Api-Key:
|
||||
- d7e9c9f4c3ca00caadde31f50fd4521a
|
||||
Expect:
|
||||
- ''
|
||||
response:
|
||||
status:
|
||||
code: 200
|
||||
message: OK
|
||||
headers:
|
||||
Date:
|
||||
- Tue, 16 Mar 2021 15:25:24 GMT
|
||||
Content-Type:
|
||||
- application/json
|
||||
Content-Length:
|
||||
- '228'
|
||||
Connection:
|
||||
- keep-alive
|
||||
Keep-Alive:
|
||||
- timeout=5
|
||||
X-Gravitee-Request-Id:
|
||||
- 0e4dd327-de40-4052-8dd3-27de401052c4
|
||||
X-Gravitee-Transaction-Id:
|
||||
- cc30bb74-6516-46d9-b0bb-746516d6d904
|
||||
Strict-Transport-Security:
|
||||
- max-age=15552000
|
||||
body:
|
||||
encoding: UTF-8
|
||||
string: '{"_id":"1d99db5a-a099-4314-ad2f-2707c6b505a6","name":"Application de
|
||||
sandbox","scopes":["dgfip_avis_imposition","dgfip_adresse","cnaf_allocataires","cnaf_enfants","cnaf_adresse","cnaf_quotient_familial","mesri_statut_etudiant"]}'
|
||||
recorded_at: Tue, 16 Mar 2021 15:25:24 GMT
|
||||
recorded_with: VCR 6.0.0
|
44
spec/fixtures/cassettes/api_particulier/success/introspect_empty_scopes.yml
vendored
Normal file
44
spec/fixtures/cassettes/api_particulier/success/introspect_empty_scopes.yml
vendored
Normal file
|
@ -0,0 +1,44 @@
|
|||
---
|
||||
http_interactions:
|
||||
- request:
|
||||
method: get
|
||||
uri: https://particulier.api.gouv.fr/api/introspect
|
||||
body:
|
||||
encoding: US-ASCII
|
||||
string: ''
|
||||
headers:
|
||||
User-Agent:
|
||||
- demarches-simplifiees.fr
|
||||
Accept:
|
||||
- application/json
|
||||
X-Api-Key:
|
||||
- d7e9c9f4c3ca00caadde31f50fd4521a
|
||||
Expect:
|
||||
- ''
|
||||
response:
|
||||
status:
|
||||
code: 200
|
||||
message: OK
|
||||
headers:
|
||||
Date:
|
||||
- Tue, 16 Mar 2021 15:25:24 GMT
|
||||
Content-Type:
|
||||
- application/json
|
||||
Content-Length:
|
||||
- '228'
|
||||
Connection:
|
||||
- keep-alive
|
||||
Keep-Alive:
|
||||
- timeout=5
|
||||
X-Gravitee-Request-Id:
|
||||
- 0e4dd327-de40-4052-8dd3-27de401052c4
|
||||
X-Gravitee-Transaction-Id:
|
||||
- cc30bb74-6516-46d9-b0bb-746516d6d904
|
||||
Strict-Transport-Security:
|
||||
- max-age=15552000
|
||||
body:
|
||||
encoding: UTF-8
|
||||
string: '{"_id":"1d99db5a-a099-4314-ad2f-2707c6b505a6","name":"Application de
|
||||
sandbox","scopes":[]}'
|
||||
recorded_at: Tue, 16 Mar 2021 15:25:24 GMT
|
||||
recorded_with: VCR 6.0.0
|
44
spec/fixtures/cassettes/api_particulier/unauthorized/introspect.yml
vendored
Normal file
44
spec/fixtures/cassettes/api_particulier/unauthorized/introspect.yml
vendored
Normal file
|
@ -0,0 +1,44 @@
|
|||
---
|
||||
http_interactions:
|
||||
- request:
|
||||
method: get
|
||||
uri: https://particulier.api.gouv.fr/api/introspect
|
||||
body:
|
||||
encoding: US-ASCII
|
||||
string: ''
|
||||
headers:
|
||||
User-Agent:
|
||||
- demarches-simplifiees.fr
|
||||
Accept:
|
||||
- application/json
|
||||
X-Api-Key:
|
||||
- d7e9c9f4c3ca00caadde31f50fd4521a
|
||||
Expect:
|
||||
- ''
|
||||
response:
|
||||
status:
|
||||
code: 401
|
||||
message: ''
|
||||
headers:
|
||||
Server:
|
||||
- nginx
|
||||
Date:
|
||||
- Wed, 15 Sep 2021 10:02:12 GMT
|
||||
Content-Type:
|
||||
- application/json; charset=utf-8
|
||||
Content-Length:
|
||||
- '134'
|
||||
X-Powered-By:
|
||||
- Express
|
||||
Vary:
|
||||
- Origin
|
||||
Etag:
|
||||
- W/"86-FwFf7uuVKCSJkazn1ZHnY1yVYUo"
|
||||
Strict-Transport-Security:
|
||||
- max-age=15724800; includeSubdomains
|
||||
body:
|
||||
encoding: UTF-8
|
||||
string: >
|
||||
{"error":"acces_denied","reason":"Token not found or inactive","message":"Votre jeton d'API n'a pas été trouvé ou n'est pas actif"}
|
||||
recorded_at: Wed, 15 Sep 2021 10:02:12 GMT
|
||||
recorded_with: VCR 6.0.0
|
28
spec/lib/api_particulier/api_spec.rb
Normal file
28
spec/lib/api_particulier/api_spec.rb
Normal file
|
@ -0,0 +1,28 @@
|
|||
describe APIParticulier::API do
|
||||
let(:token) { "d7e9c9f4c3ca00caadde31f50fd4521a" }
|
||||
let(:api) { APIParticulier::API.new(token) }
|
||||
|
||||
before { stub_const("API_PARTICULIER_URL", "https://particulier.api.gouv.fr/api") }
|
||||
|
||||
describe "scopes" do
|
||||
subject { api.scopes }
|
||||
|
||||
it "doit retourner une liste de scopes" do
|
||||
VCR.use_cassette("api_particulier/success/introspect") do
|
||||
expect(subject).to match_array(['dgfip_avis_imposition', 'dgfip_adresse', 'cnaf_allocataires', 'cnaf_enfants', 'cnaf_adresse', 'cnaf_quotient_familial', 'mesri_statut_etudiant'])
|
||||
end
|
||||
end
|
||||
|
||||
it "returns an unauthorized exception" do
|
||||
VCR.use_cassette("api_particulier/unauthorized/introspect") do
|
||||
begin
|
||||
subject
|
||||
rescue APIParticulier::Error::Unauthorized => e
|
||||
expect(e.message).to include('url: particulier.api.gouv.fr/api/introspect')
|
||||
expect(e.message).to include('HTTP error code: 401')
|
||||
expect(e.message).to include("Votre jeton d'API n'a pas été trouvé ou n'est pas actif")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
Loading…
Reference in a new issue