Merge pull request #10865 from demarches-simplifiees/fix-10799

ETQ Admin / Instructeur je veux être savoir si le jeton api entreprise d'une démarche a expiré ou va expirer prochainement
This commit is contained in:
Mathieu Magnin 2024-10-15 13:25:20 +00:00 committed by GitHub
commit 097074fdc7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 340 additions and 50 deletions

View file

@ -1,3 +1,3 @@
---
fr:
title: Jeton Entreprise
title: Jeton API Entreprise

View file

@ -1,11 +1,14 @@
.fr-col-6.fr-col-md-4.fr-col-lg-3
= link_to jeton_admin_procedure_path(@procedure), class: 'fr-tile fr-enlarge-link' do
.fr-tile__body.flex.column.align-center.justify-between
- if @procedure.api_entreprise_token.present?
%p.fr-badge.fr-badge--success Validé
- if @procedure.has_api_entreprise_token?
- if @procedure.api_entreprise_token_expired_or_expires_soon?
%p.fr-badge.fr-badge--error À renouveler
- else
%p.fr-badge.fr-badge--success Validé
- else
%p.fr-badge.fr-badge--info À configurer
%div
%h3.fr-h6.fr-mt-10v= t('.title')
%p.fr-tile-subtitle Configurer le jeton API entreprise
%p.fr-tile-subtitle Configurer le jeton API Entreprise
%p.fr-btn.fr-btn--tertiary= t('views.shared.actions.edit')

View file

@ -17,6 +17,10 @@ class APIEntrepriseToken
decoded_token.key?("exp") && decoded_token["exp"] <= Time.zone.now.to_i
end
def expiration
decoded_token.key?("exp") && Time.zone.at(decoded_token["exp"])
end
def role?(role)
roles.include?(role)
end

View file

@ -0,0 +1,33 @@
# frozen_string_literal: true
module APIEntrepriseTokenConcern
extend ActiveSupport::Concern
SOON_TO_EXPIRE_DELAY = 1.month
included do
validates :api_entreprise_token, jwt_token: true, allow_blank: true
before_save :set_api_entreprise_token_expires_at, if: :will_save_change_to_api_entreprise_token?
def api_entreprise_role?(role)
APIEntrepriseToken.new(api_entreprise_token).role?(role)
end
def api_entreprise_token
self[:api_entreprise_token].presence || Rails.application.secrets.api_entreprise[:key]
end
def api_entreprise_token_expired_or_expires_soon?
api_entreprise_token_expires_at && api_entreprise_token_expires_at <= SOON_TO_EXPIRE_DELAY.from_now
end
def has_api_entreprise_token?
self[:api_entreprise_token].present?
end
def set_api_entreprise_token_expires_at
self.api_entreprise_token_expires_at = has_api_entreprise_token? ? APIEntrepriseToken.new(api_entreprise_token).expiration : nil
end
end
end

View file

@ -1,6 +1,7 @@
# frozen_string_literal: true
class Procedure < ApplicationRecord
include APIEntrepriseTokenConcern
include ProcedureStatsConcern
include EncryptableConcern
include InitiationProcedureConcern
@ -284,7 +285,6 @@ class Procedure < ApplicationRecord
size: { less_than: LOGO_MAX_SIZE },
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/ }, allow_blank: true
validate :validate_auto_archive_on_in_the_future, if: :will_save_change_to_auto_archive_on?
@ -762,18 +762,6 @@ class Procedure < ApplicationRecord
"Procedure;#{id}"
end
def api_entreprise_role?(role)
APIEntrepriseToken.new(api_entreprise_token).role?(role)
end
def api_entreprise_token
self[:api_entreprise_token].presence || Rails.application.secrets.api_entreprise[:key]
end
def api_entreprise_token_expired?
APIEntrepriseToken.new(api_entreprise_token).expired?
end
def create_new_revision(revision = nil)
transaction do
new_revision = (revision || draft_revision)

View file

@ -0,0 +1,14 @@
# frozen_string_literal: true
module Maintenance
class UpdateAPIEntrepriseTokenExpiresAtTask < MaintenanceTasks::Task
def collection
Procedure.with_discarded.where.not(api_entreprise_token: nil)
end
def process(procedure)
procedure.set_api_entreprise_token_expires_at
procedure.save!
end
end
end

View file

@ -28,6 +28,11 @@
- elsif @procedure.locked?
= link_to commencer_url(@procedure.path), commencer_url(@procedure.path), class: "fr-link"
.flex.fr-mt-1w
- if @procedure.api_entreprise_token_expired_or_expires_soon?
%span.fr-badge.fr-badge--error.fr-mr-1w
= t('to_modify', scope: [:layouts, :breadcrumb])
%span.fr-badge.fr-badge--success.fr-mr-1w
= t('published', scope: [:layouts, :breadcrumb])
= t('since', scope: [:layouts, :breadcrumb], number: @procedure.id, date: l(@procedure.published_at.to_date))

View file

@ -0,0 +1,14 @@
- if procedure.api_entreprise_token_expires_at.present?
- if procedure.api_entreprise_token_expires_at < Time.zone.now
= render Dsfr::AlertComponent.new(state: :error, size: :sm, extra_class_names: 'fr-mb-2w') do |c|
- c.with_body do
%p
Votre jeton API Entreprise est expiré.
Merci de le renouveler.
- elsif procedure.api_entreprise_token_expired_or_expires_soon?
= render Dsfr::AlertComponent.new(state: :warning, size: :sm, extra_class_names: 'fr-mb-2w') do |c|
- c.with_body do
%p
Votre jeton API Entreprise expirera le
= procedure.api_entreprise_token_expires_at.strftime('%d/%m/%Y à %H:%M.')
Merci de le renouveler avant cette date.

View file

@ -54,11 +54,15 @@
.text-right
%p.fr-mb-0.width-max-content N° #{number_with_html_delimiter(procedure.id)}
- if procedure.close? || procedure.depubliee?
%span.fr-badge.fr-badge--sm.fr-badge--warning
= t('closed', scope: [:layouts, :breadcrumb])
- elsif procedure.publiee?
- if procedure.api_entreprise_token_expired_or_expires_soon?
%span.fr-badge.fr-badge--sm.fr-badge--error
= t('to_modify', scope: [:layouts, :breadcrumb])
%span.fr-badge.fr-badge--sm.fr-badge--success
= t('published', scope: [:layouts, :breadcrumb])

View file

@ -1,10 +1,10 @@
= render partial: 'administrateurs/breadcrumbs',
locals: { steps: [['Démarches', admin_procedures_back_path(@procedure)],
[@procedure.libelle.truncate_words(10), admin_procedure_path(@procedure)],
['Jeton Entreprise']] }
['Jeton API Entreprise']] }
.fr-container
%h1.fr-h2 Jeton Entreprise
%h1.fr-h2 Jeton API Entreprise
= form_with model: @procedure, url: url_for({ controller: 'administrateurs/procedures', action: :update_jeton }) do |f|
.fr-container
@ -14,12 +14,16 @@
Démarches Simplifiées utilise
= link_to 'API Entreprise', "https://entreprise.api.gouv.fr/"
qui permet de récupérer les informations administratives des entreprises et des associations.
Si votre démarche nécessite des autorisations spécifiques que Démarches Simplifiées na pas par défaut, merci de renseigner ici le jeton
= link_to 'API Entreprise', "https://api.gouv.fr/les-api/api-entreprise/demande-acces"
Si votre démarche nécessite des autorisations spécifiques que Démarches Simplifiées na pas par défaut, merci de renseigner ci-dessous
%strong le jeton API Entreprise
propre à votre démarche.
%p
Si besoin, vous pouvez demander une habilitation API Entreprise en cliquant sur le lien suivant :
= link_to "https://api.gouv.fr/les-api/api-entreprise/demande-acces.", "https://api.gouv.fr/les-api/api-entreprise/demande-acces"
.fr-input-group
= f.label :api_entreprise_token, "Jeton", class: 'fr-label'
= f.password_field :api_entreprise_token, value: @procedure.read_attribute(:api_entreprise_token), class: 'fr-input'
= render partial: 'administrateurs/procedures/api_entreprise_token_expiration_alert', locals: { procedure: @procedure }
= render Dsfr::InputComponent.new(form: f, attribute: :api_entreprise_token, input_type: :password_field, required: false, opts: { value: @procedure.read_attribute(:api_entreprise_token)})
= render Procedure::FixedFooterComponent.new(procedure: @procedure, form: f)

View file

@ -27,6 +27,15 @@
= link_to 'Clore', admin_procedure_close_path(procedure_id: @procedure.id), class: 'fr-btn fr-btn--tertiary fr-btn--icon-left fr-icon-calendar-close-fill', id: "close-procedure-link"
.fr-container
- if @procedure.api_entreprise_token_expired_or_expires_soon?
= render Dsfr::AlertComponent.new(state: :error, title: t(:technical_issues, scope: [:administrateurs, :procedures]), extra_class_names: 'fr-mb-2w') do |c|
- c.with_body do
%ul.fr-mb-0
%li
Le
= link_to "Jeton API Entreprise", jeton_admin_procedure_path(@procedure), class: 'error-anchor'
est expiré ou va expirer prochainement
- if @procedure.draft_changed?
= render Dsfr::CalloutComponent.new(title: t(:has_changes, scope: [:administrateurs, :revision_changes]), icon: "fr-fi-information-line") do |c|
- c.with_body do

View file

@ -7,6 +7,7 @@ en:
attributes:
procedure:
hints:
api_entreprise_token: 'For example: eyJhbGciOiJIUzI1NiJ9.eyJ1...'
description: Describe in a few lines the context, the aim etc.
description_target_audience: Describe in a few lines the final recipients of the process, the eligibility criteria if there are any, the prerequisites, etc.
description_pj: Describe the required attachments list if there is any
@ -48,6 +49,7 @@ en:
personne_morale: 'Legal entity'
declarative_with_state/en_instruction: Instruction
declarative_with_state/accepte: Accepted
api_entreprise_token: Token API Entreprise
api_particulier_token: Token API Particulier
initiated_mail: File sorted for processing notification email
received_mail: File submitted notification email

View file

@ -7,6 +7,7 @@ fr:
attributes:
procedure:
hints:
api_entreprise_token: 'Exemple : eyJhbGciOiJIUzI1NiJ9.eyJ1...'
description: Décrivez en quelques lignes le contexte, la finalité, etc.
description_target_audience: Décrivez en quelques lignes les destinataires finaux de la démarche, les conditions déligibilité sil y en a, les pré-requis, etc.
description_pj: Décrivez la liste des pièces jointes à fournir sil y en a
@ -54,6 +55,7 @@ fr:
personne_morale: 'Personne morale'
declarative_with_state/en_instruction: En instruction
declarative_with_state/accepte: Accepté
api_entreprise_token: Jeton API Entreprise
api_particulier_token: Jeton API Particulier
initiated_mail: Lemail de notification de passage de dossier en instruction
received_mail: Lemail de notification de dépôt de dossier

View file

@ -67,6 +67,7 @@ en:
dpd_part_4: How to do ? You can either send him the link to the procedure on test stage by email, or name him "administrator". In any case, publish your approach only after having had his opinion.
back_to_procedure: 'Cancel and return to the procedure page'
submit: Publish
technical_issues: "Issues are affecting the proper functioning of the process"
check_path:
path_not_available:
owner: This URL is identical to another of your published procedures. If you publish this procedure, the old one will be unpublished and will no longer be accessible to the public.

View file

@ -67,6 +67,7 @@ fr:
dpd_part_4: Comment faire ? Vous pouvez soit lui communiquer par email le lien vers la démarche en test, ou bien le nommer « administrateur ». Dans tous les cas, ne publiez votre démarche quaprès avoir eu son avis.
back_to_procedure: 'Annuler et revenir à la page de la démarche'
submit: Publier
technical_issues: Des problèmes impactent le bon fonctionnement de la démarche
check_path:
path_not_available:
owner: Cette url est identique à celle dune autre de vos démarches publiées. Si vous publiez cette démarche, lancienne sera dépubliée et ne sera plus accessible au public.

View file

@ -11,6 +11,7 @@ en:
closed: "Closed"
published: "Published"
draft: "Draft"
to_modify: "To modify"
more_info_on_test: "For more information on test stage"
go_to_FAQ: "read FAQ"
url_FAQ: "/faq#accordion-administrateur-2"

View file

@ -11,6 +11,7 @@ fr:
closed: "Close"
published: "Publiée"
draft: "En test"
to_modify:  modifier"
more_info_on_test: "Pour plus dinformation sur la phase de test"
go_to_FAQ: "consulter la FAQ"
url_FAQ: "/faq#accordion-administrateur-2"

View file

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddAPIEntrepriseTokenExpiresAtToProcedures < ActiveRecord::Migration[7.0]
def change
add_column :procedures, :api_entreprise_token_expires_at, :datetime, precision: nil
end
end

View file

@ -242,8 +242,8 @@ ActiveRecord::Schema[7.0].define(version: 2024_09_29_141825) do
t.integer "dossier_count"
t.string "dossier_state"
t.bigint "instructeur_id", null: false
t.datetime "sent_at", precision: nil, null: false
t.bigint "procedure_id"
t.datetime "sent_at", precision: nil, null: false
t.datetime "updated_at", null: false
end
@ -933,6 +933,7 @@ ActiveRecord::Schema[7.0].define(version: 2024_09_29_141825) do
t.boolean "allow_expert_messaging", default: true, null: false
t.boolean "allow_expert_review", default: true, null: false
t.string "api_entreprise_token"
t.datetime "api_entreprise_token_expires_at", precision: nil
t.text "api_particulier_scopes", default: [], array: true
t.jsonb "api_particulier_sources", default: {}
t.boolean "ask_birthday", default: false, null: false

View file

@ -0,0 +1,27 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe Procedure::Card::APIEntrepriseComponent, type: :component do
subject { render_inline(described_class.new(procedure:)) }
let(:procedure) { create(:procedure, api_entreprise_token:) }
context "Token is not configured" do
let(:api_entreprise_token) { nil }
it { is_expected.to have_css('p.fr-badge.fr-badge--info', text: "À configurer") }
end
context "Token expires soon" do
let(:api_entreprise_token) { JWT.encode({ exp: 2.days.from_now.to_i }, nil, "none") }
it { is_expected.to have_css('p.fr-badge.fr-badge--error', text: "À renouveler") }
end
context "Token expires in a long time" do
let(:api_entreprise_token) { JWT.encode({ exp: 2.months.from_now.to_i }, nil, "none") }
it { is_expected.to have_css('p.fr-badge.fr-badge--success', text: "Validé") }
end
end

View file

@ -138,4 +138,34 @@ describe APIEntrepriseToken, type: :model do
end
end
end
describe "#expiration" do
subject { api_entreprise_token.expiration }
context "without token" do
let(:token) { nil }
it { expect { subject }.to raise_exception(APIEntrepriseToken::TokenError) }
end
context "with a blank token" do
let(:token) { "" }
it { expect { subject }.to raise_exception(APIEntrepriseToken::TokenError) }
end
context "with an invalid token" do
let(:token) { "NOT-A-VALID-TOKEN" }
it { expect { subject }.to raise_exception(APIEntrepriseToken::TokenError) }
end
context "with a valid token" do
let(:token) { "eyJhbGciOiJIUzI1NiJ9.eyJ1aWQiOiI2NjRkZWEyMS02YWFlLTQwZmYtYWM0Mi1kZmQ3ZGE4YjQ3NmUiLCJqdGkiOiJhcGktZW50cmVwcmlzZS1zdGFnaW5nIiwicm9sZXMiOlsiY2VydGlmaWNhdF9jbmV0cCIsInByb2J0cCIsImV0YWJsaXNzZW1lbnRzIiwicHJpdmlsZWdlcyIsInVwdGltZSIsImF0dGVzdGF0aW9uc19hZ2VmaXBoIiwiYWN0ZXNfaW5waSIsImJpbGFuc19pbnBpIiwiYWlkZXNfY292aWRfZWZmZWN0aWZzIiwiY2VydGlmaWNhdF9yZ2VfYWRlbWUiLCJhdHRlc3RhdGlvbnNfc29jaWFsZXMiLCJlbnRyZXByaXNlX2FydGlzYW5hbGUiLCJmbnRwX2NhcnRlX3BybyIsImNvbnZlbnRpb25zX2NvbGxlY3RpdmVzIiwiZXh0cmFpdHNfcmNzIiwiZXh0cmFpdF9jb3VydF9pbnBpIiwiY2VydGlmaWNhdF9hZ2VuY2VfYmlvIiwibXNhX2NvdGlzYXRpb25zIiwiZG9jdW1lbnRzX2Fzc29jaWF0aW9uIiwiZW9yaV9kb3VhbmVzIiwiYXNzb2NpYXRpb25zIiwiYmlsYW5zX2VudHJlcHJpc2VfYmRmIiwiZW50cmVwcmlzZXMiLCJxdWFsaWJhdCIsImNlcnRpZmljYXRfb3BxaWJpIiwiZW50cmVwcmlzZSIsImV0YWJsaXNzZW1lbnQiXSwic3ViIjoic3RhZ2luZyBkZXZlbG9wbWVudCIsImlhdCI6MTY0MTMwNDcxNCwidmVyc2lvbiI6IjEuMCIsImV4cCI6MTY4ODQ3NTUxNH0.xID66pIlMnBR5_6nG-GidFBzK4Tuuy5ZsWfkMEVB_Ek" }
it "returns the correct expiration time" do
expect(subject).to eq(Time.zone.at(1688475514))
end
end
end
end

View file

@ -0,0 +1,84 @@
# frozen_string_literal: true
describe APIEntrepriseTokenConcern do
describe "#api_entreprise_token_expired_or_expires_soon?" do
subject { procedure.api_entreprise_token_expired_or_expires_soon? }
let(:procedure) { create(:procedure, api_entreprise_token:) }
context "when there is no token" do
let(:api_entreprise_token) { nil }
it { is_expected.to be_falsey }
end
context "when the token expires in 2 months" do
let(:api_entreprise_token) { JWT.encode({ exp: 2.months.from_now.to_i }, nil, "none") }
it { is_expected.to be_falsey }
end
context "when the token expires tomorrow" do
let(:api_entreprise_token) { JWT.encode({ exp: 1.day.from_now.to_i }, nil, "none") }
it { is_expected.to be_truthy }
end
context "when the token is expired" do
let(:api_entreprise_token) { JWT.encode({ exp: 1.day.ago.to_i }, nil, "none") }
it { is_expected.to be_truthy }
end
end
describe '#set_api_entreprise_token_expires_at (before_save)' do
let(:procedure) { create(:procedure, api_entreprise_token: initial_api_entreprise_token) }
before do
procedure.api_entreprise_token = api_entreprise_token
end
subject { procedure.save }
context "when procedure had no api_entreprise_token" do
let(:initial_api_entreprise_token) { nil }
context 'when the api_entreprise_token is nil' do
let(:api_entreprise_token) { nil }
it 'does not set the api_entreprise_token_expires_at' do
expect { subject }.not_to change { procedure.api_entreprise_token_expires_at }.from(nil)
end
end
context 'when the api_entreprise_token is not valid' do
let(:api_entreprise_token) { "not a token" }
it do
expect { subject }.not_to change { procedure.api_entreprise_token_expires_at }.from(nil)
end
end
context 'when the api_entreprise_token is valid' do
let(:expiration_date) { Time.zone.now.beginning_of_minute }
let(:api_entreprise_token) { JWT.encode({ exp: expiration_date.to_i }, nil, 'none') }
it do
expect { subject }.to change { procedure.api_entreprise_token_expires_at }.from(nil).to(expiration_date)
end
end
end
context "when procedure had an api_entreprise_token" do
let(:initial_api_entreprise_token) { JWT.encode({ exp: 2.months.from_now.to_i }, nil, "none") }
context 'when the api_entreprise_token is set to nil' do
let(:api_entreprise_token) { nil }
it do
expect { subject }.to change { procedure.api_entreprise_token_expires_at }.to(nil)
end
end
end
end
end

View file

@ -621,31 +621,6 @@ describe Procedure do
end
end
describe 'api_entreprise_token_expired?' do
let(:token) { "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" }
let(:procedure) { create(:procedure, api_entreprise_token: token) }
let(:payload) {
[
{ "exp" => expiration_time }
]
}
let(:subject) { procedure.api_entreprise_token_expired? }
before do
allow(JWT).to receive(:decode).with(token, nil, false).and_return(payload)
end
context "with token expired" do
let(:expiration_time) { (1.day.ago).to_i }
it { is_expected.to be_truthy }
end
context "with token not expired" do
let(:expiration_time) { (1.day.from_now).to_i }
it { is_expected.to be_falsey }
end
end
describe 'clone' do
let(:service) { create(:service) }
let(:procedure) do

View file

@ -0,0 +1,33 @@
# frozen_string_literal: true
module Maintenance
RSpec.describe UpdateAPIEntrepriseTokenExpiresAtTask do
describe '#collection' do
subject(:collection) { described_class.collection }
let!(:procedures_with_token) { create_list(:procedure, 3, api_entreprise_token: JWT.encode({}, nil, 'none')) }
let!(:procedure_without_token) { create(:procedure, api_entreprise_token: nil) }
it 'returns procedures with api_entreprise_token present' do
expect(collection).to match_array(procedures_with_token)
expect(collection).not_to include(procedure_without_token)
end
end
describe "#process" do
subject(:process) { described_class.process(procedure) }
let(:expiration) { 1.month.from_now.beginning_of_minute }
let(:procedure) { create(:procedure) }
before do
procedure.update_column(:api_entreprise_token, JWT.encode({ exp: expiration.to_i }, nil, "none"))
end
it do
expect { process }.to change { procedure.reload.api_entreprise_token_expires_at }.from(nil).to(expiration)
end
end
end
end

View file

@ -0,0 +1,47 @@
# frozen_string_literal: true
RSpec.describe 'administrateurs/procedures/_api_entreprise_token_expiration_alert', type: :view do
let(:procedure) { create(:procedure, api_entreprise_token:) }
subject { render 'administrateurs/procedures/api_entreprise_token_expiration_alert', procedure: procedure }
context "when there is no token" do
let(:api_entreprise_token) { nil }
it "does not render anything" do
subject
expect(rendered).to be_empty
end
end
context "when the token is expired" do
let(:api_entreprise_token) { JWT.encode({ exp: 2.days.ago.to_i }, nil, "none") }
it "should display an error" do
subject
expect(rendered).to have_content("Votre jeton API Entreprise est expiré")
end
end
context "when the token expires in few days it should display the expiration date" do
let(:expiration) { 2.days.from_now }
let(:api_entreprise_token) { JWT.encode({ exp: expiration.to_i }, nil, "none") }
it "should display an error" do
subject
expect(rendered).to have_content("Votre jeton API Entreprise expirera le\n#{expiration.strftime('%d/%m/%Y à %H:%M')}")
end
end
context "when the token expires in a long time" do
let(:expiration) { 2.months.from_now }
let(:api_entreprise_token) { JWT.encode({ exp: expiration.to_i }, nil, "none") }
it "does not render anything" do
subject
expect(rendered).to be_empty
end
end
end