Merge pull request #7462 from betagouv/US/invite-with-targeted-user-link

feat(invite): wrap invitations with targeted_user_links
This commit is contained in:
mfo 2022-06-17 16:52:11 +02:00 committed by GitHub
commit 73becb9c76
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 251 additions and 70 deletions

View file

@ -5,7 +5,6 @@ class InvitesController < ApplicationController
def create def create
email = params[:invite_email].downcase email = params[:invite_email].downcase
@dossier = current_user.dossiers.visible_by_user.find(params[:dossier_id]) @dossier = current_user.dossiers.visible_by_user.find(params[:dossier_id])
invite = Invite.create( invite = Invite.create(
dossier: @dossier, dossier: @dossier,
user: User.find_by(email: email), user: User.find_by(email: email),

View file

@ -11,6 +11,6 @@ class TargetedUserLinksController < ApplicationController
private private
def set_targeted_user_link def set_targeted_user_link
@targeted_user_link = TargetedUserLink.find_by(id: params[:id]) @targeted_user_link = TargetedUserLink.find(params[:id])
end end
end end

View file

@ -4,7 +4,10 @@ class InviteMailer < ApplicationMailer
def invite_user(invite) def invite_user(invite)
subject = "Participez à l'élaboration dun dossier" subject = "Participez à l'élaboration dun dossier"
targeted_user_link = invite.targeted_user_link || invite.create_targeted_user_link(target_context: 'invite',
target_model: invite,
user: invite.user)
@url = targeted_user_link_url(targeted_user_link)
if invite.user.present? if invite.user.present?
send_mail(invite, subject, invite.email_sender) send_mail(invite, subject, invite.email_sender)
end end
@ -12,6 +15,9 @@ class InviteMailer < ApplicationMailer
def invite_guest(invite) def invite_guest(invite)
subject = "#{invite.email_sender} vous invite à consulter un dossier" subject = "#{invite.email_sender} vous invite à consulter un dossier"
targeted_user_link = invite.targeted_user_link || invite.create_targeted_user_link(target_context: 'invite',
target_model: invite)
@url = targeted_user_link_url(targeted_user_link)
send_mail(invite, subject, invite.email_sender) send_mail(invite, subject, invite.email_sender)
end end

View file

@ -16,6 +16,7 @@ class Invite < ApplicationRecord
belongs_to :dossier, optional: false belongs_to :dossier, optional: false
belongs_to :user, optional: true belongs_to :user, optional: true
has_one :targeted_user_link, dependent: :destroy, inverse_of: :target_model, foreign_key: 'target_model_id'
before_validation -> { sanitize_email(:email) } before_validation -> { sanitize_email(:email) }

View file

@ -8,20 +8,36 @@
# created_at :datetime not null # created_at :datetime not null
# updated_at :datetime not null # updated_at :datetime not null
# target_model_id :bigint not null # target_model_id :bigint not null
# user_id :bigint not null # user_id :bigint
# #
class TargetedUserLink < ApplicationRecord class TargetedUserLink < ApplicationRecord
belongs_to :user belongs_to :user, optional: true
belongs_to :target_model, polymorphic: true, optional: false belongs_to :target_model, polymorphic: true, optional: false
enum target_context: { :avis => 'avis' } enum target_context: { avis: 'avis', invite: 'invite' }
def invalid_signed_in_user?(signed_in_user) def invalid_signed_in_user?(signed_in_user)
signed_in_user && signed_in_user != self.user signed_in_user && signed_in_user != self.user
end end
def target_email
case target_context
when 'avis'
user.email
when 'invite'
target_model.user&.email || target_model.email
else
raise 'invalid target_context'
end
end
def redirect_url(url_helper) def redirect_url(url_helper)
case target_context case target_context
when "invite"
invite = target_model
invite.user&.active? ?
url_helper.invite_path(invite) :
url_helper.invite_path(invite, params: { email: invite.email })
when "avis" when "avis"
avis = target_model avis = target_model
avis.expert.user.active? ? avis.expert.user.active? ?

View file

@ -20,7 +20,6 @@
Afin de répondre à cette invitation, merci de vous inscrire avec l'adresse email Afin de répondre à cette invitation, merci de vous inscrire avec l'adresse email
= @invite.email = @invite.email
sur sur
- url_for_link = invite_url(@invite, params: { email: @invite.email }) = link_to @url, @url
= link_to url_for_link, url_for_link
= render partial: "layouts/mailers/signature" = render partial: "layouts/mailers/signature"

View file

@ -13,7 +13,6 @@
%p %p
Pour le consulter, merci de suivre ce lien : Pour le consulter, merci de suivre ce lien :
- url_for_link = invite_url(@invite) = link_to @url, @url
= link_to url_for_link, url_for_link
= render partial: "layouts/mailers/signature" = render partial: "layouts/mailers/signature"

View file

@ -6,7 +6,7 @@
%h1.mt-4.mb-3.mx-0 Attention, ce lien ne vous est pas destiné %h1.mt-4.mb-3.mx-0 Attention, ce lien ne vous est pas destiné
%p.send.m-2.text-lg %p.send.m-2.text-lg
L'invitation est à destination de L'invitation est à destination de
%strong.test-target-email= @targeted_user_link.user.email %strong.test-target-email= @targeted_user_link.target_email
%br %br
Mais vous êtes connecté avec Mais vous êtes connecté avec
%strong.test-current-email= current_user.email %strong.test-current-email= current_user.email

View file

@ -15,7 +15,7 @@
"type": "controller", "type": "controller",
"class": "Users::DossiersController", "class": "Users::DossiersController",
"method": "merci", "method": "merci",
"line": 206, "line": 204,
"file": "app/controllers/users/dossiers_controller.rb", "file": "app/controllers/users/dossiers_controller.rb",
"rendered": { "rendered": {
"name": "users/dossiers/merci", "name": "users/dossiers/merci",
@ -95,20 +95,20 @@
{ {
"warning_type": "Redirect", "warning_type": "Redirect",
"warning_code": 18, "warning_code": 18,
"fingerprint": "8396f884c5596d6c1148c7326b63e7e7bfabf9b0c1daf6b3ce0c1ff127b59cb4", "fingerprint": "589e457617e6ad3d35f454b4af8ed326817930d6a724febcf680d7e03c6aeaa2",
"check_name": "Redirect", "check_name": "Redirect",
"message": "Possible unprotected redirect", "message": "Possible unprotected redirect",
"file": "app/controllers/targeted_user_links_controller.rb", "file": "app/controllers/targeted_user_links_controller.rb",
"line": 7, "line": 7,
"link": "https://brakemanscanner.org/docs/warning_types/redirect/", "link": "https://brakemanscanner.org/docs/warning_types/redirect/",
"code": "redirect_to(TargetedUserLink.find_by(:id => params[:id]).redirect_url(Rails.application.routes.url_helpers))", "code": "redirect_to(TargetedUserLink.find(params[:id]).redirect_url(Rails.application.routes.url_helpers))",
"render_path": null, "render_path": null,
"location": { "location": {
"type": "method", "type": "method",
"class": "TargetedUserLinksController", "class": "TargetedUserLinksController",
"method": "show" "method": "show"
}, },
"user_input": "TargetedUserLink.find_by(:id => params[:id]).redirect_url(Rails.application.routes.url_helpers)", "user_input": "TargetedUserLink.find(params[:id]).redirect_url(Rails.application.routes.url_helpers)",
"confidence": "High", "confidence": "High",
"note": "" "note": ""
}, },
@ -119,7 +119,7 @@
"check_name": "SQL", "check_name": "SQL",
"message": "Possible SQL injection", "message": "Possible SQL injection",
"file": "app/models/concerns/dossier_filtering_concern.rb", "file": "app/models/concerns/dossier_filtering_concern.rb",
"line": 18, "line": 25,
"link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
"code": "where(\"#{values.count} OR #{\"(#{ProcedurePresentation.sanitized_column(table, column)} ILIKE ?)\"}\", *values.map do\n \"%#{value}%\"\n end)", "code": "where(\"#{values.count} OR #{\"(#{ProcedurePresentation.sanitized_column(table, column)} ILIKE ?)\"}\", *values.map do\n \"%#{value}%\"\n end)",
"render_path": null, "render_path": null,
@ -153,6 +153,6 @@
"note": "" "note": ""
} }
], ],
"updated": "2022-05-23 16:40:50 +0200", "updated": "2022-06-15 16:15:28 +0200",
"brakeman_version": "5.2.2" "brakeman_version": "5.2.2"
} }

View file

@ -0,0 +1,5 @@
class ChangeColumnNullForTargetedUserLinks < ActiveRecord::Migration[6.1]
def change
safety_assured { change_column_null :targeted_user_links, :user_id, true }
end
end

View file

@ -0,0 +1,8 @@
class AddTargetModelIdIndexToTargetedUserLinks < ActiveRecord::Migration[6.1]
include Database::MigrationHelpers
disable_ddl_transaction!
def up
add_concurrent_index :targeted_user_links, :target_model_id
end
end

View file

@ -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.define(version: 2022_05_31_100040) do ActiveRecord::Schema.define(version: 2022_06_17_142759) 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"
@ -758,7 +758,8 @@ ActiveRecord::Schema.define(version: 2022_05_31_100040) do
t.bigint "target_model_id", null: false t.bigint "target_model_id", null: false
t.string "target_model_type", null: false t.string "target_model_type", null: false
t.datetime "updated_at", precision: 6, null: false t.datetime "updated_at", precision: 6, null: false
t.bigint "user_id", null: false t.bigint "user_id"
t.index ["target_model_id"], name: "index_targeted_user_links_on_target_model_id"
t.index ["user_id"], name: "index_targeted_user_links_on_user_id" t.index ["user_id"], name: "index_targeted_user_links_on_user_id"
end end

View file

@ -1,32 +1,35 @@
describe TargetedUserLinksController, type: :controller do describe TargetedUserLinksController, type: :controller do
describe '#show' do describe '#show' do
let!(:expert) { create(:expert, user: expert_user) } let!(:targeted_user_link) { create(:targeted_user_link, target_context: target_context, target_model: target_model, user: user) }
let!(:avis) { create(:avis, experts_procedure: expert_procedure) }
context 'avis' do
let(:target_context) { :avis }
let!(:expert) { create(:expert, user: user) }
let!(:target_model) { create(:avis, experts_procedure: expert_procedure) }
let!(:expert_procedure) { create(:experts_procedure, expert: expert) } let!(:expert_procedure) { create(:experts_procedure, expert: expert) }
let!(:targeted_user_link) { create(:targeted_user_link, target_context: :avis, target_model: avis, user: expert_user) }
context 'not connected as active expert' do context 'not connected as active expert' do
let(:expert_user) { create(:user, last_sign_in_at: 2.days.ago) } let(:user) { create(:user, last_sign_in_at: 2.days.ago) }
before { get :show, params: { id: targeted_user_link.id } } before { get :show, params: { id: targeted_user_link.id } }
it 'redirects to expert_avis_url' do it 'redirects to expert_avis_url' do
expect(response).to redirect_to(expert_avis_path(avis.procedure, avis)) expect(response).to redirect_to(expert_avis_path(target_model.procedure, target_model))
end end
end end
context 'not connected as inactive expert' do context 'not connected as inactive expert' do
let(:expert_user) { create(:user, last_sign_in_at: nil) } let(:user) { create(:user, last_sign_in_at: nil) }
before { get :show, params: { id: targeted_user_link.id } } before { get :show, params: { id: targeted_user_link.id } }
it 'redirects to sign_up_expert_avis_url' do it 'redirects to sign_up_expert_avis_url' do
expect(response).to redirect_to(sign_up_expert_avis_path(avis.procedure, avis, email: expert_user.email)) expect(response).to redirect_to(sign_up_expert_avis_path(target_model.procedure, target_model, email: user.email))
end end
end end
context 'connected as expected user' do context 'connected as expected user' do
let(:expert_user) { create(:user, last_sign_in_at: 2.days.ago) } let(:user) { create(:user, last_sign_in_at: 2.days.ago) }
before do before do
sign_in(targeted_user_link.user) sign_in(targeted_user_link.user)
@ -34,12 +37,12 @@ describe TargetedUserLinksController, type: :controller do
end end
it 'redirects to expert_avis_url' do it 'redirects to expert_avis_url' do
expect(response).to redirect_to(expert_avis_path(avis.procedure, avis)) expect(response).to redirect_to(expert_avis_path(target_model.procedure, target_model))
end end
end end
context 'connected as different user' do context 'connected as different user' do
let(:expert_user) { create(:user, last_sign_in_at: 2.days.ago) } let(:user) { create(:user, last_sign_in_at: 2.days.ago) }
before do before do
sign_in(create(:expert).user) sign_in(create(:expert).user)
@ -51,4 +54,42 @@ describe TargetedUserLinksController, type: :controller do
end end
end end
end end
context 'with invite' do
let(:target_context) { 'invite' }
let(:target_model) { create(:invite, user: user) }
context 'connected with expected user' do
let(:user) { build(:user, last_sign_in_at: 2.days.ago) }
before do
sign_in(targeted_user_link.user)
get :show, params: { id: targeted_user_link.id }
end
it 'works' do
expect(response).to redirect_to(invite_path(target_model))
end
end
context 'connected as different user' do
let(:user) { create(:user, last_sign_in_at: 2.days.ago) }
before do
sign_in(create(:expert).user)
get :show, params: { id: targeted_user_link.id }
end
it 'renders error page ' do
expect(response).to have_http_status(200)
end
end
context 'when invite user does not exists' do
let(:user) { nil }
before { get :show, params: { id: targeted_user_link.id } }
it 'works' do
expect(response).to redirect_to(invite_path(target_model, email: target_model.email))
end
end
end
end
end end

View file

@ -6,7 +6,12 @@ FactoryBot.define do
user {} user {}
end end
after(:build) do |targeted_user_link| after(:build) do |targeted_user_link|
case targeted_user_link.target_context
when 'avis'
targeted_user_link.user = targeted_user_link.target_model.expert.user targeted_user_link.user = targeted_user_link.target_model.expert.user
when 'invite'
targeted_user_link.user = targeted_user_link.target_model&.user
end
end end
end end
end end

View file

@ -0,0 +1,51 @@
RSpec.describe InviteMailer, type: :mailer do
let(:deliver) { mailer.deliver_now }
describe '.invite_user' do
let(:mailer) { InviteMailer.invite_user(invite) }
let(:invite) { create(:invite, user: create(:user)) }
it 'creates a target_user_link' do
expect { deliver }
.to change { TargetedUserLink.where(target_model: invite, user: invite.user).count }
.from(0).to(1)
end
context 'when it fails' do
it 'creates only one target_user_link' do
send_mail_values = [:raise, true]
allow_any_instance_of(InviteMailer).to receive(:send_mail) do
v = send_mail_values.shift
v == :raise ? raise("boom") : v
end
mailer.body rescue nil
mailer.body
expect(TargetedUserLink.where(target_model: invite, user: invite.user).count).to eq(1)
end
end
end
describe '.invite_guest' do
let(:mailer) { InviteMailer.invite_guest(invite) }
let(:invite) { create(:invite, user: nil, email: 'kikoo@lol.fr') }
it 'creates a target_user_link' do
expect { deliver }
.to change { TargetedUserLink.where(target_model: invite, user: nil).count }
.from(0).to(1)
end
context 'when it fails' do
it 'creates only one target_user_link' do
send_mail_values = [:raise, true]
allow_any_instance_of(InviteMailer).to receive(:send_mail) do
v = send_mail_values.shift
v == :raise ? raise("boom") : v
end
mailer.body rescue nil
mailer.body
expect(TargetedUserLink.where(target_model: invite, user: invite.user).count).to eq(1)
end
end
end
end

View file

@ -4,7 +4,7 @@ RSpec.describe TargetedUserLink, type: :model do
describe 'Validation' do describe 'Validation' do
let(:targeted_user_link) { build(:targeted_user_link) } let(:targeted_user_link) { build(:targeted_user_link) }
context 'target_context' do describe 'target_context' do
it 'is bullet proof' do it 'is bullet proof' do
expect { targeted_user_link.target_context = :kc }.to raise_error(ArgumentError, "'kc' is not a valid target_context") expect { targeted_user_link.target_context = :kc }.to raise_error(ArgumentError, "'kc' is not a valid target_context")
end end

View file

@ -14,6 +14,7 @@ describe 'Invitations' do
navigate_to_brouillon(dossier) navigate_to_brouillon(dossier)
fill_in 'Texte obligatoire', with: 'Some edited value' fill_in 'Texte obligatoire', with: 'Some edited value'
send_invite_to "user_invite@exemple.fr" send_invite_to "user_invite@exemple.fr"
expect(page).to have_current_path(brouillon_dossier_path(dossier)) expect(page).to have_current_path(brouillon_dossier_path(dossier))
@ -26,23 +27,72 @@ describe 'Invitations' do
context 'when inviting someone without an existing account' do context 'when inviting someone without an existing account' do
let(:invite) { create(:invite, dossier: dossier, user: nil) } let(:invite) { create(:invite, dossier: dossier, user: nil) }
let(:user_password) { 'my-s3cure-p4ssword' }
scenario 'an invited user can register using the registration link sent in the invitation email' do scenario 'an invited user can register using the targeted_user_link sent in the invitation email thru the ' do
# Click the invitation link log_in(owner)
visit invite_path(invite, params: { email: invite.email }) navigate_to_brouillon(dossier)
expect(page).to have_current_path(new_user_registration_path, ignore_query: true)
expect(page).to have_field('user_email', with: invite.email)
# Create the account fill_in 'Texte obligatoire', with: 'Some edited value'
sign_up_with invite.email, user_password
expect(page).to have_content('lien dactivation')
# Confirm the account send_invite_to "user_invite@exemple.fr"
# (The user should be redirected to the dossier they was invited on)
click_confirmation_link_for invite.email expect {
expect(page).to have_content('Votre compte a bien été confirmé.') perform_enqueued_jobs
expect(page).to have_current_path(brouillon_dossier_path(dossier)) }.to change { TargetedUserLink.count }.from(0).to(1)
invitation_email = open_email("user_invite@exemple.fr")
targeted_user_link = TargetedUserLink.last
expect(invitation_email).to have_link(targeted_user_link_url(targeted_user_link))
invitation_email.click_on targeted_user_link_url(targeted_user_link)
expect(page).to have_current_path("/users/sign_up?user%5Bemail%5D=user_invite%40exemple.fr")
end
end
context 'when inviting someone with an existing account' do
let(:user) { create(:user) }
scenario 'an invited user can sign in using the targeted_user_link link sent in the invitation email' do
log_in(owner)
navigate_to_brouillon(dossier)
fill_in 'Texte obligatoire', with: 'Some edited value'
send_invite_to user.email
expect {
perform_enqueued_jobs
}.to change { TargetedUserLink.count }.from(0).to(1)
invitation_email = open_email(user.email)
targeted_user_link = TargetedUserLink.last
expect(invitation_email).to have_link(targeted_user_link_url(targeted_user_link))
invitation_email.click_on targeted_user_link_url(targeted_user_link)
expect(page).to have_current_path("/users/sign_in")
end
end
context 'when visiting targeted_user_link having an invite without user, but signed with another account' do
let(:invite) { create(:invite, user: nil, email: 'target@email.com') }
let!(:targeted_user_link) { create(:targeted_user_link, target_context: 'invite', target_model: invite) }
let!(:another_user) { create(:user) }
scenario 'the connected user is alterted he is not using the expected account' do
log_in(another_user)
visit targeted_user_link_path(targeted_user_link)
expect(page).to have_current_path(targeted_user_link_path(targeted_user_link))
expect(page).to have_content("L'invitation est à destination de #{targeted_user_link.target_email}")
end
end
context 'when visiting targeted_user_link having an invite with user, but signed with another account' do
let(:invite) { create(:invite, user: create(:user)) }
let!(:targeted_user_link) { create(:targeted_user_link, target_context: 'invite', target_model: invite, user: invite.user) }
let!(:another_user) { create(:user) }
scenario 'the connected user is alterted he is not using the expected account' do
log_in(another_user)
visit targeted_user_link_path(targeted_user_link)
expect(page).to have_current_path(targeted_user_link_path(targeted_user_link))
expect(page).to have_content("L'invitation est à destination de #{targeted_user_link.target_email}")
end end
end end