Merge pull request #9931 from demarches-simplifiees/notify_api_token_expiration
ETQ Admin, je suis prévenu par mail lorsque mon jeton arrive a expiration
This commit is contained in:
commit
d2f8dbdb48
9 changed files with 297 additions and 1 deletions
21
app/jobs/cron/send_api_token_expiration_notice_job.rb
Normal file
21
app/jobs/cron/send_api_token_expiration_notice_job.rb
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
class Cron::SendAPITokenExpirationNoticeJob < Cron::CronJob
|
||||||
|
self.schedule_expression = "every day at midnight"
|
||||||
|
|
||||||
|
def perform
|
||||||
|
windows = [
|
||||||
|
1.day,
|
||||||
|
1.week,
|
||||||
|
1.month
|
||||||
|
]
|
||||||
|
|
||||||
|
windows.each do |window|
|
||||||
|
APIToken
|
||||||
|
.with_expiration_notice_to_send_for(window)
|
||||||
|
.find_each do |token|
|
||||||
|
APITokenMailer.expiration(token).deliver_later
|
||||||
|
token.expiration_notices_sent_at << Time.zone.today
|
||||||
|
token.save!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
18
app/mailers/api_token_mailer.rb
Normal file
18
app/mailers/api_token_mailer.rb
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
# Preview all emails at http://localhost:3000/rails/mailers/api_token_mailer
|
||||||
|
class APITokenMailer < ApplicationMailer
|
||||||
|
helper MailerHelper
|
||||||
|
|
||||||
|
layout 'mailers/layout'
|
||||||
|
|
||||||
|
def expiration(api_token)
|
||||||
|
@api_token = api_token
|
||||||
|
user = api_token.administrateur.user
|
||||||
|
subject = "Votre jeton d'accès à la plateforme #{APPLICATION_NAME} expire le #{l(@api_token.expires_at, format: :long)}"
|
||||||
|
|
||||||
|
mail(to: user.email, subject:)
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.critical_email?(action_name)
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end
|
|
@ -3,6 +3,26 @@ class APIToken < ApplicationRecord
|
||||||
|
|
||||||
belongs_to :administrateur, inverse_of: :api_tokens
|
belongs_to :administrateur, inverse_of: :api_tokens
|
||||||
|
|
||||||
|
scope :expiring_within, -> (duration) { where(expires_at: Date.today..duration.from_now) }
|
||||||
|
|
||||||
|
scope :without_any_expiration_notice_sent_within, -> (duration) do
|
||||||
|
where.not('(expires_at - (?::interval)) <= some(expiration_notices_sent_at)', duration.iso8601)
|
||||||
|
end
|
||||||
|
|
||||||
|
scope :with_a_bigger_lifetime_than, -> (duration) do
|
||||||
|
where('? < expires_at - created_at', duration.iso8601)
|
||||||
|
end
|
||||||
|
|
||||||
|
scope :with_expiration_notice_to_send_for, -> (duration) do
|
||||||
|
# example for duration = 1.month
|
||||||
|
# take all tokens that expire in the next month
|
||||||
|
# with a lifetime bigger than 1 month
|
||||||
|
# without any expiration notice sent for that period
|
||||||
|
expiring_within(duration)
|
||||||
|
.with_a_bigger_lifetime_than(duration)
|
||||||
|
.without_any_expiration_notice_sent_within(duration)
|
||||||
|
end
|
||||||
|
|
||||||
before_save :sanitize_targeted_procedure_ids
|
before_save :sanitize_targeted_procedure_ids
|
||||||
|
|
||||||
def context
|
def context
|
||||||
|
|
17
app/views/api_token_mailer/expiration.html.haml
Normal file
17
app/views/api_token_mailer/expiration.html.haml
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
- content_for(:title, "Expiration de votre jeton « #{@api_token.name} »")
|
||||||
|
|
||||||
|
%p
|
||||||
|
Bonjour,
|
||||||
|
|
||||||
|
%p Vous recevez ce courriel car vous êtes le propriétaire du jeton « #{@api_token.name} ».
|
||||||
|
|
||||||
|
%p
|
||||||
|
%strong Ce jeton expirera le #{l(@api_token.expires_at, format: :long)}.
|
||||||
|
%br
|
||||||
|
L'accès à l'API de #{APPLICATION_NAME} sera alors bloqué pour ce jeton.
|
||||||
|
|
||||||
|
%p
|
||||||
|
Pour le renouveler, rendez-vous sur votre page de profil, dans la section « Jetons d’identification de l’API » :
|
||||||
|
= link_to profil_url, profil_url
|
||||||
|
|
||||||
|
= render partial: "layouts/mailers/signature"
|
|
@ -0,0 +1,5 @@
|
||||||
|
class AddExpirationNoticesSentAtColumnToAPIToken < ActiveRecord::Migration[7.0]
|
||||||
|
def change
|
||||||
|
add_column :api_tokens, :expiration_notices_sent_at, :date, array: true, default: []
|
||||||
|
end
|
||||||
|
end
|
|
@ -10,7 +10,7 @@
|
||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[7.0].define(version: 2024_01_16_155926) do
|
ActiveRecord::Schema[7.0].define(version: 2024_01_23_085909) do
|
||||||
# These are extensions that must be enabled in order to support this database
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "pgcrypto"
|
enable_extension "pgcrypto"
|
||||||
enable_extension "plpgsql"
|
enable_extension "plpgsql"
|
||||||
|
@ -96,6 +96,7 @@ ActiveRecord::Schema[7.0].define(version: 2024_01_16_155926) do
|
||||||
t.inet "authorized_networks", default: [], array: true
|
t.inet "authorized_networks", default: [], array: true
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.string "encrypted_token", null: false
|
t.string "encrypted_token", null: false
|
||||||
|
t.date "expiration_notices_sent_at", default: [], array: true
|
||||||
t.datetime "last_v1_authenticated_at"
|
t.datetime "last_v1_authenticated_at"
|
||||||
t.datetime "last_v2_authenticated_at"
|
t.datetime "last_v2_authenticated_at"
|
||||||
t.string "name", null: false
|
t.string "name", null: false
|
||||||
|
|
83
spec/jobs/cron/send_api_token_expiration_notice_job_spec.rb
Normal file
83
spec/jobs/cron/send_api_token_expiration_notice_job_spec.rb
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
RSpec.describe Cron::SendAPITokenExpirationNoticeJob, type: :job do
|
||||||
|
describe 'perform' do
|
||||||
|
let(:administrateur) { create(:administrateur) }
|
||||||
|
let!(:token) { APIToken.generate(administrateur).first }
|
||||||
|
let(:mailer_double) { double('mailer', deliver_later: true) }
|
||||||
|
let(:today) { Date.new(2018, 01, 01) }
|
||||||
|
|
||||||
|
def perform_now
|
||||||
|
Cron::SendAPITokenExpirationNoticeJob.perform_now
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
travel_to(today)
|
||||||
|
token.update!(created_at: today)
|
||||||
|
allow(APITokenMailer).to receive(:expiration).and_return(mailer_double)
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when the token does not expire' do
|
||||||
|
before { perform_now }
|
||||||
|
|
||||||
|
it { expect(mailer_double).not_to have_received(:deliver_later) }
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when the token expires in 6 months' do
|
||||||
|
let(:expires_at) { Date.new(2018, 06, 01) }
|
||||||
|
before do
|
||||||
|
token.update(expires_at:)
|
||||||
|
perform_now
|
||||||
|
end
|
||||||
|
|
||||||
|
it { expect(mailer_double).not_to have_received(:deliver_later) }
|
||||||
|
|
||||||
|
context 'when the token expires less than a month' do
|
||||||
|
before do
|
||||||
|
travel_to(expires_at - 1.month - 1.day)
|
||||||
|
perform_now
|
||||||
|
|
||||||
|
travel_to(expires_at - 1.month)
|
||||||
|
perform_now
|
||||||
|
|
||||||
|
travel_to(expires_at - 1.month + 1.day)
|
||||||
|
perform_now
|
||||||
|
end
|
||||||
|
|
||||||
|
it do
|
||||||
|
expect(mailer_double).to have_received(:deliver_later).once
|
||||||
|
expect(token.reload.expiration_notices_sent_at).to match_array([expires_at - 1.month])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when the token expires less than a week' do
|
||||||
|
before do
|
||||||
|
travel_to(expires_at - 1.week)
|
||||||
|
2.times.each { perform_now }
|
||||||
|
end
|
||||||
|
|
||||||
|
it { expect(mailer_double).to have_received(:deliver_later).once }
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when we simulate the whole sequence' do
|
||||||
|
before do
|
||||||
|
travel_to(expires_at - 1.month)
|
||||||
|
2.times.each { perform_now }
|
||||||
|
|
||||||
|
travel_to(expires_at - 1.week)
|
||||||
|
2.times.each { perform_now }
|
||||||
|
|
||||||
|
travel_to(expires_at - 1.day)
|
||||||
|
2.times.each { perform_now }
|
||||||
|
end
|
||||||
|
|
||||||
|
it do
|
||||||
|
expect(mailer_double).to have_received(:deliver_later).exactly(3).times
|
||||||
|
expect(token.reload.expiration_notices_sent_at).to match_array([
|
||||||
|
expires_at - 1.month,
|
||||||
|
expires_at - 1.week,
|
||||||
|
expires_at - 1.day
|
||||||
|
])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
23
spec/mailers/previews/api_token_mailer_preview.rb
Normal file
23
spec/mailers/previews/api_token_mailer_preview.rb
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
class APITokenMailerPreview < ActionMailer::Preview
|
||||||
|
def expiration
|
||||||
|
APITokenMailer.expiration(api_token)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def api_token
|
||||||
|
APIToken.new(
|
||||||
|
administrateur: administrateur,
|
||||||
|
expires_at: 1.week.from_now,
|
||||||
|
name: 'My API token'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def administrateur
|
||||||
|
Administrateur.new(user:)
|
||||||
|
end
|
||||||
|
|
||||||
|
def user
|
||||||
|
User.new(email: 'admin@a.com')
|
||||||
|
end
|
||||||
|
end
|
|
@ -196,4 +196,112 @@ describe APIToken, type: :model do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe '#expiring_within' do
|
||||||
|
let(:api_token) { APIToken.generate(administrateur).first }
|
||||||
|
|
||||||
|
subject { APIToken.expiring_within(7.days) }
|
||||||
|
|
||||||
|
context 'when the token is not expiring' do
|
||||||
|
it { is_expected.to be_empty }
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when the token is expiring in the range' do
|
||||||
|
before { api_token.update!(expires_at: 1.day.from_now) }
|
||||||
|
|
||||||
|
it { is_expected.to eq([api_token]) }
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when the token is not expiring in the range' do
|
||||||
|
before { api_token.update!(expires_at: 8.days.from_now) }
|
||||||
|
|
||||||
|
it { is_expected.to be_empty }
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when the token is expired' do
|
||||||
|
before { api_token.update!(expires_at: 1.day.ago) }
|
||||||
|
|
||||||
|
it { is_expected.to be_empty }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#without_any_expiration_notice_sent_within' do
|
||||||
|
let(:api_token) { APIToken.generate(administrateur).first }
|
||||||
|
let(:today) { Date.new(2018, 01, 01) }
|
||||||
|
let(:expires_at) { Date.new(2018, 06, 01) }
|
||||||
|
|
||||||
|
subject { APIToken.without_any_expiration_notice_sent_within(7.days) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
travel_to(today)
|
||||||
|
api_token.update!(created_at: today, expires_at:)
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when the token has not been notified' do
|
||||||
|
it { is_expected.to eq([api_token]) }
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when the token has been notified' do
|
||||||
|
before do
|
||||||
|
api_token.expiration_notices_sent_at << expires_at - 7.days
|
||||||
|
api_token.save!
|
||||||
|
end
|
||||||
|
|
||||||
|
it { is_expected.to be_empty }
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when the token has been notified outside the window' do
|
||||||
|
before do
|
||||||
|
api_token.expiration_notices_sent_at << expires_at - 8.days
|
||||||
|
api_token.save!
|
||||||
|
end
|
||||||
|
|
||||||
|
it { is_expected.to eq([api_token]) }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#with_expiration_notice_to_send_for' do
|
||||||
|
let(:api_token) { APIToken.generate(administrateur).first }
|
||||||
|
let(:duration) { 7.days }
|
||||||
|
|
||||||
|
subject do
|
||||||
|
APIToken.with_expiration_notice_to_send_for(duration)
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when the token is expiring in the range' do
|
||||||
|
before { api_token.update!(expires_at: 1.day.from_now, created_at:) }
|
||||||
|
|
||||||
|
let(:created_at) { 1.year.ago }
|
||||||
|
|
||||||
|
it do
|
||||||
|
is_expected.to eq([api_token])
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when the token has been created within the time frame' do
|
||||||
|
let(:created_at) { 2.days.ago }
|
||||||
|
|
||||||
|
it { is_expected.to be_empty }
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when the token has already been notified' do
|
||||||
|
before do
|
||||||
|
api_token.expiration_notices_sent_at << 1.day.ago
|
||||||
|
api_token.save!
|
||||||
|
end
|
||||||
|
|
||||||
|
it { is_expected.to be_empty }
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when the token has already been notified for another window' do
|
||||||
|
before do
|
||||||
|
api_token.expiration_notices_sent_at << 1.month.ago
|
||||||
|
api_token.save!
|
||||||
|
end
|
||||||
|
|
||||||
|
it do
|
||||||
|
is_expected.to eq([api_token])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
Loading…
Reference in a new issue