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.
- checksums.yaml +7 -0
- data/.gitignore +4 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +22 -0
- data/README.md +103 -0
- data/Rakefile +4 -0
- data/ably.gemspec +32 -0
- data/lib/ably.rb +11 -0
- data/lib/ably/auth.rb +381 -0
- data/lib/ably/exceptions.rb +16 -0
- data/lib/ably/realtime.rb +38 -0
- data/lib/ably/realtime/callbacks.rb +15 -0
- data/lib/ably/realtime/channel.rb +51 -0
- data/lib/ably/realtime/client.rb +82 -0
- data/lib/ably/realtime/connection.rb +61 -0
- data/lib/ably/rest.rb +15 -0
- data/lib/ably/rest/channel.rb +58 -0
- data/lib/ably/rest/client.rb +194 -0
- data/lib/ably/rest/middleware/exceptions.rb +42 -0
- data/lib/ably/rest/middleware/external_exceptions.rb +26 -0
- data/lib/ably/rest/middleware/parse_json.rb +15 -0
- data/lib/ably/rest/paged_resource.rb +107 -0
- data/lib/ably/rest/presence.rb +44 -0
- data/lib/ably/support.rb +14 -0
- data/lib/ably/token.rb +55 -0
- data/lib/ably/version.rb +3 -0
- data/spec/acceptance/realtime_client_spec.rb +12 -0
- data/spec/acceptance/rest/auth_spec.rb +441 -0
- data/spec/acceptance/rest/base_spec.rb +113 -0
- data/spec/acceptance/rest/channel_spec.rb +68 -0
- data/spec/acceptance/rest/presence_spec.rb +22 -0
- data/spec/acceptance/rest/stats_spec.rb +57 -0
- data/spec/acceptance/rest/time_spec.rb +14 -0
- data/spec/spec_helper.rb +31 -0
- data/spec/support/api_helper.rb +41 -0
- data/spec/support/test_app.rb +77 -0
- data/spec/unit/auth.rb +9 -0
- data/spec/unit/realtime_spec.rb +9 -0
- data/spec/unit/rest_spec.rb +99 -0
- data/spec/unit/token_spec.rb +90 -0
- 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
|
data/lib/ably/support.rb
ADDED
data/lib/ably/token.rb
ADDED
@@ -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
|
data/lib/ably/version.rb
ADDED
@@ -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
|