ably 0.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.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +4 -0
  3. data/Gemfile +4 -0
  4. data/LICENSE.txt +22 -0
  5. data/README.md +103 -0
  6. data/Rakefile +4 -0
  7. data/ably.gemspec +32 -0
  8. data/lib/ably.rb +11 -0
  9. data/lib/ably/auth.rb +381 -0
  10. data/lib/ably/exceptions.rb +16 -0
  11. data/lib/ably/realtime.rb +38 -0
  12. data/lib/ably/realtime/callbacks.rb +15 -0
  13. data/lib/ably/realtime/channel.rb +51 -0
  14. data/lib/ably/realtime/client.rb +82 -0
  15. data/lib/ably/realtime/connection.rb +61 -0
  16. data/lib/ably/rest.rb +15 -0
  17. data/lib/ably/rest/channel.rb +58 -0
  18. data/lib/ably/rest/client.rb +194 -0
  19. data/lib/ably/rest/middleware/exceptions.rb +42 -0
  20. data/lib/ably/rest/middleware/external_exceptions.rb +26 -0
  21. data/lib/ably/rest/middleware/parse_json.rb +15 -0
  22. data/lib/ably/rest/paged_resource.rb +107 -0
  23. data/lib/ably/rest/presence.rb +44 -0
  24. data/lib/ably/support.rb +14 -0
  25. data/lib/ably/token.rb +55 -0
  26. data/lib/ably/version.rb +3 -0
  27. data/spec/acceptance/realtime_client_spec.rb +12 -0
  28. data/spec/acceptance/rest/auth_spec.rb +441 -0
  29. data/spec/acceptance/rest/base_spec.rb +113 -0
  30. data/spec/acceptance/rest/channel_spec.rb +68 -0
  31. data/spec/acceptance/rest/presence_spec.rb +22 -0
  32. data/spec/acceptance/rest/stats_spec.rb +57 -0
  33. data/spec/acceptance/rest/time_spec.rb +14 -0
  34. data/spec/spec_helper.rb +31 -0
  35. data/spec/support/api_helper.rb +41 -0
  36. data/spec/support/test_app.rb +77 -0
  37. data/spec/unit/auth.rb +9 -0
  38. data/spec/unit/realtime_spec.rb +9 -0
  39. data/spec/unit/rest_spec.rb +99 -0
  40. data/spec/unit/token_spec.rb +90 -0
  41. metadata +240 -0
@@ -0,0 +1,42 @@
1
+ require "json"
2
+
3
+ module Ably
4
+ module Rest
5
+ module Middleware
6
+ # HTTP exceptions raised by Ably due to an error status code
7
+ # Ably returns JSON error codes and messages so include this if possible in the exception messages
8
+ class Exceptions < Faraday::Response::Middleware
9
+ def call(env)
10
+ @app.call(env).on_complete do
11
+ if env[:status] >= 400
12
+ error_status_code = env[:status]
13
+ error_code = nil
14
+
15
+ begin
16
+ error = JSON.parse(env[:body])['error']
17
+ error_status_code = error['statusCode'].to_i if error['statusCode']
18
+ error_code = error['code'].to_i if error['code']
19
+
20
+ if error
21
+ message = "#{error['message']} (status: #{error_status_code}, code: #{error_code})"
22
+ else
23
+ message = env[:body]
24
+ end
25
+ rescue JSON::ParserError
26
+ message = env[:body]
27
+ end
28
+
29
+ message = "Unknown server error" if message.to_s.strip == ''
30
+
31
+ if env[:status] >= 500
32
+ raise Ably::ServerError, message
33
+ else
34
+ raise Ably::InvalidRequest.new(message, status: error_status_code, code: error_code)
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,26 @@
1
+ require "json"
2
+
3
+ module Ably
4
+ module Rest
5
+ module Middleware
6
+ # HTTP exceptions raised due to a status code error on a 3rd party site
7
+ # Used by auth calls
8
+ class ExternalExceptions < Faraday::Response::Middleware
9
+ def call(env)
10
+ @app.call(env).on_complete do
11
+ if env[:status] >= 400
12
+ error_status_code = env[:status]
13
+ message = "Error #{error_status_code}: #{(env[:body] || '')[0...200]}"
14
+
15
+ if error_status_code >= 500
16
+ raise Ably::ServerError, message
17
+ else
18
+ raise Ably::InvalidRequest, message
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,15 @@
1
+ require "json"
2
+
3
+ module Ably
4
+ module Rest
5
+ module Middleware
6
+ class ParseJson < Faraday::Response::Middleware
7
+ def parse(body)
8
+ JSON.parse(body, symbolize_names: true)
9
+ rescue JSON::ParserError => e
10
+ raise InvalidResponseBody, "Expected JSON response. #{e.message}"
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,107 @@
1
+ module Ably
2
+ module Rest
3
+ # Wraps any Ably HTTP response that supports paging and automatically provides methdos to iterated through
4
+ # the array of resources using {#first}, {#next}, {#last?} and {#first?}
5
+ #
6
+ # Paging information is provided by Ably in the LINK HTTP headers
7
+ class PagedResource
8
+ include Enumerable
9
+
10
+ # @param [Faraday::Response] http_response Initial HTTP response from an Ably request to a paged resource
11
+ # @param [String] base_url Base URL for request that generated the http_response so that subsequent paged requests can be made
12
+ # @param [Ably::Rest::Client] client {Ably::Client} used to make the request to Ably
13
+ #
14
+ # @return [Ably::Rest::PagedResource]
15
+ def initialize(http_response, base_url, client)
16
+ @http_response = http_response
17
+ @body = http_response.body
18
+ @client = client
19
+ @base_url = "#{base_url.gsub(%r{/[^/]*$}, '')}/"
20
+ end
21
+
22
+ # Retrieve the first page of results
23
+ #
24
+ # @return [Ably::Rest::PagedResource]
25
+ def first
26
+ PagedResource.new(@client.get(pagination_url('first')), @base_url, @client)
27
+ end
28
+
29
+ # Retrieve the next page of results
30
+ #
31
+ # @return [Ably::Rest::PagedResource]
32
+ def next
33
+ PagedResource.new(@client.get(pagination_url('next')), @base_url, @client)
34
+ end
35
+
36
+ # True if this is the last page in the paged resource set
37
+ #
38
+ # @return [Boolean]
39
+ def last?
40
+ !supports_pagination? ||
41
+ pagination_header('next').nil?
42
+ end
43
+
44
+ # True if this is the first page in the paged resource set
45
+ #
46
+ # @return [Boolean]
47
+ def first?
48
+ !supports_pagination? ||
49
+ pagination_header('first') == pagination_header('current')
50
+ end
51
+
52
+ # True if the HTTP response supports paging with the expected LINK HTTP headers
53
+ #
54
+ # @return [Boolean]
55
+ def supports_pagination?
56
+ !pagination_headers.empty?
57
+ end
58
+
59
+ # Standard Array accessor method
60
+ def [](index)
61
+ @body[index]
62
+ end
63
+
64
+ # Returns number of items within this page, not the total number of items in the entire paged resource set
65
+ def length
66
+ @body.length
67
+ end
68
+ alias_method :count, :length
69
+ alias_method :size, :length
70
+
71
+ # Method ensuring this {Ably::Rest::PagedResource} is {http://ruby-doc.org/core-2.1.3/Enumerable.html Enumerable}
72
+ def each(&block)
73
+ @body.each do |item|
74
+ if block_given?
75
+ block.call item
76
+ else
77
+ yield item
78
+ end
79
+ end
80
+ end
81
+
82
+ private
83
+ def pagination_headers
84
+ link_regex = %r{<(?<url>[^>]+)>; rel="(?<rel>[^"]+)"}
85
+ @pagination_headers ||= @http_response.headers['link'].scan(link_regex).inject({}) do |hash, val_array|
86
+ url, rel = val_array
87
+ hash[rel] = url
88
+ hash
89
+ end
90
+ end
91
+
92
+ def pagination_header(id)
93
+ pagination_headers[id]
94
+ end
95
+
96
+ def pagination_url(id)
97
+ raise InvalidPageError, "Paging heading link #{id} does not exist" unless pagination_header(id)
98
+
99
+ if pagination_header(id).match(%r{^\./})
100
+ "#{@base_url}#{pagination_header(id)[2..-1]}"
101
+ else
102
+ pagination_header[id]
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,44 @@
1
+ module Ably
2
+ module Rest
3
+ class Presence
4
+ attr_reader :client, :channel
5
+
6
+ # Initialize a new Presence object
7
+ #
8
+ # @param client [Ably::Rest::Client]
9
+ # @param channel [Channel] The channel object
10
+ def initialize(client, channel)
11
+ @client = client
12
+ @channel = channel
13
+ end
14
+
15
+ # Obtain the set of members currently present for a channel
16
+ #
17
+ # @return [PagedResource] An Array of presence-message Hash objects that supports paging (next, first)
18
+ def get(options = {})
19
+ response = client.get(base_path, options)
20
+ PagedResource.new(response, base_path, client)
21
+ end
22
+
23
+ # Return the presence messages history for the channel
24
+ #
25
+ # Options:
26
+ # - start: Time or millisecond since epoch
27
+ # - end: Time or millisecond since epoch
28
+ # - direction: :forwards or :backwards (default is :backwards)
29
+ # - limit: Maximum number of messages to retrieve up to 10,000
30
+ #
31
+ # @return [PagedResource] An Array of presence-message Hash objects that supports paging (next, first)
32
+ def history(options = {})
33
+ url = "#{base_path}/history"
34
+ response = client.get(url, options)
35
+ PagedResource.new(response, url, client)
36
+ end
37
+
38
+ private
39
+ def base_path
40
+ "/channels/#{CGI.escape(channel.name)}/presence"
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,14 @@
1
+ require "base64"
2
+
3
+ module Ably
4
+ module Support
5
+ protected
6
+ def encode64(text)
7
+ Base64.encode64(text).gsub("\n", '')
8
+ end
9
+
10
+ def user_agent
11
+ "Ably Ruby client #{Ably::VERSION} (https://ably.io)"
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,55 @@
1
+ module Ably
2
+ class Token
3
+ DEFAULTS = {
4
+ capability: { "*" => ["*"] },
5
+ ttl: 60 * 60 # 1 hour
6
+ }
7
+
8
+ TOKEN_EXPIRY_BUFFER = 5
9
+
10
+ def initialize(attributes)
11
+ @attributes = attributes.freeze
12
+ end
13
+
14
+ def id
15
+ attributes.fetch(:id)
16
+ end
17
+
18
+ def key_id
19
+ attributes.fetch(:key)
20
+ end
21
+
22
+ def issued_at
23
+ Time.at(attributes.fetch(:issued_at))
24
+ end
25
+
26
+ def expires_at
27
+ Time.at(attributes.fetch(:expires))
28
+ end
29
+
30
+ def capability
31
+ attributes.fetch(:capability)
32
+ end
33
+
34
+ def client_id
35
+ attributes.fetch(:client_id)
36
+ end
37
+
38
+ def nonce
39
+ attributes.fetch(:nonce)
40
+ end
41
+
42
+ def ==(other)
43
+ other.class == self.class &&
44
+ attributes == other.attributes
45
+ end
46
+
47
+ # Returns true if token is expired or about to expire
48
+ def expired?
49
+ expires_at < Time.now + TOKEN_EXPIRY_BUFFER
50
+ end
51
+
52
+ protected
53
+ attr_reader :attributes
54
+ end
55
+ end
@@ -0,0 +1,3 @@
1
+ module Ably
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,12 @@
1
+ require "spec_helper"
2
+
3
+ describe "Using the Realtime client" do
4
+ describe "initializing the client" do
5
+ it "should disallow an invalid key" do
6
+ expect { Ably::Realtime::Client.new({}) }.to raise_error(ArgumentError, /api_key is missing/)
7
+ expect { Ably::Realtime::Client.new(api_key: 'invalid') }.to raise_error(ArgumentError, /api_key is invalid/)
8
+ expect { Ably::Realtime::Client.new(api_key: 'invalid:asdad') }.to raise_error(ArgumentError, /api_key is invalid/)
9
+ expect { Ably::Realtime::Client.new(api_key: 'appid.keyuid:keysecret') }.to_not raise_error
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,441 @@
1
+ require "spec_helper"
2
+ require "securerandom"
3
+
4
+ describe "REST" do
5
+ let(:client) do
6
+ Ably::Rest::Client.new(api_key: api_key, environment: environment)
7
+ end
8
+ let(:auth) { client.auth }
9
+
10
+ describe "#request_token" do
11
+ let(:ttl) { 30 * 60 }
12
+ let(:capability) { { :foo => ["publish"] } }
13
+
14
+ it "returns the requested token" do
15
+ actual_token = auth.request_token(
16
+ ttl: ttl,
17
+ capability: capability
18
+ )
19
+
20
+ expect(actual_token.id).to match(/^#{app_id}\.[\w-]+$/)
21
+ expect(actual_token.key_id).to match(/^#{key_id}$/)
22
+ expect(actual_token.issued_at).to be_within(2).of(Time.now)
23
+ expect(actual_token.expires_at).to be_within(2).of(Time.now + ttl)
24
+ end
25
+
26
+ %w(client_id ttl timestamp capability nonce).each do |option|
27
+ context "option :#{option}", webmock: true do
28
+ let(:random) { SecureRandom.hex }
29
+ let(:options) { { option.to_sym => random } }
30
+
31
+ let(:token_response) { { access_token: {} }.to_json }
32
+ let!(:request_token_stub) do
33
+ stub_request(:post, "#{client.endpoint}/keys/#{key_id}/requestToken").
34
+ with(:body => hash_including({ option => random })).
35
+ to_return(:status => 201, :body => token_response, :headers => { 'Content-Type' => 'application/json' })
36
+ end
37
+
38
+ before { auth.request_token options }
39
+
40
+ it 'overrides default' do
41
+ expect(request_token_stub).to have_been_requested
42
+ end
43
+ end
44
+ end
45
+
46
+ context 'with :key_id & :key_secret options', webmock: true do
47
+ let(:key_id) { SecureRandom.hex }
48
+ let(:key_secret) { SecureRandom.hex }
49
+ let(:nonce) { SecureRandom.hex }
50
+ let(:token_options) { { key_id: key_id, key_secret: key_secret, nonce: nonce, timestamp: Time.now.to_i } }
51
+ let(:token_request) { auth.create_token_request(token_options) }
52
+ let(:mac) do
53
+ hmac_for(token_request, key_secret)
54
+ end
55
+
56
+ let(:token_response) { { access_token: {} }.to_json }
57
+ let!(:request_token_stub) do
58
+ stub_request(:post, "#{client.endpoint}/keys/#{key_id}/requestToken").
59
+ with(:body => hash_including({ 'mac' => mac })).
60
+ to_return(:status => 201, :body => token_response, :headers => { 'Content-Type' => 'application/json' })
61
+ end
62
+
63
+ let!(:token) { auth.request_token(token_options) }
64
+
65
+ specify 'key_id is used in request and signing uses key_secret' do
66
+ expect(request_token_stub).to have_been_requested
67
+ end
68
+ end
69
+
70
+ context "with :query_time option" do
71
+ let(:options) { { query_time: true } }
72
+
73
+ it 'queries the server for the time' do
74
+ expect(client).to receive(:time).and_call_original
75
+ auth.request_token(options)
76
+ end
77
+ end
78
+
79
+ context "without :query_time option" do
80
+ let(:options) { { query_time: false } }
81
+
82
+ it 'queries the server for the time' do
83
+ expect(client).to_not receive(:time)
84
+ auth.request_token(options)
85
+ end
86
+ end
87
+
88
+ context 'with :auth_url option', webmock: true do
89
+ let(:auth_url) { 'https://www.fictitious.com/get_token' }
90
+ let(:token_request) { { id: key_id }.to_json }
91
+ let(:token_response) { { access_token: { } }.to_json }
92
+ let(:query_params) { nil }
93
+ let(:headers) { nil }
94
+ let(:auth_method) { :get }
95
+ let(:options) do
96
+ {
97
+ auth_url: auth_url,
98
+ auth_params: query_params,
99
+ auth_headers: headers,
100
+ auth_method: auth_method
101
+ }
102
+ end
103
+
104
+ let!(:auth_url_request_stub) do
105
+ stub = stub_request(auth_method, auth_url)
106
+ stub.with(:query => hash_including(query_params)) unless query_params.nil?
107
+ stub.with(:header => hash_including(headers)) unless headers.nil?
108
+ stub.to_return(:status => 201, :body => token_request, :headers => { 'Content-Type' => 'application/json' })
109
+ end
110
+
111
+ let!(:request_token_stub) do
112
+ stub_request(:post, "#{client.endpoint}/keys/#{key_id}/requestToken").
113
+ with(:body => hash_including({ 'id' => key_id })).
114
+ to_return(:status => 201, :body => token_response, :headers => { 'Content-Type' => 'application/json' })
115
+ end
116
+
117
+ context 'valid' do
118
+ before { auth.request_token options }
119
+
120
+ context 'and default options' do
121
+ it 'requests a token from :auth_url' do
122
+ expect(request_token_stub).to have_been_requested
123
+ expect(auth_url_request_stub).to have_been_requested
124
+ end
125
+ end
126
+
127
+ context 'with params' do
128
+ let(:query_params) { { 'key' => SecureRandom.hex } }
129
+ it 'requests a token from :auth_url' do
130
+ expect(request_token_stub).to have_been_requested
131
+ expect(auth_url_request_stub).to have_been_requested
132
+ end
133
+ end
134
+
135
+ context 'with headers' do
136
+ let(:headers) { { 'key' => SecureRandom.hex } }
137
+ it 'requests a token from :auth_url' do
138
+ expect(request_token_stub).to have_been_requested
139
+ expect(auth_url_request_stub).to have_been_requested
140
+ end
141
+ end
142
+
143
+ context 'with POST' do
144
+ let(:auth_method) { :post }
145
+ it 'requests a token from :auth_url' do
146
+ expect(request_token_stub).to have_been_requested
147
+ expect(auth_url_request_stub).to have_been_requested
148
+ end
149
+ end
150
+ end
151
+
152
+ context 'when response is invalid' do
153
+ context '500' do
154
+ let!(:auth_url_request_stub) do
155
+ stub_request(auth_method, auth_url).to_return(:status => 500)
156
+ end
157
+
158
+ it 'raises ServerError' do
159
+ expect { auth.request_token options }.to raise_error(Ably::ServerError)
160
+ end
161
+ end
162
+
163
+ context 'XML' do
164
+ let!(:auth_url_request_stub) do
165
+ stub_request(auth_method, auth_url).
166
+ to_return(:status => 201, :body => '<xml></xml>', :headers => { 'Content-Type' => 'application/xml' })
167
+ end
168
+
169
+ it 'raises InvalidResponseBody' do
170
+ expect { auth.request_token options }.to raise_error(Ably::InvalidResponseBody)
171
+ end
172
+ end
173
+ end
174
+ end
175
+
176
+ context 'with auth_block' do
177
+ let(:client_id) { SecureRandom.hex }
178
+ let(:options) { { client_id: client_id } }
179
+ let!(:token) do
180
+ auth.request_token(options) do |block_options|
181
+ @block_called = true
182
+ @block_options = block_options
183
+ auth.create_token_request(client_id: client_id)
184
+ end
185
+ end
186
+
187
+ it 'calls the block' do
188
+ expect(@block_called).to eql(true)
189
+ expect(@block_options).to include(options)
190
+ end
191
+
192
+ it 'uses the token request when requesting a new token' do
193
+ expect(token.client_id).to eql(client_id)
194
+ end
195
+ end
196
+ end
197
+
198
+ describe '#authorise' do
199
+ context 'with no previous authorisation' do
200
+ let(:request_options) do
201
+ { auth_url: 'http://somewhere.com/' }
202
+ end
203
+
204
+ it 'has no current_token' do
205
+ expect(auth.current_token).to be_nil
206
+ end
207
+
208
+ it 'passes all options to request_token' do
209
+ expect(auth).to receive(:request_token).with(request_options)
210
+ auth.authorise request_options
211
+ end
212
+
213
+ it 'returns a valid token' do
214
+ expect(auth.authorise).to be_a(Ably::Token)
215
+ end
216
+
217
+ it 'issues a new token if option :force => true' do
218
+ expect { auth.authorise(force: true) }.to change { auth.current_token }
219
+ end
220
+ end
221
+
222
+ context 'with previous authorisation' do
223
+ before do
224
+ auth.authorise
225
+ expect(auth.current_token).to_not be_expired
226
+ end
227
+
228
+ it 'does not request a token if token is not expired' do
229
+ expect(auth).to_not receive(:request_token)
230
+ auth.authorise
231
+ end
232
+
233
+ it 'requests a new token if token is expired' do
234
+ allow(auth.current_token).to receive(:expired?).and_return(true)
235
+ expect(auth).to receive(:request_token)
236
+ expect { auth.authorise }.to change { auth.current_token }
237
+ end
238
+
239
+ it 'issues a new token if option :force => true' do
240
+ expect { auth.authorise(force: true) }.to change { auth.current_token }
241
+ end
242
+ end
243
+ end
244
+
245
+ describe "#create_token_request" do
246
+ let(:ttl) { 60 * 60 }
247
+ let(:capability) { { :foo => ["publish"] } }
248
+ let(:options) { Hash.new }
249
+ subject { auth.create_token_request(options) }
250
+
251
+ it "uses the key ID from the client" do
252
+ expect(subject[:id]).to eql(key_id)
253
+ end
254
+
255
+ it "uses the default TTL" do
256
+ expect(subject[:ttl]).to eql(Ably::Token::DEFAULTS[:ttl])
257
+ end
258
+
259
+ it "uses the default capability" do
260
+ expect(subject[:capability]).to eql(Ably::Token::DEFAULTS[:capability].to_json)
261
+ end
262
+
263
+ it "has a unique nonce" do
264
+ unique_nonces = 100.times.map { auth.create_token_request[:nonce] }
265
+ expect(unique_nonces.uniq.length).to eql(100)
266
+ end
267
+
268
+ it "has a nonce of at least 16 characters" do
269
+ expect(subject[:nonce].length).to be >= 16
270
+ end
271
+
272
+ %w(ttl capability nonce timestamp client_id).each do |attribute|
273
+ context "with option :#{attribute}" do
274
+ let(:option_value) { SecureRandom.hex }
275
+ before do
276
+ options[attribute.to_sym] = option_value
277
+ end
278
+ it "overrides default" do
279
+ expect(subject[attribute.to_sym]).to eql(option_value)
280
+ end
281
+ end
282
+ end
283
+
284
+ context "invalid attributes" do
285
+ let(:options) { { nonce: 'valid', is_not_used_by_token_request: 'invalid' } }
286
+ specify 'are ignored' do
287
+ expect(subject.keys).to_not include(:is_not_used_by_token_request)
288
+ expect(subject.keys).to include(:nonce)
289
+ expect(subject[:nonce]).to eql('valid')
290
+ end
291
+ end
292
+
293
+ context "missing key ID and/or secret" do
294
+ let(:client) { Ably::Rest::Client.new(auth_url: 'http://example.com') }
295
+
296
+ it "should raise an exception if key secret is missing" do
297
+ expect { auth.create_token_request(key_id: 'id') }.to raise_error Ably::TokenRequestError
298
+ end
299
+
300
+ it "should raise an exception if key id is missing" do
301
+ expect { auth.create_token_request(key_secret: 'secret') }.to raise_error Ably::TokenRequestError
302
+ end
303
+ end
304
+
305
+ context "with :query_time option" do
306
+ let(:time) { Time.now - 30 }
307
+ let(:options) { { query_time: true } }
308
+
309
+ it 'queries the server for the time' do
310
+ expect(client).to receive(:time).and_return(time)
311
+ expect(subject[:timestamp]).to eql(time.to_i)
312
+ end
313
+ end
314
+
315
+ context "signing" do
316
+ let(:options) do
317
+ {
318
+ id: SecureRandom.hex,
319
+ ttl: SecureRandom.hex,
320
+ capability: SecureRandom.hex,
321
+ client_id: SecureRandom.hex,
322
+ timestamp: SecureRandom.hex,
323
+ nonce: SecureRandom.hex
324
+ }
325
+ end
326
+
327
+ it 'generates a valid HMAC' do
328
+ hmac = hmac_for(options, key_secret)
329
+ expect(subject[:mac]).to eql(hmac)
330
+ end
331
+ end
332
+ end
333
+
334
+ context "client with token authentication" do
335
+ let(:capability) { { :foo => ["publish"] } }
336
+
337
+ describe "with token_id argument" do
338
+ let(:ttl) { 60 * 60 }
339
+ let(:token) do
340
+ auth.request_token(
341
+ ttl: ttl,
342
+ capability: capability
343
+ )
344
+ end
345
+ let(:token_id) { token.id }
346
+ let(:token_auth_client) do
347
+ Ably::Rest::Client.new(token_id: token_id, environment: environment)
348
+ end
349
+
350
+ it "authenticates successfully" do
351
+ expect(token_auth_client.channel("foo").publish("event", "data")).to be_truthy
352
+ end
353
+
354
+ it "disallows publishing on unspecified capability channels" do
355
+ expect { token_auth_client.channel("bar").publish("event", "data") }.to raise_error do |error|
356
+ expect(error).to be_a(Ably::InvalidRequest)
357
+ expect(error.status).to eql(401)
358
+ expect(error.code).to eql(40160)
359
+ end
360
+ end
361
+
362
+ it "fails if timestamp is invalid" do
363
+ expect { auth.request_token(timestamp: Time.now.to_i - 180) }.to raise_error do |error|
364
+ expect(error).to be_a(Ably::InvalidRequest)
365
+ expect(error.status).to eql(401)
366
+ expect(error.code).to eql(40101)
367
+ end
368
+ end
369
+ end
370
+
371
+ describe "implicit through client id" do
372
+ let(:client_id) { '999' }
373
+ let(:client) do
374
+ Ably::Rest::Client.new(api_key: api_key, client_id: client_id, environment: environment)
375
+ end
376
+ let(:token_id) { 'unique-token-id' }
377
+ let(:token_response) do
378
+ {
379
+ access_token: {
380
+ id: token_id
381
+ }
382
+ }.to_json
383
+ end
384
+
385
+ context 'stubbed', webmock: true do
386
+ let!(:request_token_stub) do
387
+ stub_request(:post, "#{client.endpoint}/keys/#{key_id}/requestToken").
388
+ to_return(:status => 201, :body => token_response, :headers => { 'Content-Type' => 'application/json' })
389
+ end
390
+ let!(:publish_message_stub) do
391
+ stub_request(:post, "#{client.endpoint}/channels/foo/publish").
392
+ with(headers: { 'Authorization' => "Bearer #{encode64(token_id)}" }).
393
+ to_return(status: 201, body: '{}', headers: { 'Content-Type' => 'application/json' })
394
+ end
395
+
396
+ it "will create a token request" do
397
+ client.channel("foo").publish("event", "data")
398
+ expect(request_token_stub).to have_been_requested
399
+ end
400
+ end
401
+
402
+ context "will create a token" do
403
+ let(:token) { client.auth.current_token }
404
+
405
+ it "before a request is made" do
406
+ expect(token).to be_nil
407
+ end
408
+
409
+ it "when a message is published" do
410
+ expect(client.channel("foo").publish("event", "data")).to be_truthy
411
+ end
412
+
413
+ it "with capability and TTL defaults" do
414
+ client.channel("foo").publish("event", "data")
415
+
416
+ expect(token).to be_a(Ably::Token)
417
+ capability_with_str_key = Ably::Token::DEFAULTS[:capability]
418
+ capability = Hash[capability_with_str_key.keys.map(&:to_sym).zip(capability_with_str_key.values)]
419
+ expect(token.capability).to eql(capability)
420
+ expect(token.expires_at).to be_within(2).of(Time.now + Ably::Token::DEFAULTS[:ttl])
421
+ expect(token.client_id).to eql(client_id)
422
+ end
423
+ end
424
+ end
425
+ end
426
+
427
+ def hmac_for(token_request, secret)
428
+ text = token_request.values_at(
429
+ :id,
430
+ :ttl,
431
+ :capability,
432
+ :client_id,
433
+ :timestamp,
434
+ :nonce
435
+ ).map { |t| "#{t}\n" }.join("")
436
+
437
+ encode64(
438
+ Digest::HMAC.digest(text, key_secret, Digest::SHA256)
439
+ )
440
+ end
441
+ end