From 1b2601f32cd6c2245a6c6663afa4e2fa0c51757e Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Tue, 23 Jan 2024 10:01:07 +0100 Subject: [PATCH 1/4] add expiration_notices_sent_at [date] column to api_token --- ...909_add_expiration_notices_sent_at_column_to_api_token.rb | 5 +++++ db/schema.rb | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20240123085909_add_expiration_notices_sent_at_column_to_api_token.rb diff --git a/db/migrate/20240123085909_add_expiration_notices_sent_at_column_to_api_token.rb b/db/migrate/20240123085909_add_expiration_notices_sent_at_column_to_api_token.rb new file mode 100644 index 000000000..50fd8661c --- /dev/null +++ b/db/migrate/20240123085909_add_expiration_notices_sent_at_column_to_api_token.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index f81fb674a..73bdb748d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # 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 enable_extension "pgcrypto" 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.datetime "created_at", 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_v2_authenticated_at" t.string "name", null: false From d909e2c8ea38513bdfe16dfaffd849d948448051 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Tue, 23 Jan 2024 17:20:53 +0100 Subject: [PATCH 2/4] add various expiring scopes to APIToken --- app/models/api_token.rb | 20 +++++++ spec/models/api_token_spec.rb | 108 ++++++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+) diff --git a/app/models/api_token.rb b/app/models/api_token.rb index 68b02985a..2e27881df 100644 --- a/app/models/api_token.rb +++ b/app/models/api_token.rb @@ -3,6 +3,26 @@ class APIToken < ApplicationRecord 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 def context diff --git a/spec/models/api_token_spec.rb b/spec/models/api_token_spec.rb index dd03422b9..e9da709a4 100644 --- a/spec/models/api_token_spec.rb +++ b/spec/models/api_token_spec.rb @@ -196,4 +196,112 @@ describe APIToken, type: :model do 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 From 6353c10955c73f36293c16e25f07ef83cf0f81db Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Tue, 23 Jan 2024 17:21:30 +0100 Subject: [PATCH 3/4] add APIToken expiration mailer --- app/mailers/api_token_mailer.rb | 18 +++++++++++++++ .../api_token_mailer/expiration.html.haml | 17 ++++++++++++++ .../previews/api_token_mailer_preview.rb | 23 +++++++++++++++++++ 3 files changed, 58 insertions(+) create mode 100644 app/mailers/api_token_mailer.rb create mode 100644 app/views/api_token_mailer/expiration.html.haml create mode 100644 spec/mailers/previews/api_token_mailer_preview.rb diff --git a/app/mailers/api_token_mailer.rb b/app/mailers/api_token_mailer.rb new file mode 100644 index 000000000..5f0c86d60 --- /dev/null +++ b/app/mailers/api_token_mailer.rb @@ -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 diff --git a/app/views/api_token_mailer/expiration.html.haml b/app/views/api_token_mailer/expiration.html.haml new file mode 100644 index 000000000..e645c4966 --- /dev/null +++ b/app/views/api_token_mailer/expiration.html.haml @@ -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" diff --git a/spec/mailers/previews/api_token_mailer_preview.rb b/spec/mailers/previews/api_token_mailer_preview.rb new file mode 100644 index 000000000..8a53f6051 --- /dev/null +++ b/spec/mailers/previews/api_token_mailer_preview.rb @@ -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 From 97f335c36aad5126d5e6cb1099a442a6bde164e6 Mon Sep 17 00:00:00 2001 From: simon lehericey Date: Fri, 26 Jan 2024 10:00:23 +0100 Subject: [PATCH 4/4] add a cron job to send expiration notice --- .../send_api_token_expiration_notice_job.rb | 21 +++++ ...nd_api_token_expiration_notice_job_spec.rb | 83 +++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 app/jobs/cron/send_api_token_expiration_notice_job.rb create mode 100644 spec/jobs/cron/send_api_token_expiration_notice_job_spec.rb diff --git a/app/jobs/cron/send_api_token_expiration_notice_job.rb b/app/jobs/cron/send_api_token_expiration_notice_job.rb new file mode 100644 index 000000000..48d69c0d8 --- /dev/null +++ b/app/jobs/cron/send_api_token_expiration_notice_job.rb @@ -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 diff --git a/spec/jobs/cron/send_api_token_expiration_notice_job_spec.rb b/spec/jobs/cron/send_api_token_expiration_notice_job_spec.rb new file mode 100644 index 000000000..ae2af0421 --- /dev/null +++ b/spec/jobs/cron/send_api_token_expiration_notice_job_spec.rb @@ -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