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:
LeSim 2021-09-16 09:20:02 +02:00 committed by GitHub
commit 86147ec165
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 447 additions and 2 deletions

View file

@ -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

View 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

View 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

View file

@ -267,7 +267,7 @@ class Procedure < ApplicationRecord
if: -> { new_record? || created_at > Date.new(2020, 11, 13) } if: -> { new_record? || created_at > Date.new(2020, 11, 13) }
validates :api_entreprise_token, jwt_token: true, allow_blank: true 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 before_save :update_juridique_required
after_initialize :ensure_path_exists after_initialize :ensure_path_exists

View file

@ -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')

View file

@ -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'

View file

@ -194,10 +194,25 @@
%span.icon.clock %span.icon.clock
%p.card-admin-status-todo À configurer %p.card-admin-status-todo À configurer
%div %div
%p.card-admin-title Jeton %p.card-admin-title Jeton Entreprise
%p.card-admin-subtitle Configurer le jeton API entreprise %p.card-admin-subtitle Configurer le jeton API entreprise
%p.button Modifier %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 = link_to monavis_admin_procedure_path(@procedure), class: 'card-admin' do
- if @procedure.monavis_embed.present? - if @procedure.monavis_embed.present?
%div %div

View file

@ -75,3 +75,6 @@ DS_ENV="staging"
# Désactivé l'OTP pour SuperAdmin # Désactivé l'OTP pour SuperAdmin
# SUPER_ADMIN_OTP_ENABLED = "disabled" # "enabled" par défaut # 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"

View file

@ -27,6 +27,7 @@ end
features = [ features = [
:administrateur_routage, :administrateur_routage,
:administrateur_web_hook, :administrateur_web_hook,
:api_particulier,
:dossier_pdf_vide, :dossier_pdf_vide,
:expert_not_allowed_to_invite, :expert_not_allowed_to_invite,
:hide_instructeur_email, :hide_instructeur_email,

View file

@ -2,6 +2,7 @@
# API URLs # API URLs
API_ENTREPRISE_URL = ENV.fetch("API_ENTREPRISE_URL", "https://entreprise.api.gouv.fr/v2") 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_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") 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") 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") SENDINBLUE_API_URL = ENV.fetch("SENDINBLUE_API_URL", "https://in-automate.sendinblue.com/api/v2")

View file

@ -97,6 +97,9 @@ fr:
first: Premier first: Premier
truncate: '&hellip;' truncate: '&hellip;'
shared: shared:
actions:
save: Enregistrer
edit: Modifier
greetings: greetings:
hello: Bonjour, hello: Bonjour,
best_regards: Bonne journée, best_regards: Bonne journée,
@ -366,3 +369,22 @@ fr:
no_establishment: "Aucun établissement nest associé à ce dossier" no_establishment: "Aucun établissement nest associé à ce dossier"
identity_saved: "Identité enregistrée" identity_saved: "Identité enregistrée"
no_longer_available: "Lattestation n'est plus disponible sur ce dossier." no_longer_available: "Lattestation 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"

View file

@ -16,3 +16,11 @@ fr:
aasm_state/hidden: Suprimée aasm_state/hidden: Suprimée
declarative_with_state/en_instruction: En instruction declarative_with_state/en_instruction: En instruction
declarative_with_state/accepte: Accepté declarative_with_state/accepte: Accepté
api_particulier_token: Jeton API Particulier
errors:
models:
procedure:
attributes:
api_particulier_token:
invalid: 'na pas le bon format'

View file

@ -397,6 +397,12 @@ Rails.application.routes.draw do
put :experts_require_administrateur_invitation put :experts_require_administrateur_invitation
end end
get :api_particulier, controller: 'jeton_particulier'
resource 'api_particulier', only: [] do
resource 'jeton', only: [:show, :update], controller: 'jeton_particulier'
end
put 'clone' put 'clone'
put 'archive' put 'archive'
get 'publication' => 'procedures#publication', as: :publication get 'publication' => 'procedures#publication', as: :publication

View file

@ -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

View 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

View 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

View 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

View 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