devise_invitable 3.0.4 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,5 @@
1
1
  require 'active_support/deprecation'
2
+ require 'active_support/security_utils'
2
3
  require 'devise_invitable/models/authenticatable'
3
4
 
4
5
  module Devise
@@ -30,7 +31,7 @@ module Devise
30
31
  attr_accessor :completing_invite
31
32
  attr_accessor :registering_directly
32
33
  attr_accessor :invited_by_for_next_invitation
33
- attr_reader :raw_invitation_token
34
+ attr_reader :acceptance_token
34
35
 
35
36
  included do
36
37
  include ::DeviseInvitable::Inviter
@@ -145,6 +146,13 @@ module Devise
145
146
  current_invitation&.invitation_token = value
146
147
  end
147
148
 
149
+ # The public raw token is an encrypted acceptance envelope for new
150
+ # invitations. Before persistence it falls back to the underlying Devise
151
+ # token so invitation callbacks retain their historical behavior.
152
+ def raw_invitation_token
153
+ acceptance_token || @raw_invitation_token
154
+ end
155
+
148
156
  def invitation_created_at
149
157
  current_invitation&.invitation_created_at
150
158
  end
@@ -201,38 +209,42 @@ module Devise
201
209
  current_invitation.invitation_token = nil
202
210
  end
203
211
 
204
- # Save the resource, accept the selected invitation, and supersede the
205
- # other outstanding invitations for the same resource.
212
+ # Save the resource, accept the selected invitation, and resolve the
213
+ # other outstanding invitations for the same invite keys.
206
214
  def accept_invitation!
207
215
  if current_invitation&.pending?
216
+ @accepted_invitation_for_provisioned_resource = resource_previously_provisioned?
217
+ provisioning_acceptance = !accepted_invitation_for_provisioned_resource?
218
+ previous_confirmed_at = confirmed_at if respond_to?(:confirmed_at)
208
219
  @accepting_invitation = true
220
+ skip_reconfirmation! if respond_to?(:skip_reconfirmation!)
209
221
  run_callbacks :invitation_accepted do
210
222
  accepted_at = Time.now.utc
211
223
  self.confirmed_at ||= accepted_at if respond_to?(:confirmed_at=)
212
224
 
213
225
  if save
214
226
  current_invitation.invitable = self
215
- current_invitation.resource_created_at ||= accepted_at
227
+ current_invitation.resource_created_at ||= accepted_at if provisioning_acceptance
216
228
  current_invitation.invitation_accepted_at = accepted_at
217
229
  current_invitation.invitation_token = nil
218
230
  current_invitation.save!
219
- supersede_other_invitations!(accepted_at)
231
+ resolve_other_invitations_after_acceptance!(accepted_at)
220
232
  reset_devise_invitation_associations
221
233
  true
222
234
  else
223
235
  false
224
236
  end
225
237
  end.tap do |saved|
226
- rollback_accepted_invitation unless saved
238
+ rollback_accepted_invitation(previous_confirmed_at) unless saved
227
239
  @accepting_invitation = false
228
240
  end
229
241
  end
230
242
  end
231
243
 
232
- def rollback_accepted_invitation
244
+ def rollback_accepted_invitation(previous_confirmed_at = nil)
233
245
  invitation = current_invitation
234
246
  invitation.reload if invitation&.persisted?
235
- self.confirmed_at = nil if self.respond_to?(:confirmed_at=)
247
+ self.confirmed_at = previous_confirmed_at if respond_to?(:confirmed_at=)
236
248
  end
237
249
 
238
250
  # Verify wheather a user is created by invitation, irrespective to invitation status
@@ -240,6 +252,14 @@ module Devise
240
252
  devise_invitations.provisioning.exists?
241
253
  end
242
254
 
255
+ def resource_previously_provisioned?
256
+ persisted? && !invitation_pending?
257
+ end
258
+
259
+ def accepted_invitation_for_provisioned_resource?
260
+ !!@accepted_invitation_for_provisioned_resource
261
+ end
262
+
243
263
  # True only while this record is a placeholder created by an unresolved
244
264
  # invitation. An active resource may have other pending invitations.
245
265
  def invitation_pending?
@@ -274,6 +294,8 @@ module Devise
274
294
 
275
295
  # Reset invitation token and send invitation again
276
296
  def invite!(invited_by = nil, options = {})
297
+ options = options.dup
298
+ invitation_data = options.delete(:invitation_data)
277
299
  resource_was_new = new_record?
278
300
  # Required to workaround confirmable model's confirmation_required? method
279
301
  # being implemented to check for non-nil value of confirmed_at
@@ -286,6 +308,7 @@ module Devise
286
308
  self.downcase_keys if new_record_and_responds_to?(:downcase_keys)
287
309
  self.strip_whitespace if new_record_and_responds_to?(:strip_whitespace)
288
310
  invitation = build_devise_invitation(invited_by)
311
+ invitation.invitation_data = invitation_data
289
312
  if instance_variable_defined?(:@invited_by_for_next_invitation)
290
313
  remove_instance_variable(:@invited_by_for_next_invitation)
291
314
  end
@@ -293,6 +316,8 @@ module Devise
293
316
  generate_invitation_token
294
317
 
295
318
  run_callbacks :invitation_created do
319
+ invitation.invitation_data = DeviseInvitable::AcceptanceToken
320
+ .normalize_invitation_data(invitation.invitation_data)
296
321
  now = Time.now.utc
297
322
  invitation.invitation_created_at = now
298
323
 
@@ -305,8 +330,15 @@ module Devise
305
330
  end
306
331
  invitation.save!
307
332
  reset_devise_invitation_associations
333
+ generate_acceptance_token
308
334
  invitation.invited_by.decrement_invitation_limit! if invitation.invited_by.present?
309
- deliver_invitation(options) unless skip_invitation
335
+ unless skip_invitation
336
+ if self.class.deliver_invitation_later
337
+ enqueue_invitation_delivery(options)
338
+ else
339
+ deliver_invitation(options)
340
+ end
341
+ end
310
342
  end
311
343
  end
312
344
  end
@@ -338,7 +370,8 @@ module Devise
338
370
 
339
371
  # Enforce password when invitation is being accepted
340
372
  def password_required?
341
- (accepting_invitation? && self.class.require_password_on_accepting) || super
373
+ (accepting_invitation? && !resource_previously_provisioned? &&
374
+ self.class.require_password_on_accepting) || super
342
375
  end
343
376
 
344
377
  def unauthenticated_message
@@ -359,16 +392,12 @@ module Devise
359
392
 
360
393
  # Deliver the invitation email
361
394
  def deliver_invitation(options = {})
362
- generate_invitation_token! unless @raw_invitation_token
363
- marked_as_sent = false
364
- unless current_invitation.invitation_sent_at
365
- current_invitation.update_attribute :invitation_sent_at, Time.now.utc
366
- marked_as_sent = true
367
- end
368
- send_devise_notification(:invitation_instructions, @raw_invitation_token, options)
369
- rescue
370
- current_invitation.update_attribute(:invitation_sent_at, nil) if marked_as_sent
371
- raise
395
+ DeviseInvitable::InvitationDelivery.call(
396
+ current_invitation,
397
+ resource: self,
398
+ acceptance_token: acceptance_token,
399
+ options: options
400
+ )
372
401
  end
373
402
 
374
403
  # provide alias to the encrypted invitation_token stored by devise
@@ -414,6 +443,15 @@ module Devise
414
443
  association._loaded?
415
444
  end
416
445
 
446
+ def enqueue_invitation_delivery(options)
447
+ DeviseInvitable::InvitationDelivery.enqueue(
448
+ current_invitation,
449
+ resource: self,
450
+ acceptance_token: acceptance_token,
451
+ options: options
452
+ )
453
+ end
454
+
417
455
  def assign_invitation_attribute(attribute, value)
418
456
  return unless current_invitation
419
457
 
@@ -470,19 +508,49 @@ module Devise
470
508
  generate_invitation_token && current_invitation.save!
471
509
  end
472
510
 
511
+ def generate_acceptance_token
512
+ @acceptance_token = DeviseInvitable::AcceptanceToken.generate(
513
+ invitation: current_invitation,
514
+ raw_token: @raw_invitation_token,
515
+ invite_key: current_invitation.invite_key_for_acceptance.presence ||
516
+ self.class.normalized_invite_keys(attributes),
517
+ invitation_data: current_invitation.invitation_data
518
+ )
519
+ end
520
+
521
+ def prepare_invitation_delivery
522
+ generate_invitation_token! unless @raw_invitation_token
523
+ generate_acceptance_token unless acceptance_token
524
+ acceptance_token
525
+ end
526
+
527
+ def send_invitation_notification_now(token, options)
528
+ devise_mailer
529
+ .send(:invitation_instructions, self, token, options)
530
+ .deliver_now
531
+ end
532
+
473
533
  def new_record_and_responds_to?(method)
474
534
  self.new_record? && self.respond_to?(method, true)
475
535
  end
476
536
 
477
537
  def build_devise_invitation(invited_by)
478
- invite_key_values = self.class.invite_key_fields.each_with_object({}) do |key, values|
479
- values[key] = public_send(key)
480
- end
481
- DeviseInvitable::Invitation.new(
538
+ invite_key_values = self.class.normalized_invite_keys(attributes)
539
+ invitation = DeviseInvitable::Invitation.new(
482
540
  invitable_type: self.class.devise_invitable_base_class.name,
483
- invite_key: invite_key_values,
541
+ invite_key: {},
484
542
  invited_by: invited_by || invited_by_for_next_invitation
485
543
  )
544
+ invitation.transient_invite_key = invite_key_values
545
+ invitation
546
+ end
547
+
548
+ def resolve_other_invitations_after_acceptance!(time)
549
+ if self.class.supersede_other_invitations_on_accept
550
+ supersede_other_invitations!(time)
551
+ else
552
+ preserve_other_invitations!
553
+ end
486
554
  end
487
555
 
488
556
  def supersede_other_invitations!(time)
@@ -499,6 +567,16 @@ module Devise
499
567
  end
500
568
  end
501
569
 
570
+ def preserve_other_invitations!
571
+ self.class.pending_invitations_for(attributes).each do |invitation|
572
+ next if invitation.id == current_invitation.id
573
+
574
+ invitation.invitable = self
575
+ invitation.resource_created_at = nil if current_invitation.provisioning?
576
+ invitation.save!
577
+ end
578
+ end
579
+
502
580
  def resolve_invitations_after_registration!
503
581
  self.class.pending_invitations_for(attributes).each do |invitation|
504
582
  invitation.update!(
@@ -608,6 +686,10 @@ module Devise
608
686
  original_token = attributes.delete(:invitation_token)
609
687
  invitable = find_by_invitation_token(original_token, false)
610
688
  if invitable.errors.empty?
689
+ if invitable.resource_previously_provisioned?
690
+ attributes.delete(:password)
691
+ attributes.delete(:password_confirmation)
692
+ end
611
693
  invitable.assign_attributes(attributes)
612
694
  invitable.accept_invitation!
613
695
  end
@@ -622,12 +704,9 @@ module Devise
622
704
  return
623
705
  end
624
706
 
625
- invitation_token = Devise.token_generator.digest(self, :invitation_token, original_token)
626
- invitation = DeviseInvitable::Invitation.where(
627
- invitable_type: devise_invitable_base_class.name,
628
- invitation_token: invitation_token
629
- ).first
630
- invitable = invitation&.invitable || new(invitation ? invitation.invite_key : {})
707
+ invitation, invite_key = invitation_and_key_from_token(original_token)
708
+ invitable = invitation&.invitable || new(invite_key || {})
709
+ invitable.assign_attributes(invite_key) if invitation && invite_key.present?
631
710
  invitable.instance_variable_set(:@current_invitation, invitation)
632
711
  if invitation.nil?
633
712
  invitable.errors.add(:invitation_token, :invalid)
@@ -640,9 +719,14 @@ module Devise
640
719
  def pending_invitations_for(attributes)
641
720
  keys = normalized_invite_keys(attributes)
642
721
  digest = DeviseInvitable::Invitation.digest_for(devise_invitable_base_class.name, keys)
643
- DeviseInvitable::Invitation.pending.where(
644
- invitable_type: devise_invitable_base_class.name,
645
- invite_key_digest: digest
722
+ DeviseInvitable::Invitation.to_adapter.find_all(
723
+ conditions: {
724
+ invitation_accepted_at: nil,
725
+ superseded_at: nil,
726
+ revoked_at: nil,
727
+ invitable_type: devise_invitable_base_class.name,
728
+ invite_key_digest: digest
729
+ }
646
730
  ).to_a
647
731
  end
648
732
 
@@ -683,6 +767,9 @@ module Devise
683
767
  Devise::Models.config(self, :allow_insecure_sign_in_after_accept)
684
768
  Devise::Models.config(self, :require_password_on_accepting)
685
769
  Devise::Models.config(self, :create_resource_on_invitation)
770
+ Devise::Models.config(self, :store_invite_keys_until_delivery)
771
+ Devise::Models.config(self, :deliver_invitation_later)
772
+ Devise::Models.config(self, :supersede_other_invitations_on_accept)
686
773
 
687
774
  def invited_by_class_name=(value)
688
775
  DeviseInvitable.warn_deprecated_invited_by_setting(:invited_by_class_name)
@@ -696,6 +783,44 @@ module Devise
696
783
 
697
784
  private
698
785
 
786
+ def invitation_and_key_from_token(original_token)
787
+ payload = DeviseInvitable::AcceptanceToken.decode(original_token)
788
+ return legacy_invitation_and_key(original_token) unless payload
789
+ return [nil, nil] unless payload[:invitable_type] == devise_invitable_base_class.name
790
+
791
+ invitation_token = Devise.token_generator.digest(
792
+ self,
793
+ :invitation_token,
794
+ payload[:raw_token]
795
+ )
796
+ invitation = DeviseInvitable::Invitation.to_adapter.find_first(
797
+ id: payload[:invitation_id],
798
+ invitable_type: devise_invitable_base_class.name,
799
+ invitation_token: invitation_token
800
+ )
801
+ invitation.invitation_data = payload[:invitation_data] || {} if invitation
802
+ invite_key = normalized_invite_keys(payload[:invite_key] || {})
803
+ expected_digest = DeviseInvitable::Invitation.digest_for(
804
+ devise_invitable_base_class.name,
805
+ invite_key
806
+ )
807
+ invitation = nil unless invitation &&
808
+ ActiveSupport::SecurityUtils.secure_compare(
809
+ invitation.invite_key_digest,
810
+ expected_digest
811
+ )
812
+ [invitation, invite_key]
813
+ end
814
+
815
+ def legacy_invitation_and_key(original_token)
816
+ invitation_token = Devise.token_generator.digest(self, :invitation_token, original_token)
817
+ invitation = DeviseInvitable::Invitation.to_adapter.find_first(
818
+ invitable_type: devise_invitable_base_class.name,
819
+ invitation_token: invitation_token
820
+ )
821
+ [invitation, invitation&.invite_key]
822
+ end
823
+
699
824
  def ensure_unique_invite_key_match!(required_attributes, attributes)
700
825
  filtered_attributes = attributes.to_h.with_indifferent_access
701
826
  .slice(*required_attributes)
@@ -703,7 +828,7 @@ module Devise
703
828
  return unless filtered_attributes.size == required_attributes.size
704
829
 
705
830
  filtered_attributes = devise_parameter_filter.filter(filtered_attributes)
706
- matches = where(filtered_attributes).limit(2).to_a
831
+ matches = to_adapter.find_all(conditions: filtered_attributes, limit: 2).to_a
707
832
  return unless matches.many?
708
833
 
709
834
  raise DeviseInvitable::AmbiguousInviteKeyError,
@@ -1,3 +1,3 @@
1
1
  module DeviseInvitable
2
- VERSION = '3.0.4'.freeze
2
+ VERSION = '3.1.0'.freeze
3
3
  end
@@ -1,9 +1,14 @@
1
1
  module DeviseInvitable
2
2
  class AmbiguousInviteKeyError < StandardError; end
3
+ class InvitationDataTooLargeError < ArgumentError; end
4
+ class MissingInviteKeyError < StandardError; end
3
5
  class ResourceAttributesRequirePlaceholderError < ArgumentError; end
4
6
 
7
+ autoload :AcceptanceToken, 'devise_invitable/acceptance_token'
5
8
  autoload :Inviter, 'devise_invitable/inviter'
6
9
  autoload :Invitation, 'devise_invitable/invitation'
10
+ autoload :InvitationDelivery, 'devise_invitable/invitation_delivery'
11
+ autoload :InvitationDeliveryJob, 'devise_invitable/invitation_delivery_job'
7
12
  autoload :Mailer, 'devise_invitable/mailer'
8
13
  autoload :Migrator, 'devise_invitable/migrator'
9
14
  autoload :Mapping, 'devise_invitable/mapping'
@@ -12,6 +17,15 @@ module DeviseInvitable
12
17
  autoload :Registrations, 'devise_invitable/controllers/registrations'
13
18
  autoload :Helpers, 'devise_invitable/controllers/helpers'
14
19
  end
20
+
21
+ def self.invitation_delivery_job
22
+ job = Devise.invitation_delivery_job
23
+ job = job.constantize if job.respond_to?(:constantize)
24
+ return job if job.respond_to?(:perform_later)
25
+
26
+ raise ArgumentError,
27
+ 'Devise.invitation_delivery_job must respond to perform_later'
28
+ end
15
29
  end
16
30
 
17
31
  require 'devise'
@@ -101,10 +115,36 @@ module Devise
101
115
  @@require_password_on_accepting = true
102
116
 
103
117
  # Public: Create the invited resource as soon as an invitation is sent.
104
- # When false, only the invitation and its invite keys are persisted and the
105
- # resource is created when the invitation is accepted.
118
+ # When false, only the invitation record is persisted and the resource is
119
+ # created when the invitation is accepted.
106
120
  mattr_accessor :create_resource_on_invitation
107
121
  @@create_resource_on_invitation = false
122
+
123
+ # Public: Store invite keys until an invitation is delivered so a skipped,
124
+ # resource-less invitation can be delivered later. Stored keys are cleared
125
+ # after successful delivery.
126
+ mattr_accessor :store_invite_keys_until_delivery
127
+ @@store_invite_keys_until_delivery = false
128
+
129
+ # Public: Deliver invitation emails through the configured invitation job.
130
+ # Other Devise notifications continue to use send_devise_notification.
131
+ mattr_accessor :deliver_invitation_later
132
+ @@deliver_invitation_later = false
133
+
134
+ # Public: Active Job class used by Invitation#deliver_later. It may be a
135
+ # class or a class name and must respond to perform_later.
136
+ mattr_accessor :invitation_delivery_job
137
+ @@invitation_delivery_job = 'DeviseInvitable::InvitationDeliveryJob'
138
+
139
+ # Public: Queue used by the default invitation delivery job.
140
+ mattr_accessor :invitation_delivery_queue_name
141
+ @@invitation_delivery_queue_name = :mailers
142
+
143
+ # Public: Supersede other pending invitations for the same invite keys when
144
+ # one invitation is accepted. Disable this only when each invitation carries
145
+ # an independently acceptable application-specific context.
146
+ mattr_accessor :supersede_other_invitations_on_accept
147
+ @@supersede_other_invitations_on_accept = true
108
148
  end
109
149
 
110
150
  module DeviseInvitable
@@ -51,6 +51,27 @@ module DeviseInvitable
51
51
  # Default: false
52
52
  # config.create_resource_on_invitation = true
53
53
 
54
+ # Store invite keys until delivery so skipped invitations without placeholder
55
+ # resources can be delivered later. Keys are cleared after successful delivery.
56
+ # Default: false
57
+ # config.store_invite_keys_until_delivery = true
58
+
59
+ # Deliver invitation emails with the configured invitation delivery job.
60
+ # Other Devise notifications still use send_devise_notification.
61
+ # Default: false
62
+ # config.deliver_invitation_later = true
63
+
64
+ # Active Job class and queue used by invitation.deliver_later. Configure a
65
+ # custom job class when invitations should inherit from ApplicationJob or use
66
+ # application-specific retry and callback behavior.
67
+ # config.invitation_delivery_job = "MyInvitationDeliveryJob"
68
+ # config.invitation_delivery_queue_name = :mailers
69
+
70
+ # Supersede other pending invitations for the same invite keys when one is
71
+ # accepted. Disable this only for independently acceptable invitation data.
72
+ # Default: true
73
+ # config.supersede_other_invitations_on_accept = false
74
+
54
75
  # The column name used for counter_cache column. If this is nil,
55
76
  # the #invited_by association is declared without counter_cache.
56
77
  # Default: nil
@@ -147,6 +147,32 @@ class InvitationTest < ActionDispatch::IntegrationTest
147
147
  Devise.allow_insecure_sign_in_after_accept = original_option_value
148
148
  end
149
149
 
150
+ test 'does not sign in an already provisioned user when accepting another invitation' do
151
+ original_create_resource = User.create_resource_on_invitation
152
+ original_supersede = User.supersede_other_invitations_on_accept
153
+ User.create_resource_on_invitation = false
154
+ User.supersede_other_invitations_on_accept = false
155
+
156
+ first_invited = User.invite!(email: 'valid@email.com')
157
+ first_token = first_invited.raw_invitation_token
158
+ second_invited = User.invite!(email: 'valid@email.com')
159
+ second_token = second_invited.raw_invitation_token
160
+ user = User.accept_invitation!(
161
+ invitation_token: first_token,
162
+ password: '123456789',
163
+ password_confirmation: '123456789'
164
+ )
165
+
166
+ set_password invitation_token: second_token
167
+
168
+ assert_equal new_user_session_path, current_path
169
+ assert user.reload.valid_password?('123456789')
170
+ refute user.valid_password?('987654321')
171
+ ensure
172
+ User.create_resource_on_invitation = original_create_resource
173
+ User.supersede_other_invitations_on_accept = original_supersede
174
+ end
175
+
150
176
  test 'clear token and set invitation_accepted_at after recover password instead of accept_invitation' do
151
177
  user = User.invite!(email: 'valid@email.com')
152
178
 
@@ -0,0 +1,138 @@
1
+ require 'test_helper'
2
+ require 'model_tests_helper'
3
+
4
+ class InvitationDeliveryJobTest < ActiveSupport::TestCase
5
+ setup do
6
+ @invitation_delivery_job = Devise.invitation_delivery_job
7
+ @invitation_delivery_queue_name = Devise.invitation_delivery_queue_name
8
+ @create_resource_on_invitation = User.create_resource_on_invitation
9
+ @store_invite_keys_until_delivery = User.store_invite_keys_until_delivery
10
+ @deliver_invitation_later = User.deliver_invitation_later
11
+ User.create_resource_on_invitation = false
12
+ User.deliver_invitation_later = false
13
+ DeviseInvitable::Invitation.delete_all
14
+ end
15
+
16
+ teardown do
17
+ Devise.invitation_delivery_job = @invitation_delivery_job
18
+ Devise.invitation_delivery_queue_name = @invitation_delivery_queue_name
19
+ User.create_resource_on_invitation = @create_resource_on_invitation
20
+ User.store_invite_keys_until_delivery = @store_invite_keys_until_delivery
21
+ User.deliver_invitation_later = @deliver_invitation_later
22
+ end
23
+
24
+ test 'deliver_later passes a persisted invitation and acceptance token to the configured job' do
25
+ job_class = Class.new do
26
+ class << self
27
+ attr_reader :arguments
28
+
29
+ def perform_later(*arguments)
30
+ @arguments = arguments
31
+ :enqueued
32
+ end
33
+ end
34
+ end
35
+ Devise.invitation_delivery_job = job_class
36
+
37
+ user = User.invite!(email: 'queued@example.com', skip_invitation: true)
38
+ invitation = user.current_invitation
39
+
40
+ assert_equal :enqueued, invitation.deliver_later
41
+ enqueued_invitation, acceptance_token, options = job_class.arguments
42
+ assert_equal invitation, enqueued_invitation
43
+ assert_equal({}, options)
44
+ assert_equal 'queued@example.com',
45
+ DeviseInvitable::AcceptanceToken.decode(acceptance_token).dig(:invite_key, :email)
46
+
47
+ assert_difference('ActionMailer::Base.deliveries.size', 1) do
48
+ DeviseInvitable::InvitationDeliveryJob.perform_now(
49
+ DeviseInvitable::Invitation.find(invitation.id),
50
+ acceptance_token,
51
+ options
52
+ )
53
+ end
54
+ assert_predicate invitation.reload, :invitation_sent_at
55
+ assert_equal ['queued@example.com'], ActionMailer::Base.deliveries.last.to
56
+ end
57
+
58
+ test 'the default job delivers a reloaded resource-less invitation from stored keys' do
59
+ User.store_invite_keys_until_delivery = true
60
+ user = User.invite!(email: 'stored@example.com', skip_invitation: true)
61
+ invitation = DeviseInvitable::Invitation.find(user.current_invitation.id)
62
+
63
+ assert_difference('ActionMailer::Base.deliveries.size', 1) do
64
+ DeviseInvitable::InvitationDeliveryJob.perform_now(invitation)
65
+ end
66
+
67
+ assert_predicate invitation.reload, :invitation_sent_at
68
+ assert_empty invitation.invite_key
69
+ assert_equal ['stored@example.com'], ActionMailer::Base.deliveries.last.to
70
+ end
71
+
72
+ test 'the default job does not enqueue through send_devise_notification' do
73
+ user = User.invite!(email: 'direct@example.com', skip_invitation: true)
74
+ invitation = user.current_invitation
75
+ acceptance_token = user.acceptance_token
76
+ User.any_instance.stubs(:send_devise_notification).raises('must not be called')
77
+
78
+ assert_difference('ActionMailer::Base.deliveries.size', 1) do
79
+ DeviseInvitable::InvitationDeliveryJob.perform_now(
80
+ invitation,
81
+ acceptance_token
82
+ )
83
+ end
84
+
85
+ assert_predicate invitation.reload, :invitation_sent_at
86
+ end
87
+
88
+ test 'the setting enqueues the normal invitation flow with the configured job' do
89
+ job_class = Class.new do
90
+ class << self
91
+ attr_reader :arguments
92
+
93
+ def perform_later(*arguments)
94
+ @arguments = arguments
95
+ :enqueued
96
+ end
97
+ end
98
+ end
99
+ Devise.invitation_delivery_job = job_class
100
+ User.deliver_invitation_later = true
101
+ User.any_instance.stubs(:send_devise_notification).raises('must not be called')
102
+
103
+ assert_no_difference('ActionMailer::Base.deliveries.size') do
104
+ User.invite!(email: 'configured@example.com')
105
+ end
106
+
107
+ invitation, acceptance_token, options = job_class.arguments
108
+ assert_predicate invitation, :persisted?
109
+ assert_nil invitation.invitation_sent_at
110
+ assert_equal({ validate: false }, options)
111
+ assert_equal 'configured@example.com',
112
+ DeviseInvitable::AcceptanceToken.decode(acceptance_token).dig(:invite_key, :email)
113
+ end
114
+
115
+ test 'the normal flow keeps using send_devise_notification when the setting is disabled' do
116
+ User.any_instance.expects(:send_devise_notification)
117
+ .with(:invitation_instructions, kind_of(String), { validate: false })
118
+ .returns(:application_delivery)
119
+
120
+ assert_equal :application_delivery,
121
+ User.invite_mail!(email: 'application-delivery@example.com')
122
+ end
123
+
124
+ test 'the default job uses the configured queue' do
125
+ Devise.invitation_delivery_queue_name = :critical_mailers
126
+
127
+ assert_equal 'critical_mailers', DeviseInvitable::InvitationDeliveryJob.new.queue_name
128
+ end
129
+
130
+ test 'a reloaded resource-less invitation needs stored keys or an acceptance token' do
131
+ user = User.invite!(email: 'missing@example.com', skip_invitation: true)
132
+ invitation = DeviseInvitable::Invitation.find(user.current_invitation.id)
133
+
134
+ assert_raises(DeviseInvitable::MissingInviteKeyError) do
135
+ invitation.deliver_later
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,17 @@
1
+ require 'test_helper'
2
+ require 'open3'
3
+
4
+ class LazyLoadingTest < ActiveSupport::TestCase
5
+ LIBRARY = File.expand_path('../lib', __dir__)
6
+
7
+ test 'loading devise invitable does not load Active Job' do
8
+ script = <<~RUBY
9
+ $LOAD_PATH.unshift #{LIBRARY.inspect}
10
+ require 'devise_invitable'
11
+ abort 'Active Job was loaded' if defined?(ActiveJob::Base)
12
+ RUBY
13
+ _stdout, stderr, status = Open3.capture3(RbConfig.ruby, '-e', script)
14
+
15
+ assert status.success?, stderr
16
+ end
17
+ end