sms_safe 1.0.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.
@@ -0,0 +1,151 @@
1
+ require 'mail'
2
+
3
+ module SmsSafe
4
+
5
+ # When a message is intercepted, Interceptor decides whether we need to do anything with it,
6
+ # and does it.
7
+ # The different adaptor classes in the Interceptors module provide mapping to each of the SMS libraries peculiarities.
8
+ class Interceptor
9
+
10
+ # Method called by all the sub-classes to process the SMS being sent
11
+ # @param [Object] original_message the message we intercepted from the texter gem. May be of varying types, depending
12
+ # on which texter gem is being used.
13
+ # @return [Object] the message to send (if modified recipient / text), of the same type we received
14
+ # or nil if no SMS should be sent
15
+ def process_message(original_message)
16
+ message = convert_message(original_message)
17
+
18
+ if intercept_message?(message)
19
+ intercept_message!(message)
20
+ else
21
+ original_message
22
+ end
23
+ end
24
+
25
+ # Decides whether to intercept the message that is being sent, or to let it go through
26
+ # @param [Message] message the message we are evaluating
27
+ # @return [Boolean] whether to intercept the message (true) or let it go through (false)
28
+ def intercept_message?(message)
29
+ matching_rules = [SmsSafe.configuration.internal_phone_numbers].flatten.compact
30
+ internal_recipient = matching_rules.any? do |rule|
31
+ case rule
32
+ when String then message.to == rule
33
+ when Regexp then !!(message.to =~ rule)
34
+ when Proc then rule.call(message)
35
+ else
36
+ raise InvalidConfigSettingError.new("Ensure internal_phone_numbers is a String, a Regexp or a Proc (or an array of them). It was: #{SmsSafe.configuration.internal_phone_numbers.inspect}")
37
+ end
38
+ end
39
+ !internal_recipient # Intercept messages that are *not* going to one of the allowed numbers
40
+ end
41
+
42
+ # Once we've decided to intercept the message, act on it, based on the intercept_mechanism set
43
+ # @param [Message] message the message we are evaluating
44
+ # @return [Object] the message to send, of the type that corresponds to the texter gem (if :redirecting)
45
+ # or nil to cancel sending (if :email or :discard)
46
+ def intercept_message!(message)
47
+ case SmsSafe.configuration.intercept_mechanism
48
+ when :redirect then redirect(message)
49
+ when :email then email(message)
50
+ when :discard then discard
51
+ else
52
+ raise InvalidConfigSettingError.new("Ensure intercept_mechanism is either :redirect, :email or :discard. It was: #{SmsSafe.configuration.intercept_mechanism.inspect}")
53
+ end
54
+ end
55
+
56
+ # Decides which phone number to redirect the message to
57
+ # @param [Message] message the message we are redirecting
58
+ # @return [String] the phone number to redirect the number to
59
+ def redirect_phone_number(message)
60
+ target = SmsSafe.configuration.redirect_target
61
+ case target
62
+ when String then target
63
+ when Proc then target.call(message)
64
+ else
65
+ raise InvalidConfigSettingError.new("Ensure redirect_target is a String or a Proc. It was: #{SmsSafe.configuration.redirect_target.inspect}")
66
+ end
67
+ end
68
+
69
+ # Modifies the text of the message to indicate it was redirected
70
+ # Simply appends "(SmsSafe: original_recipient_number)", for brevity
71
+ #
72
+ # @param [Message] message the message we are redirecting
73
+ # @return [String] the new text for the SMS
74
+ def redirect_text(message)
75
+ "#{message.text} (SmsSafe: #{message.to})"
76
+ end
77
+
78
+ # Sends an e-mail to the specified address, instead of
79
+ def email(message)
80
+ message_body = <<-EOS
81
+ This email was originally an SMS that SmsSafe intercepted:
82
+
83
+ From: #{message.from}
84
+ To: #{message.to}
85
+ Text: #{message.text}
86
+
87
+ Full object: #{message.original_message.inspect}
88
+ EOS
89
+
90
+ recipient = email_recipient(message)
91
+
92
+ mail = Mail.new do
93
+ from recipient
94
+ to recipient
95
+ subject 'SmsSafe: #{message.to} - #{message.text}'
96
+ body message_body
97
+ end
98
+ mail.deliver!
99
+
100
+ # Must return nil to stop the sending
101
+ nil
102
+ end
103
+
104
+ # Decides which email address to send the SMS to
105
+ # @param [Message] message the message we are emailing
106
+ # @return [String] the email address to email it to
107
+ def email_recipient(message)
108
+ target = SmsSafe.configuration.email_target
109
+ case target
110
+ when String then target
111
+ when Proc then target.call(message)
112
+ else
113
+ raise InvalidConfigSettingError.new("Ensure email_target is a String or a Proc. It was: #{SmsSafe.configuration.email_target.inspect}")
114
+ end
115
+ end
116
+
117
+ # Discards the message. Essentially doesn't do anything. Will sleep for a bit, however, if
118
+ # configuration.discard_delay is set.
119
+ def discard
120
+ # Delay to simulate the time it takes to talk to the external service
121
+ if !SmsSafe.configuration.discard_delay.nil? && SmsSafe.configuration.discard_delay > 0
122
+ delay = SmsSafe.configuration.discard_delay.to_f / 1000 # delay is specified in ms
123
+ sleep delay
124
+ end
125
+
126
+ # Must return nil to stop the sending
127
+ nil
128
+ end
129
+
130
+ # Converts an SMS message from whatever object the texter gem uses into our generic Message
131
+ # Must be overridden by each gem's interceptor
132
+ #
133
+ # @param [Object] message that is being sent
134
+ # @return [Message] the message converted into our own Message class
135
+ def convert_message(message)
136
+ raise "Must override!"
137
+ end
138
+
139
+ # Returns a modified version of the original message with new recipient and text,
140
+ # to give back to the texter gem to send.
141
+ # Must be overridden by each gem's interceptor
142
+ # Call redirect_phone_number and redirect_text to get the new recipient and text, and
143
+ # modify message.original_message
144
+ #
145
+ # @param [Message] message that is being sent, unmodified
146
+ # @return [Object] modified message to send, of the type the texter gem uses
147
+ def redirect(message)
148
+ raise "Must override!"
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,32 @@
1
+ module SmsSafe
2
+ module Interceptors
3
+ class ActionTexter < SmsSafe::Interceptor
4
+ # This method will be called differently for each Texter Gem, it's the one that the hook likes to call
5
+ # In all cases, it's a one-liner that calls process_message in the superclass
6
+ # It could even be an alias, for all practical purposes
7
+ def delivering_sms(message)
8
+ self.process_message(message)
9
+ end
10
+
11
+ # Converts an ActionTexter::Message into an SmsSafe::Message
12
+ # @param [ActionTexter::Message] message that is being sent by ActionTexter gem
13
+ # @return [Message] the message converted into our own Message class
14
+ def convert_message(message)
15
+ SmsSafe::Message.new(from: message.from, to: message.to, text: message.text, original_message: message)
16
+ end
17
+
18
+
19
+ # Returns a modified version of the original message with new recipient and text,
20
+ # to give back to the texter gem to send.
21
+ #
22
+ # @param [Message] message that is being sent, unmodified
23
+ # @return [ActionTexter::Message] modified message to send
24
+ def redirect(message)
25
+ original_message = message.original_message
26
+ original_message.to = redirect_phone_number(message)
27
+ original_message.text = redirect_text(message)
28
+ original_message
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,31 @@
1
+ module SmsSafe
2
+ module Interceptors
3
+ class Nexmo < SmsSafe::Interceptor
4
+ # This method will be called differently for each Texter Gem, it's the one that the hook likes to call
5
+ # In all cases, it's a one-liner that calls process_message in the superclass
6
+ # It could even be an alias, for all practical purposes
7
+ # def delivering_sms(message)
8
+ # self.process_message(message)
9
+ # end
10
+
11
+ # Converts a hash of params (Nexmo doesn't use a class to represent their messages) into Message
12
+ # @param [Hash] message that is being sent by Nexmo gem
13
+ # @return [Message] the message converted into our own Message class
14
+ def convert_message(message)
15
+ SmsSafe::Message.new(from: message[:from], to: message[:to], text: message[:text], original_message: message)
16
+ end
17
+
18
+ # Returns a modified version of the original message with new recipient and text,
19
+ # to give back to the texter gem to send.
20
+ #
21
+ # @param [Message] message that is being sent, unmodified
22
+ # @return [Hash] modified message to send
23
+ def redirect(message)
24
+ original_message = message.original_message
25
+ original_message[:to] = redirect_phone_number(message)
26
+ original_message[:text] = redirect_text(message)
27
+ original_message
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,31 @@
1
+ module SmsSafe
2
+ module Interceptors
3
+ class Twilio < SmsSafe::Interceptor
4
+ # This method will be called differently for each Texter Gem, it's the one that the hook likes to call
5
+ # In all cases, it's a one-liner that calls process_message in the superclass
6
+ # It could even be an alias, for all practical purposes
7
+ # def delivering_sms(message)
8
+ # self.process_message(message)
9
+ # end
10
+
11
+ # Converts a hash of params (Twilio's call is just a hash to Client.messages) into Message
12
+ # @param [Hash] message that is being sent by Twilio gem
13
+ # @return [Message] the message converted into our own Message class
14
+ def convert_message(message)
15
+ SmsSafe::Message.new(from: message[:from], to: message[:to], text: message[:body], original_message: message)
16
+ end
17
+
18
+ # Returns a modified version of the original message with new recipient and text,
19
+ # to give back to the texter gem to send.
20
+ #
21
+ # @param [Message] message that is being sent, unmodified
22
+ # @return [Hash] modified message to send
23
+ def redirect(message)
24
+ original_message = message.original_message
25
+ original_message[:to] = redirect_phone_number(message)
26
+ original_message[:body] = redirect_text(message)
27
+ original_message
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,21 @@
1
+ module SmsSafe
2
+
3
+ # Different texter gems will have different classes for their messages.
4
+ # This is a common class that acts as an impedance adapter. Most of our methods use this class
5
+ #
6
+ # @!attribute from
7
+ # @return [String] name or phone number of the author of the message.
8
+ # @!attribute to
9
+ # @return [String] phone number of the recipient of the message.
10
+ # @!attribute text
11
+ # @return [String] actual message to send.
12
+ # @!attribute original_message
13
+ # @return [String] original message sent by the texter gem, unmapped.
14
+ class Message
15
+ attr_accessor :from, :to, :text, :original_message
16
+
17
+ def initialize(attrs)
18
+ attrs.each { |k, v| self.send "#{k.to_s}=".to_sym, v }
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,3 @@
1
+ module SmsSafe
2
+ VERSION = "1.0.0"
3
+ end
@@ -0,0 +1,49 @@
1
+ lib = File.expand_path("../lib", __FILE__)
2
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
+ require "sms_safe/version"
4
+
5
+ Gem::Specification.new do |s|
6
+ s.name = 'sms_safe'
7
+ s.version = '1.0.0'
8
+ s.summary = "Keep your SMS messages from escaping into the wild during development."
9
+ s.description = %q{SmsSafe provides a safety net while you're developing an application that uses ActionTexter
10
+ or other gems to send SMS. It keeps SMS messages from escaping into the wild.
11
+
12
+ Once you've installed and configured this gem, you can rest assures that your app won't send
13
+ SMS messages to external phone numbers. Instead, messages will be routed to a phone number
14
+ you specify, converted into e-mails to you, or simply not sent at all.
15
+
16
+ SmsSafe can also include an artificial delay to simulate the call to your SMS provider,
17
+ for realistic load testing.}
18
+ s.authors = ["Daniel Magliola"]
19
+ s.email = 'dmagliola@crystalgears.com'
20
+ s.homepage = 'https://github.com/dmagliola/sms_safe'
21
+ s.license = 'MIT'
22
+
23
+ s.files = `git ls-files`.split($/)
24
+ s.executables = s.files.grep(%r{^bin/}) { |f| File.basename(f) }
25
+ s.test_files = s.files.grep(%r{^(test|s.features)/})
26
+ s.require_paths = ["lib"]
27
+
28
+ s.required_ruby_version = ">= 1.9.3"
29
+
30
+ s.add_runtime_dependency "mail", '>= 2.4'
31
+
32
+ s.add_development_dependency "bundler"
33
+ s.add_development_dependency "rake"
34
+
35
+ s.add_development_dependency "minitest"
36
+ s.add_development_dependency "minitest-reporters"
37
+ s.add_development_dependency "shoulda"
38
+ s.add_development_dependency "mocha"
39
+ s.add_development_dependency "simplecov"
40
+
41
+ # All the Gems we integrate with, to be able to test the hooks
42
+ s.add_development_dependency "action_texter"
43
+ s.add_development_dependency "twilio-ruby"
44
+ s.add_development_dependency "nexmo"
45
+
46
+ s.add_development_dependency "appraisal"
47
+ s.add_development_dependency "coveralls"
48
+ s.add_development_dependency "codeclimate-test-reporter"
49
+ end
@@ -0,0 +1,149 @@
1
+ require_relative "test_helper"
2
+ require "action_texter"
3
+ require "nexmo"
4
+ require "twilio-ruby"
5
+
6
+ # These are the real integration tests. Each one of these installs the hook, then tried sending an SMS
7
+ # that will and will not get intercepted, and check that that actually happens.
8
+ class HooksTest < MiniTest::Test
9
+ context "With a basic configuration for SmsSafe" do
10
+ setup do
11
+ SmsSafe.configure do |config|
12
+ config.internal_phone_numbers = INTERNAL_PHONE_NUMBERS
13
+ config.intercept_mechanism = :discard
14
+ config.redirect_target = DEFAULT_INTERNAL_PHONE_NUMBER
15
+ end
16
+
17
+ @action_texter_client = ActionTexter::TestClient.new
18
+ end
19
+
20
+ should "hook ActionTexter" do
21
+ ActionTexter::Client.setup("Test") # Excellent, no need to mock stuff up! Thank you ActionTexter!
22
+ SmsSafe.hook!(:action_texter)
23
+
24
+ # Try to send an external message
25
+ @action_texter_client.deliveries.clear
26
+ message = ActionTexter::Message.new(from: DEFAULT_INTERNAL_PHONE_NUMBER, to: EXTERNAL_PHONE_NUMBERS.first, text: "Foo", reference: "ref-1")
27
+ result = message.deliver
28
+
29
+ # Check that return is nil and that nothing got sent
30
+ assert_nil result
31
+ assert_equal 0, @action_texter_client.deliveries.length
32
+
33
+ # Change configuration to redirect
34
+ SmsSafe.configuration.intercept_mechanism = :redirect
35
+
36
+ # Try to send an external message
37
+ @action_texter_client.deliveries.clear
38
+ message = ActionTexter::Message.new(from: DEFAULT_INTERNAL_PHONE_NUMBER, to: EXTERNAL_PHONE_NUMBERS.first, text: "Foo", reference: "ref-1")
39
+ result = message.deliver
40
+
41
+ # Check that return is appropriate and that something got sent, redirected and with changed text
42
+ refute_nil result
43
+ assert_equal 1, @action_texter_client.deliveries.length
44
+ assert_equal DEFAULT_INTERNAL_PHONE_NUMBER, @action_texter_client.deliveries.last.to
45
+ assert_operator "Foo".length, :<, @action_texter_client.deliveries.last.text.length
46
+
47
+ # Try to send an internal message
48
+ @action_texter_client.deliveries.clear
49
+ message = ActionTexter::Message.new(from: DEFAULT_INTERNAL_PHONE_NUMBER, to: INTERNAL_PHONE_NUMBERS.last, text: "Foo", reference: "ref-1")
50
+ result = message.deliver
51
+
52
+ # Check that it got delivered, unchanged
53
+ refute_nil result
54
+ assert_equal 1, @action_texter_client.deliveries.length
55
+ assert_equal INTERNAL_PHONE_NUMBERS.last, @action_texter_client.deliveries.last.to
56
+ assert_equal "Foo", @action_texter_client.deliveries.last.text
57
+ end
58
+
59
+ should "hook Nexmo" do
60
+ nexmo = Nexmo::Client.new(key: "blah", secret: "bleh")
61
+ SmsSafe.hook!(:nexmo)
62
+
63
+ # Stub the "post" method so that it doesn't actually do a post
64
+ # I'm doing that instead of stubbing "send_message", since we're already monkeypatching send_message, and I don't want those two to collide
65
+ nexmo.expects(:post).never
66
+
67
+ # Try to send an external message
68
+ result = nexmo.send_message(from: DEFAULT_INTERNAL_PHONE_NUMBER, to: EXTERNAL_PHONE_NUMBERS.first, text: 'Foo')
69
+
70
+ # Check that return is nil and that nothing got sent
71
+ assert_nil result
72
+
73
+ # Change configuration to redirect
74
+ SmsSafe.configuration.intercept_mechanism = :redirect
75
+
76
+ # Stub again so that it validates the parameters we want
77
+ nexmo.expects(:post).
78
+ once.
79
+ with() { |path, params| params[:to] == DEFAULT_INTERNAL_PHONE_NUMBER && params[:text].length > 'Foo'.length && params[:text].include?('Foo') }.
80
+ returns({ 'messages' => ['status' => 0, 'message-id' => '123456']})
81
+
82
+ # Try to send an external message
83
+ result = nexmo.send_message(from: DEFAULT_INTERNAL_PHONE_NUMBER, to: EXTERNAL_PHONE_NUMBERS.first, text: 'Foo')
84
+
85
+ # Check that return is appropriate. The rest got checked in the stub
86
+ refute_nil result
87
+
88
+ # Stub again so that it validates the parameters we want
89
+ nexmo.expects(:post).
90
+ once.
91
+ with() { |path, params| params[:to] == INTERNAL_PHONE_NUMBERS.last && params[:text] = 'Foo' }.
92
+ returns({ 'messages' => ['status' => 0, 'message-id' => '123456']})
93
+
94
+ # Try to send an internal message
95
+ result = nexmo.send_message(from: DEFAULT_INTERNAL_PHONE_NUMBER, to: INTERNAL_PHONE_NUMBERS.last, text: 'Foo')
96
+
97
+ # Check that it got delivered. The rest got checked in the stub
98
+ refute_nil result
99
+ end
100
+
101
+ should "hook Twilio" do
102
+ twilio = Twilio::REST::Client.new 'blah', 'bleh'
103
+ SmsSafe.hook!(:twilio)
104
+
105
+ # Stub the "post" method so that it doesn't actually do a post
106
+ # I'm doing that instead of stubbing "send_message", since we're already monkeypatching send_message, and I don't want those two to collide
107
+ twilio.expects(:post).never
108
+
109
+ # Try to send an external message
110
+ result = twilio.messages.create(from: DEFAULT_INTERNAL_PHONE_NUMBER, to: EXTERNAL_PHONE_NUMBERS.first, body: 'Foo')
111
+
112
+ # Check that return is nil and that nothing got sent
113
+ assert_nil result
114
+
115
+ # Change configuration to redirect
116
+ SmsSafe.configuration.intercept_mechanism = :redirect
117
+
118
+ # Stub again so that it validates the parameters we want
119
+ twilio.expects(:post).
120
+ once.
121
+ with() { |path, params| params[:to] == DEFAULT_INTERNAL_PHONE_NUMBER && params[:body].length > 'Foo'.length && params[:body].include?('Foo') }.
122
+ returns({ 'sid' => 'Message01'})
123
+
124
+ # Try to send an external message
125
+ result = twilio.messages.create(from: DEFAULT_INTERNAL_PHONE_NUMBER, to: EXTERNAL_PHONE_NUMBERS.first, body: 'Foo')
126
+
127
+ # Check that return is appropriate. The rest got checked in the stub
128
+ refute_nil result
129
+
130
+ # Stub again so that it validates the parameters we want
131
+ twilio.expects(:post).
132
+ once.
133
+ with() { |path, params| params[:to] == INTERNAL_PHONE_NUMBERS.last && params[:body] = 'Foo' }.
134
+ returns({ 'sid' => 'Message01'})
135
+
136
+ # Try to send an internal message
137
+ result = twilio.messages.create(from: DEFAULT_INTERNAL_PHONE_NUMBER, to: INTERNAL_PHONE_NUMBERS.last, body: 'Foo')
138
+
139
+ # Check that it got delivered. The rest got checked in the stub
140
+ refute_nil result
141
+ end
142
+
143
+ should "raise if hooking an invalid library" do
144
+ assert_raises(SmsSafe::InvalidConfigSettingError) do
145
+ SmsSafe.hook!(:invalid)
146
+ end
147
+ end
148
+ end
149
+ end