oauth2 1.4.2 → 1.4.7
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 +4 -4
- data/CHANGELOG.md +28 -2
- data/CODE_OF_CONDUCT.md +105 -46
- data/README.md +33 -5
- data/lib/oauth2/access_token.rb +12 -4
- data/lib/oauth2/authenticator.rb +10 -0
- data/lib/oauth2/client.rb +63 -16
- data/lib/oauth2/mac_token.rb +10 -2
- data/lib/oauth2/response.rb +5 -3
- data/lib/oauth2/strategy/assertion.rb +3 -3
- data/lib/oauth2/strategy/password.rb +2 -2
- data/lib/oauth2/version.rb +9 -3
- data/spec/helper.rb +37 -0
- data/spec/oauth2/access_token_spec.rb +216 -0
- data/spec/oauth2/authenticator_spec.rb +84 -0
- data/spec/oauth2/client_spec.rb +506 -0
- data/spec/oauth2/mac_token_spec.rb +117 -0
- data/spec/oauth2/response_spec.rb +90 -0
- data/spec/oauth2/strategy/assertion_spec.rb +58 -0
- data/spec/oauth2/strategy/auth_code_spec.rb +107 -0
- data/spec/oauth2/strategy/base_spec.rb +5 -0
- data/spec/oauth2/strategy/client_credentials_spec.rb +69 -0
- data/spec/oauth2/strategy/implicit_spec.rb +26 -0
- data/spec/oauth2/strategy/password_spec.rb +55 -0
- data/spec/oauth2/version_spec.rb +23 -0
- metadata +41 -38
- data/.document +0 -5
- data/.gitignore +0 -19
- data/.jrubyrc +0 -1
- data/.rspec +0 -2
- data/.rubocop.yml +0 -80
- data/.rubocop_rspec.yml +0 -26
- data/.rubocop_todo.yml +0 -15
- data/.ruby-version +0 -1
- data/.travis.yml +0 -70
- data/CONTRIBUTING.md +0 -18
- data/Gemfile +0 -40
- data/Rakefile +0 -45
- data/gemfiles/jruby_1.7.gemfile +0 -11
- data/gemfiles/jruby_9.0.gemfile +0 -7
- data/gemfiles/jruby_9.1.gemfile +0 -3
- data/gemfiles/jruby_9.2.gemfile +0 -3
- data/gemfiles/jruby_head.gemfile +0 -3
- data/gemfiles/ruby_1.9.gemfile +0 -11
- data/gemfiles/ruby_2.0.gemfile +0 -6
- data/gemfiles/ruby_2.1.gemfile +0 -6
- data/gemfiles/ruby_2.2.gemfile +0 -3
- data/gemfiles/ruby_2.3.gemfile +0 -3
- data/gemfiles/ruby_2.4.gemfile +0 -3
- data/gemfiles/ruby_2.5.gemfile +0 -3
- data/gemfiles/ruby_2.6.gemfile +0 -9
- data/gemfiles/ruby_head.gemfile +0 -9
- data/gemfiles/truffleruby.gemfile +0 -3
- data/oauth2.gemspec +0 -44
@@ -0,0 +1,117 @@
|
|
1
|
+
describe OAuth2::MACToken do
|
2
|
+
subject { described_class.new(client, token, 'abc123') }
|
3
|
+
|
4
|
+
let(:token) { 'monkey' }
|
5
|
+
let(:client) do
|
6
|
+
OAuth2::Client.new('abc', 'def', :site => 'https://api.example.com') do |builder|
|
7
|
+
builder.request :url_encoded
|
8
|
+
builder.adapter :test do |stub|
|
9
|
+
VERBS.each do |verb|
|
10
|
+
stub.send(verb, '/token/header') { |env| [200, {}, env[:request_headers]['Authorization']] }
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
describe '#initialize' do
|
17
|
+
it 'assigns client and token' do
|
18
|
+
expect(subject.client).to eq(client)
|
19
|
+
expect(subject.token).to eq(token)
|
20
|
+
end
|
21
|
+
|
22
|
+
it 'assigns secret' do
|
23
|
+
expect(subject.secret).to eq('abc123')
|
24
|
+
end
|
25
|
+
|
26
|
+
it 'defaults algorithm to hmac-sha-256' do
|
27
|
+
expect(subject.algorithm).to be_instance_of(OpenSSL::Digest::SHA256)
|
28
|
+
end
|
29
|
+
|
30
|
+
it 'handles hmac-sha-256' do
|
31
|
+
mac = described_class.new(client, token, 'abc123', :algorithm => 'hmac-sha-256')
|
32
|
+
expect(mac.algorithm).to be_instance_of(OpenSSL::Digest::SHA256)
|
33
|
+
end
|
34
|
+
|
35
|
+
it 'handles hmac-sha-1' do
|
36
|
+
mac = described_class.new(client, token, 'abc123', :algorithm => 'hmac-sha-1')
|
37
|
+
expect(mac.algorithm).to be_instance_of(OpenSSL::Digest::SHA1)
|
38
|
+
end
|
39
|
+
|
40
|
+
it 'raises on improper algorithm' do
|
41
|
+
expect { described_class.new(client, token, 'abc123', :algorithm => 'invalid-sha') }.to raise_error(ArgumentError)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
describe '#request' do
|
46
|
+
VERBS.each do |verb|
|
47
|
+
it "sends the token in the Authorization header for a #{verb.to_s.upcase} request" do
|
48
|
+
expect(subject.post('/token/header').body).to include("MAC id=\"#{token}\"")
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
describe '#header' do
|
54
|
+
it 'does not generate the same header twice' do
|
55
|
+
header = subject.header('get', 'https://www.example.com/hello')
|
56
|
+
duplicate_header = subject.header('get', 'https://www.example.com/hello')
|
57
|
+
|
58
|
+
expect(header).not_to eq(duplicate_header)
|
59
|
+
end
|
60
|
+
|
61
|
+
it 'generates the proper format' do
|
62
|
+
header = subject.header('get', 'https://www.example.com/hello?a=1')
|
63
|
+
expect(header).to match(/MAC id="#{token}", ts="[0-9]+", nonce="[^"]+", mac="[^"]+"/)
|
64
|
+
end
|
65
|
+
|
66
|
+
it 'passes ArgumentError with an invalid url' do
|
67
|
+
expect { subject.header('get', 'this-is-not-valid') }.to raise_error(ArgumentError)
|
68
|
+
end
|
69
|
+
|
70
|
+
it 'passes URI::InvalidURIError through' do
|
71
|
+
expect { subject.header('get', nil) }.to raise_error(URI::InvalidURIError)
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
describe '#signature' do
|
76
|
+
it 'generates properly' do
|
77
|
+
signature = subject.signature(0, 'random-string', 'get', URI('https://www.google.com'))
|
78
|
+
expect(signature).to eq('rMDjVA3VJj3v1OmxM29QQljKia6msl5rjN83x3bZmi8=')
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
describe '#headers' do
|
83
|
+
it 'is an empty hash' do
|
84
|
+
expect(subject.headers).to eq({})
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
describe '.from_access_token' do
|
89
|
+
subject { described_class.from_access_token(access_token, 'hello') }
|
90
|
+
|
91
|
+
let(:access_token) do
|
92
|
+
OAuth2::AccessToken.new(
|
93
|
+
client, token,
|
94
|
+
:expires_at => 1,
|
95
|
+
:expires_in => 1,
|
96
|
+
:refresh_token => 'abc',
|
97
|
+
:random => 1
|
98
|
+
)
|
99
|
+
end
|
100
|
+
|
101
|
+
it 'initializes client, token, and secret properly' do
|
102
|
+
expect(subject.client).to eq(client)
|
103
|
+
expect(subject.token).to eq(token)
|
104
|
+
expect(subject.secret).to eq('hello')
|
105
|
+
end
|
106
|
+
|
107
|
+
it 'initializes configuration options' do
|
108
|
+
expect(subject.expires_at).to eq(1)
|
109
|
+
expect(subject.expires_in).to eq(1)
|
110
|
+
expect(subject.refresh_token).to eq('abc')
|
111
|
+
end
|
112
|
+
|
113
|
+
it 'initializes params' do
|
114
|
+
expect(subject.params).to eq(:random => 1)
|
115
|
+
end
|
116
|
+
end
|
117
|
+
end
|
@@ -0,0 +1,90 @@
|
|
1
|
+
describe OAuth2::Response do
|
2
|
+
describe '#initialize' do
|
3
|
+
let(:status) { 200 }
|
4
|
+
let(:headers) { {'foo' => 'bar'} }
|
5
|
+
let(:body) { 'foo' }
|
6
|
+
|
7
|
+
it 'returns the status, headers and body' do
|
8
|
+
response = double('response', :headers => headers,
|
9
|
+
:status => status,
|
10
|
+
:body => body)
|
11
|
+
subject = described_class.new(response)
|
12
|
+
expect(subject.headers).to eq(headers)
|
13
|
+
expect(subject.status).to eq(status)
|
14
|
+
expect(subject.body).to eq(body)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
describe '.register_parser' do
|
19
|
+
let(:response) do
|
20
|
+
double('response', :headers => {'Content-Type' => 'application/foo-bar'},
|
21
|
+
:status => 200,
|
22
|
+
:body => 'baz')
|
23
|
+
end
|
24
|
+
|
25
|
+
before do
|
26
|
+
described_class.register_parser(:foobar, 'application/foo-bar') do |body|
|
27
|
+
"foobar #{body}"
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
it 'adds to the content types and parsers' do
|
32
|
+
expect(described_class.send(:class_variable_get, :@@parsers).keys).to include(:foobar)
|
33
|
+
expect(described_class.send(:class_variable_get, :@@content_types).keys).to include('application/foo-bar')
|
34
|
+
end
|
35
|
+
|
36
|
+
it 'is able to parse that content type automatically' do
|
37
|
+
expect(described_class.new(response).parsed).to eq('foobar baz')
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
describe '#parsed' do
|
42
|
+
it 'parses application/x-www-form-urlencoded body' do
|
43
|
+
headers = {'Content-Type' => 'application/x-www-form-urlencoded'}
|
44
|
+
body = 'foo=bar&answer=42'
|
45
|
+
response = double('response', :headers => headers, :body => body)
|
46
|
+
subject = described_class.new(response)
|
47
|
+
expect(subject.parsed.keys.size).to eq(2)
|
48
|
+
expect(subject.parsed['foo']).to eq('bar')
|
49
|
+
expect(subject.parsed['answer']).to eq('42')
|
50
|
+
end
|
51
|
+
|
52
|
+
it 'parses application/json body' do
|
53
|
+
headers = {'Content-Type' => 'application/json'}
|
54
|
+
body = MultiJson.encode(:foo => 'bar', :answer => 42)
|
55
|
+
response = double('response', :headers => headers, :body => body)
|
56
|
+
subject = described_class.new(response)
|
57
|
+
expect(subject.parsed.keys.size).to eq(2)
|
58
|
+
expect(subject.parsed['foo']).to eq('bar')
|
59
|
+
expect(subject.parsed['answer']).to eq(42)
|
60
|
+
end
|
61
|
+
|
62
|
+
it "doesn't try to parse other content-types" do
|
63
|
+
headers = {'Content-Type' => 'text/html'}
|
64
|
+
body = '<!DOCTYPE html><html><head></head><body></body></html>'
|
65
|
+
|
66
|
+
response = double('response', :headers => headers, :body => body)
|
67
|
+
|
68
|
+
expect(MultiJson).not_to receive(:decode)
|
69
|
+
expect(MultiJson).not_to receive(:load)
|
70
|
+
expect(Rack::Utils).not_to receive(:parse_query)
|
71
|
+
|
72
|
+
subject = described_class.new(response)
|
73
|
+
expect(subject.parsed).to be_nil
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
context 'with xml parser registration' do
|
78
|
+
it 'tries to load multi_xml and use it' do
|
79
|
+
expect(described_class.send(:class_variable_get, :@@parsers)[:xml]).not_to be_nil
|
80
|
+
end
|
81
|
+
|
82
|
+
it 'is able to parse xml' do
|
83
|
+
headers = {'Content-Type' => 'text/xml'}
|
84
|
+
body = '<?xml version="1.0" standalone="yes" ?><foo><bar>baz</bar></foo>'
|
85
|
+
|
86
|
+
response = double('response', :headers => headers, :body => body)
|
87
|
+
expect(described_class.new(response).parsed).to eq('foo' => {'bar' => 'baz'})
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
describe OAuth2::Strategy::Assertion do
|
2
|
+
subject { client.assertion }
|
3
|
+
|
4
|
+
let(:client) do
|
5
|
+
cli = OAuth2::Client.new('abc', 'def', :site => 'http://api.example.com')
|
6
|
+
cli.connection.build do |b|
|
7
|
+
b.adapter :test do |stub|
|
8
|
+
stub.post('/oauth/token') do |env|
|
9
|
+
case @mode
|
10
|
+
when 'formencoded'
|
11
|
+
[200, {'Content-Type' => 'application/x-www-form-urlencoded'}, 'expires_in=600&access_token=salmon&refresh_token=trout']
|
12
|
+
when 'json'
|
13
|
+
[200, {'Content-Type' => 'application/json'}, '{"expires_in":600,"access_token":"salmon","refresh_token":"trout"}']
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
cli
|
19
|
+
end
|
20
|
+
|
21
|
+
let(:params) do
|
22
|
+
{
|
23
|
+
:hmac_secret => 'foo',
|
24
|
+
:exp => Time.now.utc.to_i + 3600,
|
25
|
+
}
|
26
|
+
end
|
27
|
+
|
28
|
+
describe '#authorize_url' do
|
29
|
+
it 'raises NotImplementedError' do
|
30
|
+
expect { subject.authorize_url }.to raise_error(NotImplementedError)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
%w[json formencoded].each do |mode|
|
35
|
+
describe "#get_token (#{mode})" do
|
36
|
+
before do
|
37
|
+
@mode = mode
|
38
|
+
@access = subject.get_token(params)
|
39
|
+
end
|
40
|
+
|
41
|
+
it 'returns AccessToken with same Client' do
|
42
|
+
expect(@access.client).to eq(client)
|
43
|
+
end
|
44
|
+
|
45
|
+
it 'returns AccessToken with #token' do
|
46
|
+
expect(@access.token).to eq('salmon')
|
47
|
+
end
|
48
|
+
|
49
|
+
it 'returns AccessToken with #expires_in' do
|
50
|
+
expect(@access.expires_in).to eq(600)
|
51
|
+
end
|
52
|
+
|
53
|
+
it 'returns AccessToken with #expires_at' do
|
54
|
+
expect(@access.expires_at).not_to be_nil
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
@@ -0,0 +1,107 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
describe OAuth2::Strategy::AuthCode do
|
4
|
+
subject { client.auth_code }
|
5
|
+
|
6
|
+
let(:code) { 'sushi' }
|
7
|
+
let(:kvform_token) { 'expires_in=600&access_token=salmon&refresh_token=trout&extra_param=steve' }
|
8
|
+
let(:facebook_token) { kvform_token.gsub('_in', '') }
|
9
|
+
let(:json_token) { MultiJson.encode(:expires_in => 600, :access_token => 'salmon', :refresh_token => 'trout', :extra_param => 'steve') }
|
10
|
+
|
11
|
+
let(:client) do
|
12
|
+
OAuth2::Client.new('abc', 'def', :site => 'http://api.example.com') do |builder|
|
13
|
+
builder.adapter :test do |stub|
|
14
|
+
stub.get("/oauth/token?client_id=abc&client_secret=def&code=#{code}&grant_type=authorization_code") do |env|
|
15
|
+
case @mode
|
16
|
+
when 'formencoded'
|
17
|
+
[200, {'Content-Type' => 'application/x-www-form-urlencoded'}, kvform_token]
|
18
|
+
when 'json'
|
19
|
+
[200, {'Content-Type' => 'application/json'}, json_token]
|
20
|
+
when 'from_facebook'
|
21
|
+
[200, {'Content-Type' => 'application/x-www-form-urlencoded'}, facebook_token]
|
22
|
+
end
|
23
|
+
end
|
24
|
+
stub.post('/oauth/token', 'client_id' => 'abc', 'client_secret' => 'def', 'code' => 'sushi', 'grant_type' => 'authorization_code') do |env|
|
25
|
+
case @mode
|
26
|
+
when 'formencoded'
|
27
|
+
[200, {'Content-Type' => 'application/x-www-form-urlencoded'}, kvform_token]
|
28
|
+
when 'json'
|
29
|
+
[200, {'Content-Type' => 'application/json'}, json_token]
|
30
|
+
when 'from_facebook'
|
31
|
+
[200, {'Content-Type' => 'application/x-www-form-urlencoded'}, facebook_token]
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
describe '#authorize_url' do
|
39
|
+
it 'includes the client_id' do
|
40
|
+
expect(subject.authorize_url).to include('client_id=abc')
|
41
|
+
end
|
42
|
+
|
43
|
+
it 'includes the type' do
|
44
|
+
expect(subject.authorize_url).to include('response_type=code')
|
45
|
+
end
|
46
|
+
|
47
|
+
it 'includes passed in options' do
|
48
|
+
cb = 'http://myserver.local/oauth/callback'
|
49
|
+
expect(subject.authorize_url(:redirect_uri => cb)).to include("redirect_uri=#{Rack::Utils.escape(cb)}")
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
describe '#get_token (handling utf-8 data)' do
|
54
|
+
let(:json_token) { MultiJson.encode(:expires_in => 600, :access_token => 'salmon', :refresh_token => 'trout', :extra_param => 'André') }
|
55
|
+
|
56
|
+
before do
|
57
|
+
@mode = 'json'
|
58
|
+
client.options[:token_method] = :post
|
59
|
+
end
|
60
|
+
|
61
|
+
it 'does not raise an error' do
|
62
|
+
expect { subject.get_token(code) }.not_to raise_error
|
63
|
+
end
|
64
|
+
|
65
|
+
it 'does not create an error instance' do
|
66
|
+
expect(OAuth2::Error).not_to receive(:new)
|
67
|
+
|
68
|
+
subject.get_token(code)
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
%w[json formencoded from_facebook].each do |mode|
|
73
|
+
[:get, :post].each do |verb|
|
74
|
+
describe "#get_token (#{mode}, access_token_method=#{verb}" do
|
75
|
+
before do
|
76
|
+
@mode = mode
|
77
|
+
client.options[:token_method] = verb
|
78
|
+
@access = subject.get_token(code)
|
79
|
+
end
|
80
|
+
|
81
|
+
it 'returns AccessToken with same Client' do
|
82
|
+
expect(@access.client).to eq(client)
|
83
|
+
end
|
84
|
+
|
85
|
+
it 'returns AccessToken with #token' do
|
86
|
+
expect(@access.token).to eq('salmon')
|
87
|
+
end
|
88
|
+
|
89
|
+
it 'returns AccessToken with #refresh_token' do
|
90
|
+
expect(@access.refresh_token).to eq('trout')
|
91
|
+
end
|
92
|
+
|
93
|
+
it 'returns AccessToken with #expires_in' do
|
94
|
+
expect(@access.expires_in).to eq(600)
|
95
|
+
end
|
96
|
+
|
97
|
+
it 'returns AccessToken with #expires_at' do
|
98
|
+
expect(@access.expires_at).to be_kind_of(Integer)
|
99
|
+
end
|
100
|
+
|
101
|
+
it 'returns AccessToken with params accessible via []' do
|
102
|
+
expect(@access['extra_param']).to eq('steve')
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
@@ -0,0 +1,69 @@
|
|
1
|
+
describe OAuth2::Strategy::ClientCredentials do
|
2
|
+
subject { client.client_credentials }
|
3
|
+
|
4
|
+
let(:kvform_token) { 'expires_in=600&access_token=salmon&refresh_token=trout' }
|
5
|
+
let(:json_token) { '{"expires_in":600,"access_token":"salmon","refresh_token":"trout"}' }
|
6
|
+
|
7
|
+
let(:client) do
|
8
|
+
OAuth2::Client.new('abc', 'def', :site => 'http://api.example.com') do |builder|
|
9
|
+
builder.adapter :test do |stub|
|
10
|
+
stub.post('/oauth/token', 'grant_type' => 'client_credentials') do |env|
|
11
|
+
client_id, client_secret = Base64.decode64(env[:request_headers]['Authorization'].split(' ', 2)[1]).split(':', 2)
|
12
|
+
client_id == 'abc' && client_secret == 'def' || raise(Faraday::Adapter::Test::Stubs::NotFound)
|
13
|
+
case @mode
|
14
|
+
when 'formencoded'
|
15
|
+
[200, {'Content-Type' => 'application/x-www-form-urlencoded'}, kvform_token]
|
16
|
+
when 'json'
|
17
|
+
[200, {'Content-Type' => 'application/json'}, json_token]
|
18
|
+
end
|
19
|
+
end
|
20
|
+
stub.post('/oauth/token', 'client_id' => 'abc', 'client_secret' => 'def', 'grant_type' => 'client_credentials') do |env|
|
21
|
+
case @mode
|
22
|
+
when 'formencoded'
|
23
|
+
[200, {'Content-Type' => 'application/x-www-form-urlencoded'}, kvform_token]
|
24
|
+
when 'json'
|
25
|
+
[200, {'Content-Type' => 'application/json'}, json_token]
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
describe '#authorize_url' do
|
33
|
+
it 'raises NotImplementedError' do
|
34
|
+
expect { subject.authorize_url }.to raise_error(NotImplementedError)
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
%w[json formencoded].each do |mode|
|
39
|
+
[:basic_auth, :request_body].each do |auth_scheme|
|
40
|
+
describe "#get_token (#{mode}) (#{auth_scheme})" do
|
41
|
+
before do
|
42
|
+
@mode = mode
|
43
|
+
client.options[:auth_scheme] = auth_scheme
|
44
|
+
@access = subject.get_token
|
45
|
+
end
|
46
|
+
|
47
|
+
it 'returns AccessToken with same Client' do
|
48
|
+
expect(@access.client).to eq(client)
|
49
|
+
end
|
50
|
+
|
51
|
+
it 'returns AccessToken with #token' do
|
52
|
+
expect(@access.token).to eq('salmon')
|
53
|
+
end
|
54
|
+
|
55
|
+
it 'returns AccessToken without #refresh_token' do
|
56
|
+
expect(@access.refresh_token).to be_nil
|
57
|
+
end
|
58
|
+
|
59
|
+
it 'returns AccessToken with #expires_in' do
|
60
|
+
expect(@access.expires_in).to eq(600)
|
61
|
+
end
|
62
|
+
|
63
|
+
it 'returns AccessToken with #expires_at' do
|
64
|
+
expect(@access.expires_at).not_to be_nil
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|