ably 1.0.6 → 1.1.3

Sign up to get free protection for your applications and to get access to all the features.
Files changed (93) hide show
  1. checksums.yaml +4 -4
  2. data/.editorconfig +14 -0
  3. data/.travis.yml +10 -8
  4. data/CHANGELOG.md +75 -3
  5. data/LICENSE +1 -3
  6. data/README.md +12 -7
  7. data/Rakefile +32 -0
  8. data/SPEC.md +1277 -835
  9. data/ably.gemspec +14 -9
  10. data/lib/ably/auth.rb +30 -4
  11. data/lib/ably/exceptions.rb +10 -4
  12. data/lib/ably/logger.rb +7 -1
  13. data/lib/ably/models/channel_state_change.rb +1 -1
  14. data/lib/ably/models/connection_state_change.rb +1 -1
  15. data/lib/ably/models/device_details.rb +87 -0
  16. data/lib/ably/models/device_push_details.rb +86 -0
  17. data/lib/ably/models/error_info.rb +23 -2
  18. data/lib/ably/models/idiomatic_ruby_wrapper.rb +4 -4
  19. data/lib/ably/models/protocol_message.rb +32 -2
  20. data/lib/ably/models/push_channel_subscription.rb +89 -0
  21. data/lib/ably/modules/conversions.rb +1 -1
  22. data/lib/ably/modules/encodeable.rb +1 -1
  23. data/lib/ably/modules/exception_codes.rb +128 -0
  24. data/lib/ably/modules/model_common.rb +15 -2
  25. data/lib/ably/modules/state_machine.rb +2 -2
  26. data/lib/ably/realtime.rb +1 -0
  27. data/lib/ably/realtime/auth.rb +1 -1
  28. data/lib/ably/realtime/channel.rb +24 -102
  29. data/lib/ably/realtime/channel/channel_manager.rb +2 -6
  30. data/lib/ably/realtime/channel/channel_state_machine.rb +2 -2
  31. data/lib/ably/realtime/channel/publisher.rb +74 -0
  32. data/lib/ably/realtime/channel/push_channel.rb +62 -0
  33. data/lib/ably/realtime/client.rb +91 -3
  34. data/lib/ably/realtime/client/incoming_message_dispatcher.rb +6 -2
  35. data/lib/ably/realtime/client/outgoing_message_dispatcher.rb +1 -1
  36. data/lib/ably/realtime/connection.rb +34 -20
  37. data/lib/ably/realtime/connection/connection_manager.rb +25 -9
  38. data/lib/ably/realtime/connection/websocket_transport.rb +1 -1
  39. data/lib/ably/realtime/presence.rb +4 -4
  40. data/lib/ably/realtime/presence/members_map.rb +3 -3
  41. data/lib/ably/realtime/push.rb +40 -0
  42. data/lib/ably/realtime/push/admin.rb +61 -0
  43. data/lib/ably/realtime/push/channel_subscriptions.rb +108 -0
  44. data/lib/ably/realtime/push/device_registrations.rb +105 -0
  45. data/lib/ably/rest.rb +1 -0
  46. data/lib/ably/rest/channel.rb +53 -17
  47. data/lib/ably/rest/channel/push_channel.rb +62 -0
  48. data/lib/ably/rest/client.rb +154 -32
  49. data/lib/ably/rest/middleware/parse_message_pack.rb +17 -1
  50. data/lib/ably/rest/presence.rb +1 -0
  51. data/lib/ably/rest/push.rb +42 -0
  52. data/lib/ably/rest/push/admin.rb +54 -0
  53. data/lib/ably/rest/push/channel_subscriptions.rb +121 -0
  54. data/lib/ably/rest/push/device_registrations.rb +103 -0
  55. data/lib/ably/version.rb +7 -2
  56. data/spec/acceptance/realtime/auth_spec.rb +245 -17
  57. data/spec/acceptance/realtime/channel_history_spec.rb +26 -20
  58. data/spec/acceptance/realtime/channel_spec.rb +177 -59
  59. data/spec/acceptance/realtime/client_spec.rb +153 -0
  60. data/spec/acceptance/realtime/connection_failures_spec.rb +72 -6
  61. data/spec/acceptance/realtime/connection_spec.rb +129 -18
  62. data/spec/acceptance/realtime/message_spec.rb +36 -34
  63. data/spec/acceptance/realtime/presence_spec.rb +201 -167
  64. data/spec/acceptance/realtime/push_admin_spec.rb +736 -0
  65. data/spec/acceptance/realtime/push_spec.rb +27 -0
  66. data/spec/acceptance/rest/auth_spec.rb +41 -3
  67. data/spec/acceptance/rest/base_spec.rb +2 -2
  68. data/spec/acceptance/rest/channel_spec.rb +79 -4
  69. data/spec/acceptance/rest/channels_spec.rb +6 -0
  70. data/spec/acceptance/rest/client_spec.rb +129 -10
  71. data/spec/acceptance/rest/message_spec.rb +158 -6
  72. data/spec/acceptance/rest/push_admin_spec.rb +952 -0
  73. data/spec/acceptance/rest/push_spec.rb +25 -0
  74. data/spec/acceptance/rest/time_spec.rb +1 -1
  75. data/spec/run_parallel_tests +33 -0
  76. data/spec/spec_helper.rb +1 -1
  77. data/spec/support/debug_failure_helper.rb +9 -5
  78. data/spec/support/test_app.rb +2 -2
  79. data/spec/unit/logger_spec.rb +10 -3
  80. data/spec/unit/models/device_details_spec.rb +102 -0
  81. data/spec/unit/models/device_push_details_spec.rb +101 -0
  82. data/spec/unit/models/error_info_spec.rb +51 -3
  83. data/spec/unit/models/message_spec.rb +17 -2
  84. data/spec/unit/models/presence_message_spec.rb +1 -1
  85. data/spec/unit/models/push_channel_subscription_spec.rb +86 -0
  86. data/spec/unit/modules/enum_spec.rb +1 -1
  87. data/spec/unit/realtime/client_spec.rb +13 -1
  88. data/spec/unit/realtime/connection_spec.rb +1 -1
  89. data/spec/unit/realtime/push_channel_spec.rb +36 -0
  90. data/spec/unit/rest/channel_spec.rb +8 -1
  91. data/spec/unit/rest/client_spec.rb +30 -0
  92. data/spec/unit/rest/push_channel_spec.rb +36 -0
  93. metadata +95 -26
@@ -10,6 +10,14 @@ module Ably
10
10
  env.body = parse(env.body) unless env.response_headers['Ably-Middleware-Parsed'] == true
11
11
  env.response_headers['Ably-Middleware-Parsed'] = true
12
12
  end
13
+ rescue Ably::Exceptions::InvalidResponseBody => e
14
+ debug_info = {
15
+ method: env.method,
16
+ url: env.url,
17
+ base64_body: base64_body(env.body),
18
+ response_headers: env.response_headers
19
+ }
20
+ raise Ably::Exceptions::InvalidResponseBody, "#{e.message}\nRequest env: #{debug_info}"
13
21
  end
14
22
 
15
23
  def parse(body)
@@ -18,8 +26,16 @@ module Ably
18
26
  else
19
27
  body
20
28
  end
29
+ rescue MessagePack::UnknownExtTypeError => e
30
+ raise Ably::Exceptions::InvalidResponseBody, "MessagePack::UnknownExtTypeError body could not be decoded: #{e.message}. Got Base64:\n#{base64_body(body)}"
21
31
  rescue MessagePack::MalformedFormatError => e
22
- raise Ably::Exceptions::InvalidResponseBody, "Expected MessagePack response: #{e.message}"
32
+ raise Ably::Exceptions::InvalidResponseBody, "MessagePack::MalformedFormatError body could not be decoded: #{e.message}. Got Base64:\n#{base64_body(body)}"
33
+ end
34
+
35
+ def base64_body(body)
36
+ Base64.encode64(body)
37
+ rescue => err
38
+ "[#{err.message}! Could not base64 encode body: '#{body}']"
23
39
  end
24
40
  end
25
41
  end
@@ -5,6 +5,7 @@ module Ably
5
5
 
6
6
  # {Ably::Rest::Client} for this Presence object
7
7
  # @return {Ably::Rest::Client}
8
+ # @private
8
9
  attr_reader :client
9
10
 
10
11
  # {Ably::Rest::Channel} this Presence object is associated with
@@ -0,0 +1,42 @@
1
+ require 'ably/rest/push/admin'
2
+
3
+ module Ably
4
+ module Rest
5
+ # Class providing push notification functionality
6
+ class Push
7
+ include Ably::Modules::Conversions
8
+
9
+ # @private
10
+ attr_reader :client
11
+
12
+ def initialize(client)
13
+ @client = client
14
+ end
15
+
16
+ # Admin features for push notifications like managing devices and channel subscriptions
17
+ # @return [Ably::Rest::Push::Admin]
18
+ def admin
19
+ @admin ||= Admin.new(self)
20
+ end
21
+
22
+ # Activate this device for push notifications by registering with the push transport such as GCM/APNS
23
+ #
24
+ # @note This is unsupported in the Ruby library
25
+ def activate(*arg)
26
+ raise_unsupported
27
+ end
28
+
29
+ # Deactivate this device for push notifications by removing the registration with the push transport such as GCM/APNS
30
+ #
31
+ # @note This is unsupported in the Ruby library
32
+ def deactivate(*arg)
33
+ raise_unsupported
34
+ end
35
+
36
+ private
37
+ def raise_unsupported
38
+ raise Ably::Exceptions::PushNotificationsNotSupported, 'This device does not support receiving or subscribing to push notifications. All PushChannel methods are unavailable'
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,54 @@
1
+ require 'ably/rest/push/device_registrations'
2
+ require 'ably/rest/push/channel_subscriptions'
3
+
4
+ module Ably::Rest
5
+ class Push
6
+ # Class providing push notification administrative functionality
7
+ # for registering devices and attaching to channels etc.
8
+ class Admin
9
+ include Ably::Modules::Conversions
10
+
11
+ # @api private
12
+ attr_reader :client
13
+
14
+ # @api private
15
+ attr_reader :push
16
+
17
+ def initialize(push)
18
+ @push = push
19
+ @client = push.client
20
+ end
21
+
22
+ # Publish a push message directly to a single recipient
23
+ #
24
+ # @param recipient [Hash] A recipient device, client_id or raw APNS/GCM target. Refer to push documentation
25
+ # @param data [Hash] The notification payload data and fields. Refer to push documentation
26
+ #
27
+ # @return [void]
28
+ #
29
+ def publish(recipient, data)
30
+ raise ArgumentError, "Expecting a Hash object for recipient, got #{recipient.class}" unless recipient.kind_of?(Hash)
31
+ raise ArgumentError, "Recipient data is empty. You must provide recipient details" if recipient.empty?
32
+ raise ArgumentError, "Expecting a Hash object for data, got #{data.class}" unless data.kind_of?(Hash)
33
+ raise ArgumentError, "Push data field is empty. You must provide attributes for the push notification" if data.empty?
34
+
35
+ publish_data = data.merge(recipient: IdiomaticRubyWrapper(recipient))
36
+ # Co-erce to camelCase for notitication fields which are always camelCase
37
+ publish_data[:notification] = IdiomaticRubyWrapper(data[:notification]) if publish_data[:notification].kind_of?(Hash)
38
+ client.post('/push/publish', publish_data)
39
+ end
40
+
41
+ # Manage device registrations
42
+ # @return [Ably::Rest::Push::DeviceRegistrations]
43
+ def device_registrations
44
+ @device_registrations ||= DeviceRegistrations.new(self)
45
+ end
46
+
47
+ # Manage channel subscriptions for devices or clients
48
+ # @return [Ably::Rest::Push::ChannelSubscriptions]
49
+ def channel_subscriptions
50
+ @channel_subscriptions ||= ChannelSubscriptions.new(self)
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,121 @@
1
+ module Ably::Rest
2
+ class Push
3
+ # Manage push notification channel subscriptions for devices or client identifiers
4
+ class ChannelSubscriptions
5
+ include Ably::Modules::Conversions
6
+
7
+ # @api private
8
+ attr_reader :client
9
+
10
+ # @api private
11
+ attr_reader :admin
12
+
13
+ def initialize(admin)
14
+ @admin = admin
15
+ @client = admin.client
16
+ end
17
+
18
+ # List channel subscriptions filtered by optional params
19
+ #
20
+ # @param [Hash] params the filter options for the list channel subscription request. At least one of channel, client_id or device_id is required
21
+ # @option params [String] :channel filter by realtime pub/sub channel name
22
+ # @option params [String] :client_id filter by devices registered to a client identifier. If provided with device_id param, a concat operation is used so that any device with this client_id or provided device_id is returned.
23
+ # @option params [String] :device_id filter by unique device ID. If provided with client_id param, a concat operation is used so that any device with this device_id or provided client_id is returned.
24
+ # @option params [Integer] :limit maximum number of subscriptions to retrieve up to 1,000, defaults to 100
25
+ #
26
+ # @return [Ably::Models::PaginatedResult<Ably::Models::PushChannelSubscription>] Paginated list of matching {Ably::Models::PushChannelSubscription}
27
+ #
28
+ def list(params)
29
+ raise ArgumentError, "params must be a Hash" unless params.kind_of?(Hash)
30
+
31
+ if (IdiomaticRubyWrapper(params).keys & [:channel, :client_id, :device_id]).length == 0
32
+ raise ArgumentError, "at least one channel, client_id or device_id filter param must be provided"
33
+ end
34
+
35
+ params = params.clone
36
+
37
+ paginated_options = {
38
+ coerce_into: 'Ably::Models::PushChannelSubscription',
39
+ async_blocking_operations: params.delete(:async_blocking_operations),
40
+ }
41
+
42
+ response = client.get('/push/channelSubscriptions', IdiomaticRubyWrapper(params).as_json)
43
+
44
+ Ably::Models::PaginatedResult.new(response, '', client, paginated_options)
45
+ end
46
+
47
+ # List channels with at least one subscribed device
48
+ #
49
+ # @param [Hash] params the options for the list channels request
50
+ # @option params [Integer] :limit maximum number of channels to retrieve up to 1,000, defaults to 100
51
+ #
52
+ # @return [Ably::Models::PaginatedResult<String>] Paginated list of matching {Ably::Models::PushChannelSubscription}
53
+ #
54
+ def list_channels(params = {})
55
+ params = {} if params.nil?
56
+ raise ArgumentError, "params must be a Hash" unless params.kind_of?(Hash)
57
+
58
+ params = params.clone
59
+
60
+ paginated_options = {
61
+ coerce_into: 'String',
62
+ async_blocking_operations: params.delete(:async_blocking_operations),
63
+ }
64
+
65
+ response = client.get('/push/channels', IdiomaticRubyWrapper(params).as_json)
66
+
67
+ Ably::Models::PaginatedResult.new(response, '', client, paginated_options)
68
+ end
69
+
70
+ # Save push channel subscription for a device or client ID
71
+ #
72
+ # @param [Ably::Models::PushChannelSubscription,Hash] push_channel_subscription the push channel subscription details to save
73
+ #
74
+ # @return [void]
75
+ #
76
+ def save(push_channel_subscription)
77
+ push_channel_subscription_object = PushChannelSubscription(push_channel_subscription)
78
+ raise ArgumentError, "Channel is required yet is empty" if push_channel_subscription_object.channel.to_s.empty?
79
+
80
+ client.post("/push/channelSubscriptions", push_channel_subscription_object.as_json)
81
+ end
82
+
83
+ # Remove a push channel subscription
84
+ #
85
+ # @param [Ably::Models::PushChannelSubscription,Hash] push_channel_subscription the push channel subscription details to remove
86
+ #
87
+ # @return [void]
88
+ #
89
+ def remove(push_channel_subscription)
90
+ push_channel_subscription_object = PushChannelSubscription(push_channel_subscription)
91
+ raise ArgumentError, "Channel is required yet is empty" if push_channel_subscription_object.channel.to_s.empty?
92
+ if push_channel_subscription_object.client_id.to_s.empty? && push_channel_subscription_object.device_id.to_s.empty?
93
+ raise ArgumentError, "Either client_id or device_id must be present"
94
+ end
95
+
96
+ client.delete("/push/channelSubscriptions", push_channel_subscription_object.as_json)
97
+ end
98
+
99
+ # Remove all matching push channel subscriptions
100
+ #
101
+ # @param [Hash] params the filter options for the list channel subscription request. At least one of channel, client_id or device_id is required
102
+ # @option params [String] :channel filter by realtime pub/sub channel name
103
+ # @option params [String] :client_id filter by devices registered to a client identifier. If provided with device_id param, a concat operation is used so that any device with this client_id or provided device_id is returned.
104
+ # @option params [String] :device_id filter by unique device ID. If provided with client_id param, a concat operation is used so that any device with this device_id or provided client_id is returned.
105
+ #
106
+ # @return [void]
107
+ #
108
+ def remove_where(params)
109
+ raise ArgumentError, "params must be a Hash" unless params.kind_of?(Hash)
110
+
111
+ if (IdiomaticRubyWrapper(params).keys & [:channel, :client_id, :device_id]).length == 0
112
+ raise ArgumentError, "at least one channel, client_id or device_id filter param must be provided"
113
+ end
114
+
115
+ params = params.clone
116
+
117
+ client.delete("/push/channelSubscriptions", IdiomaticRubyWrapper(params).as_json)
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,103 @@
1
+ module Ably::Rest
2
+ class Push
3
+ # Manage device registrations for push notifications
4
+ class DeviceRegistrations
5
+ include Ably::Modules::Conversions
6
+
7
+ # @api private
8
+ attr_reader :client
9
+
10
+ # @api private
11
+ attr_reader :admin
12
+
13
+ def initialize(admin)
14
+ @admin = admin
15
+ @client = admin.client
16
+ end
17
+
18
+ # Get registered device by device ID
19
+ #
20
+ # @param [String, Ably::Models::DeviceDetails] device_id the device to retrieve
21
+ #
22
+ # @return [Ably::Models::DeviceDetails] Returns {Ably::Models::DeviceDetails} if a match is found else a {Ably::Exceptions::ResourceMissing} is raised
23
+ #
24
+ def get(device_id)
25
+ device_id = device_id.id if device_id.kind_of?(Ably::Models::DeviceDetails)
26
+ raise ArgumentError, "device_id must be a string or DeviceDetails object" unless device_id.kind_of?(String)
27
+
28
+ DeviceDetails(client.get("/push/deviceRegistrations/#{device_id}").body)
29
+ end
30
+
31
+ # List registered devices filtered by optional params
32
+ #
33
+ # @param [Hash] params the filter options for the list registered device request
34
+ # @option params [String] :client_id filter by devices registered to a client identifier. Cannot be used with +device_id+ param
35
+ # @option params [String] :device_id filter by unique device ID. Cannot be used with +client_id+ param
36
+ # @option params [Integer] :limit maximum number of devices to retrieve up to 1,000, defaults to 100
37
+ #
38
+ # @return [Ably::Models::PaginatedResult<Ably::Models::DeviceDetails>] Paginated list of matching {Ably::Models::DeviceDetails}
39
+ #
40
+ def list(params = {})
41
+ params = {} if params.nil?
42
+ raise ArgumentError, "params must be a Hash" unless params.kind_of?(Hash)
43
+ raise ArgumentError, "device_id filter cannot be specified alongside a client_id filter. Use one or the other" if params[:client_id] && params[:device_id]
44
+
45
+ params = params.clone
46
+
47
+ paginated_options = {
48
+ coerce_into: 'Ably::Models::DeviceDetails',
49
+ async_blocking_operations: params.delete(:async_blocking_operations),
50
+ }
51
+
52
+ response = client.get('/push/deviceRegistrations', IdiomaticRubyWrapper(params).as_json)
53
+
54
+ Ably::Models::PaginatedResult.new(response, '', client, paginated_options)
55
+ end
56
+
57
+ # Save and register device
58
+ #
59
+ # @param [Ably::Models::DeviceDetails, Hash] device the device details to save
60
+ #
61
+ # @return [void]
62
+ #
63
+ def save(device)
64
+ device_details = DeviceDetails(device)
65
+ raise ArgumentError, "Device ID is required yet is empty" if device_details.id.nil? || device_details == ''
66
+
67
+ client.put("/push/deviceRegistrations/#{device_details.id}", device_details.as_json)
68
+ end
69
+
70
+ # Remove device
71
+ #
72
+ # @param [String, Ably::Models::DeviceDetails] device_id the device to remove
73
+ #
74
+ # @return [void]
75
+ #
76
+ def remove(device_id)
77
+ device_id = device_id.id if device_id.kind_of?(Ably::Models::DeviceDetails)
78
+ raise ArgumentError, "device_id must be a string or DeviceDetails object" unless device_id.kind_of?(String)
79
+
80
+ client.delete("/push/deviceRegistrations/#{device_id}", {})
81
+ end
82
+
83
+ # Remove device matching where params
84
+ #
85
+ # @param [Hash] params the filter params for the remove request
86
+ # @option params [String] :client_id remove devices registered to a client identifier. Cannot be used with +device_id+ param
87
+ # @option params [String] :device_id remove device with this unique device ID. Cannot be used with +client_id+ param
88
+ #
89
+ # @return [void]
90
+ #
91
+ def remove_where(params = {})
92
+ filter = if params.kind_of?(Ably::Models::DeviceDetails)
93
+ { 'deviceId' => params.id }
94
+ else
95
+ raise ArgumentError, "params must be a Hash" unless params.kind_of?(Hash)
96
+ raise ArgumentError, "device_id filter cannot be specified alongside a client_id filter. Use one or the other" if params[:client_id] && params[:device_id]
97
+ IdiomaticRubyWrapper(params).as_json
98
+ end
99
+ client.delete("/push/deviceRegistrations", filter)
100
+ end
101
+ end
102
+ end
103
+ end
@@ -1,6 +1,6 @@
1
1
  module Ably
2
- VERSION = '1.0.6'
3
- PROTOCOL_VERSION = '1.0'
2
+ VERSION = '1.1.3'
3
+ PROTOCOL_VERSION = '1.1'
4
4
 
5
5
  # Allow a variant to be configured for all instances of this client library
6
6
  # such as ruby-rest-[VERSION]
@@ -13,4 +13,9 @@ module Ably
13
13
  def self.lib_variant
14
14
  @lib_variant
15
15
  end
16
+
17
+ # @api private
18
+ def self.major_minor_version_numeric
19
+ VERSION.gsub(/\.\d+$/, '').to_f
20
+ end
16
21
  end
@@ -607,16 +607,17 @@ describe Ably::Realtime::Auth, :event_machine do
607
607
 
608
608
  context 'when auth fails' do
609
609
  let(:client_options) { default_options.merge(auth_callback: basic_token_cb, log_level: :none) }
610
+ let!(:token_string) { client.rest_client.auth.request_token.token }
610
611
 
611
612
  it 'transitions the connection state to the FAILED state (#RSA15c, #RTC8a2, #RTC8a3)' do
612
613
  connection_failed = false
613
614
 
614
615
  client.connection.once(:connected) do
615
- client.auth.authorize(nil, auth_callback: lambda { |token_params| 'invalid.token:will.cause.failure' }).tap do |deferrable|
616
+ client.auth.authorize(nil, auth_callback: lambda { |token_params| "#{app_id}.invalid.token.will.cause.failure" }).tap do |deferrable|
616
617
  deferrable.errback do |error|
617
618
  EventMachine.add_timer(0.2) do
618
619
  expect(connection_failed).to eql(true)
619
- expect(error.message).to match(/Invalid accessToken/i)
620
+ expect(error.message).to match(/invalid.*accessToken/i)
620
621
  expect(error.code).to eql(40005)
621
622
  stop_reactor
622
623
  end
@@ -626,7 +627,7 @@ describe Ably::Realtime::Auth, :event_machine do
626
627
  end
627
628
 
628
629
  client.connection.once(:failed) do
629
- expect(client.connection.error_reason.message).to match(/Invalid accessToken/i)
630
+ expect(client.connection.error_reason.message).to match(/invalid.*accessToken/i)
630
631
  expect(client.connection.error_reason.code).to eql(40005)
631
632
  connection_failed = true
632
633
  end
@@ -660,17 +661,19 @@ describe Ably::Realtime::Auth, :event_machine do
660
661
  client.connection.once(:disconnected) { raise 'Upgrade does not require a disconnect' }
661
662
 
662
663
  channel = client.channels.get('foo')
663
- channel.publish('not-allowed').errback do |error|
664
- expect(error.code).to eql(40160)
665
- expect(error.message).to match(/permission denied/)
664
+ channel.attach do
665
+ channel.publish('not-allowed').errback do |error|
666
+ expect(error.code).to eql(40160)
667
+ expect(error.message).to match(/permission denied/)
666
668
 
667
- client.auth.authorize(nil, auth_callback: upgraded_token_cb)
668
- client.connection.once(:update) do
669
- expect(client.connection.error_reason).to be_nil
670
- channel.subscribe('now-allowed') do |message|
671
- stop_reactor
669
+ client.auth.authorize(nil, auth_callback: upgraded_token_cb)
670
+ client.connection.once(:update) do
671
+ expect(client.connection.error_reason).to be_nil
672
+ channel.subscribe('now-allowed') do |message|
673
+ stop_reactor
674
+ end
675
+ channel.publish 'now-allowed'
672
676
  end
673
- channel.publish 'now-allowed'
674
677
  end
675
678
  end
676
679
  end
@@ -749,11 +752,8 @@ describe Ably::Realtime::Auth, :event_machine do
749
752
  end
750
753
 
751
754
  context 'when received' do
752
- # Ably in all environments other than locla will send AUTH 30 seconds before expiry
753
- # We set the TTL to 33s and wait (3s window)
754
- # In local env, that window is 5 seconds instead of 30 seconds
755
- let(:local_offset) { ENV['ABLY_ENV'] == 'local' ? 25 : 0 }
756
- let(:client_options) { default_options.merge(use_token_auth: :true, default_token_params: { ttl: 33 - local_offset }) }
755
+ # Ably will send AUTH 30 seconds before expiry
756
+ let(:client_options) { default_options.merge(use_token_auth: :true, default_token_params: { ttl: 33 }) }
757
757
 
758
758
  it 'should immediately start a new authentication process (#RTN22)' do
759
759
  client.connection.once(:connected) do
@@ -1031,5 +1031,233 @@ describe Ably::Realtime::Auth, :event_machine do
1031
1031
  end
1032
1032
  end
1033
1033
  end
1034
+
1035
+ context 'when using JWT' do
1036
+ let(:auth_url) { 'https://echo.ably.io/createJWT' }
1037
+ let(:auth_params) { { keyName: key_name, keySecret: key_secret } }
1038
+ let(:channel_name) { "test_JWT_#{random_str}" }
1039
+ let(:message_name) { 'message_JWT' }
1040
+
1041
+ # RSA8g
1042
+ context 'when using auth_url' do
1043
+ let(:client_options) { default_options.merge(auth_url: auth_url, auth_params: auth_params) }
1044
+
1045
+ context 'when credentials are valid' do
1046
+ it 'client successfully fetches a channel and publishes a message' do
1047
+ channel = client.channels.get(channel_name)
1048
+ channel.subscribe do |message|
1049
+ expect(message.name).to eql(message_name)
1050
+ stop_reactor
1051
+ end
1052
+ channel.publish message_name
1053
+ end
1054
+ end
1055
+
1056
+ context 'when credentials are wrong' do
1057
+ let(:auth_params) { { keyName: key_name, keySecret: 'invalid' } }
1058
+
1059
+ it 'disconnected includes and invalid signature message' do
1060
+ client.connection.once(:disconnected) do |state_change|
1061
+ expect(state_change.reason.message.match(/signature verification failed/i)).to_not be_nil
1062
+ expect(state_change.reason.code).to eql(40144)
1063
+ stop_reactor
1064
+ end
1065
+ client.connect
1066
+ end
1067
+ end
1068
+
1069
+ context 'when token is expired' do
1070
+ let(:token_duration) { 5 }
1071
+ let(:auth_params) { { keyName: key_name, keySecret: key_secret, expiresIn: token_duration } }
1072
+ it 'receives a 40142 error from the server' do
1073
+ client.connection.once(:connected) do
1074
+ client.connection.once(:disconnected) do |state_change|
1075
+ expect(state_change.reason).to be_a(Ably::Models::ErrorInfo)
1076
+ expect(state_change.reason.message).to match(/(expire)/i)
1077
+ expect(state_change.reason.code).to eql(40142)
1078
+ stop_reactor
1079
+ end
1080
+ end
1081
+ end
1082
+ end
1083
+ end
1084
+
1085
+ # RSA8g
1086
+ context 'when using auth_callback' do
1087
+ let(:token_callback) do
1088
+ lambda do |token_params|
1089
+ Ably::Rest::Client.new(default_options).auth.request_token({}, { auth_url: auth_url, auth_params: auth_params }).token
1090
+ end
1091
+ end
1092
+ let(:client_options) { default_options.merge(auth_callback: token_callback) }
1093
+ WebMock.allow_net_connect!
1094
+ WebMock.disable!
1095
+ context 'when credentials are valid' do
1096
+
1097
+ it 'authentication succeeds and client can post a message' do
1098
+ channel = client.channels.get(channel_name)
1099
+ channel.subscribe do |message|
1100
+ expect(message.name).to eql(message_name)
1101
+ stop_reactor
1102
+ end
1103
+ channel.publish(message_name) do
1104
+ # assert_requested :get, Addressable::Template.new("#{auth_url}{?keyName,keySecret}")
1105
+ end
1106
+ end
1107
+ end
1108
+
1109
+ context 'when credentials are invalid' do
1110
+ let(:auth_params) { { keyName: key_name, keySecret: 'invalid' } }
1111
+
1112
+ it 'authentication fails and reason for disconnection is invalid signature' do
1113
+ client.connection.once(:disconnected) do |state_change|
1114
+ expect(state_change.reason.message.match(/signature verification failed/i)).to_not be_nil
1115
+ expect(state_change.reason.code).to eql(40144)
1116
+ stop_reactor
1117
+ end
1118
+ client.connect
1119
+ end
1120
+ end
1121
+ end
1122
+
1123
+ context 'when the client is initialized with ClientOptions and the token is a JWT token' do
1124
+ let(:client_options) { { token: token, environment: environment, protocol: protocol } }
1125
+
1126
+ context 'when credentials are valid' do
1127
+ let(:token) { Faraday.get("#{auth_url}?keyName=#{key_name}&keySecret=#{key_secret}").body }
1128
+
1129
+ it 'posts successfully to a channel' do
1130
+ channel = client.channels.get(channel_name)
1131
+ channel.subscribe do |message|
1132
+ expect(message.name).to eql(message_name)
1133
+ stop_reactor
1134
+ end
1135
+ channel.publish(message_name)
1136
+ end
1137
+ end
1138
+
1139
+ context 'when credentials are invalid' do
1140
+ let(:key_secret) { 'invalid' }
1141
+ let(:token) { Faraday.get("#{auth_url}?keyName=#{key_name}&keySecret=#{key_secret}").body }
1142
+ let(:client_options) { { token: token, environment: environment, protocol: protocol, log_level: :none } }
1143
+
1144
+ it 'fails with an invalid signature error' do
1145
+ client.connection.once(:disconnected) do |state_change|
1146
+ expect(state_change.reason.message.match(/signature verification failed/i)).to_not be_nil
1147
+ expect(state_change.reason.code).to eql(40144)
1148
+ stop_reactor
1149
+ end
1150
+ client.connect
1151
+ end
1152
+ end
1153
+ end
1154
+
1155
+ context 'when JWT token expires' do
1156
+ before do
1157
+ stub_const 'Ably::Models::TokenDetails::TOKEN_EXPIRY_BUFFER', 0 # allow token to be used even if about to expire
1158
+ stub_const 'Ably::Auth::TOKEN_DEFAULTS', Ably::Auth::TOKEN_DEFAULTS.merge(renew_token_buffer: 0) # Ensure tokens issued expire immediately after issue
1159
+ end
1160
+ let(:token_callback) do
1161
+ lambda do |token_params|
1162
+ # Ably in all environments other than production will send AUTH 5 seconds before expiry, so
1163
+ # we generate a JWT that expires in 5s so that the window for Realtime to send has passed
1164
+ tokenResponse = Faraday.get "#{auth_url}?keyName=#{key_name}&keySecret=#{key_secret}&expiresIn=5"
1165
+ tokenResponse.body
1166
+ end
1167
+ end
1168
+ let(:client_options) { default_options.merge(use_token_auth: true, auth_callback: token_callback) }
1169
+
1170
+ # RTC8a
1171
+ it 'client disconnects, a new token is requested via auth_callback and the client gets reconnected' do
1172
+ client.connection.once(:connected) do
1173
+ original_token = auth.current_token_details
1174
+ original_conn_id = client.connection.id
1175
+
1176
+ client.connection.once(:disconnected) do |state_change|
1177
+ expect(state_change.reason.code).to eql(40142)
1178
+
1179
+ client.connection.once(:connected) do
1180
+ expect(original_token).to_not eql(auth.current_token_details)
1181
+ expect(original_conn_id).to eql(client.connection.id)
1182
+ stop_reactor
1183
+ end
1184
+ end
1185
+ end
1186
+ end
1187
+
1188
+ context 'and an AUTH procol message is received' do
1189
+ let(:token_callback) do
1190
+ lambda do |token_params|
1191
+ # Ably in all environments other than local will send AUTH 30 seconds before expiry
1192
+ # We set the TTL to 35s so there's room to receive an AUTH protocol message
1193
+ tokenResponse = Faraday.get "#{auth_url}?keyName=#{key_name}&keySecret=#{key_secret}&expiresIn=35"
1194
+ tokenResponse.body
1195
+ end
1196
+ end
1197
+
1198
+ # RTC8a, RTC8a4
1199
+ it 'client reauths correctly without going through a disconnection' do
1200
+ client.connection.once(:connected) do
1201
+ original_token = client.auth.current_token_details
1202
+ received_auth = false
1203
+
1204
+ client.connection.__incoming_protocol_msgbus__.subscribe(:protocol_message) do |protocol_message|
1205
+ received_auth = true if protocol_message.action == :auth
1206
+ end
1207
+
1208
+ client.connection.once(:update) do
1209
+ expect(received_auth).to be_truthy
1210
+ expect(original_token).to_not eql(client.auth.current_token_details)
1211
+ stop_reactor
1212
+ end
1213
+ end
1214
+ end
1215
+ end
1216
+ end
1217
+
1218
+ context 'when the JWT token request includes a client_id' do
1219
+ let(:client_id) { random_str }
1220
+ let(:auth_callback) do
1221
+ lambda do |token_params|
1222
+ Faraday.get("#{auth_url}?keyName=#{key_name}&keySecret=#{key_secret}&client_id=#{client_id}").body
1223
+ end
1224
+ end
1225
+ let(:client_options) { default_options.merge(auth_callback: auth_callback) }
1226
+
1227
+ it 'the client_id is the same that was specified in the auth_callback that generated the JWT token' do
1228
+ client.connection.once(:connected) do
1229
+ expect(client.auth.client_id).to eql(client_id)
1230
+ stop_reactor
1231
+ end
1232
+ end
1233
+ end
1234
+
1235
+ context 'when the JWT token request includes a subscribe-only capability' do
1236
+ let(:channel_with_publish_permissions) { "test_JWT_with_publish_#{random_str}" }
1237
+ let(:basic_capability) { JSON.dump(channel_name => ['subscribe'], channel_with_publish_permissions => ['publish']) }
1238
+ let(:auth_callback) do
1239
+ lambda do |token_params|
1240
+ Faraday.get("#{auth_url}?keyName=#{key_name}&keySecret=#{key_secret}&capability=#{URI.escape(basic_capability)}").body
1241
+ end
1242
+ end
1243
+ let(:client_options) { default_options.merge(auth_callback: auth_callback, log_level: :error) }
1244
+
1245
+ it 'client fails to publish to a channel with subscribe-only capability and publishes successfully on a channel with permissions' do
1246
+ client.connection.once(:connected) do
1247
+ forbidden_channel = client.channels.get(channel_name)
1248
+ allowed_channel = client.channels.get(channel_with_publish_permissions)
1249
+ forbidden_channel.publish('not-allowed').errback do |error|
1250
+ expect(error.code).to eql(40160)
1251
+ expect(error.message).to match(/permission denied/)
1252
+
1253
+ allowed_channel.publish(message_name) do |message|
1254
+ expect(message.name).to eql(message_name)
1255
+ stop_reactor
1256
+ end
1257
+ end
1258
+ end
1259
+ end
1260
+ end
1261
+ end
1034
1262
  end
1035
1263
  end