oauth2-aptible 0.9.4.aptible
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.document +5 -0
- data/CONTRIBUTING.md +18 -0
- data/LICENSE.md +20 -0
- data/README.md +137 -0
- data/Rakefile +39 -0
- data/lib/oauth2.rb +10 -0
- data/lib/oauth2/access_token.rb +173 -0
- data/lib/oauth2/client.rb +173 -0
- data/lib/oauth2/error.rb +24 -0
- data/lib/oauth2/response.rb +90 -0
- data/lib/oauth2/strategy/assertion.rb +72 -0
- data/lib/oauth2/strategy/auth_code.rb +33 -0
- data/lib/oauth2/strategy/base.rb +16 -0
- data/lib/oauth2/strategy/client_credentials.rb +36 -0
- data/lib/oauth2/strategy/implicit.rb +29 -0
- data/lib/oauth2/strategy/password.rb +27 -0
- data/lib/oauth2/version.rb +15 -0
- data/oauth2.gemspec +27 -0
- data/spec/helper.rb +29 -0
- data/spec/oauth2/access_token_spec.rb +172 -0
- data/spec/oauth2/client_spec.rb +205 -0
- data/spec/oauth2/response_spec.rb +101 -0
- data/spec/oauth2/strategy/assertion_spec.rb +56 -0
- data/spec/oauth2/strategy/auth_code_spec.rb +88 -0
- data/spec/oauth2/strategy/base_spec.rb +7 -0
- data/spec/oauth2/strategy/client_credentials_spec.rb +81 -0
- data/spec/oauth2/strategy/implicit_spec.rb +28 -0
- data/spec/oauth2/strategy/password_spec.rb +57 -0
- metadata +174 -0
@@ -0,0 +1,205 @@
|
|
1
|
+
require 'helper'
|
2
|
+
|
3
|
+
describe OAuth2::Client do
|
4
|
+
let!(:error_value) { 'invalid_token' }
|
5
|
+
let!(:error_description_value) { 'bad bad token' }
|
6
|
+
|
7
|
+
subject do
|
8
|
+
OAuth2::Client.new('abc', 'def', :site => 'https://api.example.com') do |builder|
|
9
|
+
builder.adapter :test do |stub|
|
10
|
+
stub.get('/success') { |env| [200, {'Content-Type' => 'text/awesome'}, 'yay'] }
|
11
|
+
stub.get('/reflect') { |env| [200, {}, env[:body]] }
|
12
|
+
stub.post('/reflect') { |env| [200, {}, env[:body]] }
|
13
|
+
stub.get('/unauthorized') { |env| [401, {'Content-Type' => 'application/json'}, MultiJson.encode(:error => error_value, :error_description => error_description_value)] }
|
14
|
+
stub.get('/conflict') { |env| [409, {'Content-Type' => 'text/plain'}, 'not authorized'] }
|
15
|
+
stub.get('/redirect') { |env| [302, {'Content-Type' => 'text/plain', 'location' => '/success'}, ''] }
|
16
|
+
stub.post('/redirect') { |env| [303, {'Content-Type' => 'text/plain', 'location' => '/reflect'}, ''] }
|
17
|
+
stub.get('/error') { |env| [500, {'Content-Type' => 'text/plain'}, 'unknown error'] }
|
18
|
+
stub.get('/empty_get') { |env| [204, {}, nil] }
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
describe '#initialize' do
|
24
|
+
it 'assigns id and secret' do
|
25
|
+
expect(subject.id).to eq('abc')
|
26
|
+
expect(subject.secret).to eq('def')
|
27
|
+
end
|
28
|
+
|
29
|
+
it 'assigns site from the options hash' do
|
30
|
+
expect(subject.site).to eq('https://api.example.com')
|
31
|
+
end
|
32
|
+
|
33
|
+
it 'assigns Faraday::Connection#host' do
|
34
|
+
expect(subject.connection.host).to eq('api.example.com')
|
35
|
+
end
|
36
|
+
|
37
|
+
it 'leaves Faraday::Connection#ssl unset' do
|
38
|
+
expect(subject.connection.ssl).to be_empty
|
39
|
+
end
|
40
|
+
|
41
|
+
it 'is able to pass a block to configure the connection' do
|
42
|
+
connection = double('connection')
|
43
|
+
builder = double('builder')
|
44
|
+
allow(connection).to receive(:build).and_yield(builder)
|
45
|
+
allow(Faraday::Connection).to receive(:new).and_return(connection)
|
46
|
+
|
47
|
+
expect(builder).to receive(:adapter).with(:test)
|
48
|
+
|
49
|
+
OAuth2::Client.new('abc', 'def') do |client|
|
50
|
+
client.adapter :test
|
51
|
+
end.connection
|
52
|
+
end
|
53
|
+
|
54
|
+
it 'defaults raise_errors to true' do
|
55
|
+
expect(subject.options[:raise_errors]).to be true
|
56
|
+
end
|
57
|
+
|
58
|
+
it 'allows true/false for raise_errors option' do
|
59
|
+
client = OAuth2::Client.new('abc', 'def', :site => 'https://api.example.com', :raise_errors => false)
|
60
|
+
expect(client.options[:raise_errors]).to be false
|
61
|
+
client = OAuth2::Client.new('abc', 'def', :site => 'https://api.example.com', :raise_errors => true)
|
62
|
+
expect(client.options[:raise_errors]).to be true
|
63
|
+
end
|
64
|
+
|
65
|
+
it 'allows override of raise_errors option' do
|
66
|
+
client = OAuth2::Client.new('abc', 'def', :site => 'https://api.example.com', :raise_errors => true) do |builder|
|
67
|
+
builder.adapter :test do |stub|
|
68
|
+
stub.get('/notfound') { |env| [404, {}, nil] }
|
69
|
+
end
|
70
|
+
end
|
71
|
+
expect(client.options[:raise_errors]).to be true
|
72
|
+
expect { client.request(:get, '/notfound') }.to raise_error(OAuth2::Error)
|
73
|
+
response = client.request(:get, '/notfound', :raise_errors => false)
|
74
|
+
expect(response.status).to eq(404)
|
75
|
+
end
|
76
|
+
|
77
|
+
it 'allows get/post for access_token_method option' do
|
78
|
+
client = OAuth2::Client.new('abc', 'def', :site => 'https://api.example.com', :access_token_method => :get)
|
79
|
+
expect(client.options[:access_token_method]).to eq(:get)
|
80
|
+
client = OAuth2::Client.new('abc', 'def', :site => 'https://api.example.com', :access_token_method => :post)
|
81
|
+
expect(client.options[:access_token_method]).to eq(:post)
|
82
|
+
end
|
83
|
+
|
84
|
+
it 'does not mutate the opts hash argument' do
|
85
|
+
opts = {:site => 'http://example.com/'}
|
86
|
+
opts2 = opts.dup
|
87
|
+
OAuth2::Client.new 'abc', 'def', opts
|
88
|
+
expect(opts).to eq(opts2)
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
%w(authorize token).each do |url_type|
|
93
|
+
describe ":#{url_type}_url option" do
|
94
|
+
it "defaults to a path of /oauth/#{url_type}" do
|
95
|
+
expect(subject.send("#{url_type}_url")).to eq("https://api.example.com/oauth/#{url_type}")
|
96
|
+
end
|
97
|
+
|
98
|
+
it "is settable via the :#{url_type}_url option" do
|
99
|
+
subject.options[:"#{url_type}_url"] = '/oauth/custom'
|
100
|
+
expect(subject.send("#{url_type}_url")).to eq('https://api.example.com/oauth/custom')
|
101
|
+
end
|
102
|
+
|
103
|
+
it 'allows a different host than the site' do
|
104
|
+
subject.options[:"#{url_type}_url"] = 'https://api.foo.com/oauth/custom'
|
105
|
+
expect(subject.send("#{url_type}_url")).to eq('https://api.foo.com/oauth/custom')
|
106
|
+
end
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
describe '#request' do
|
111
|
+
it 'works with a null response body' do
|
112
|
+
expect(subject.request(:get, 'empty_get').body).to eq('')
|
113
|
+
end
|
114
|
+
|
115
|
+
it 'returns on a successful response' do
|
116
|
+
response = subject.request(:get, '/success')
|
117
|
+
expect(response.body).to eq('yay')
|
118
|
+
expect(response.status).to eq(200)
|
119
|
+
expect(response.headers).to eq('Content-Type' => 'text/awesome')
|
120
|
+
end
|
121
|
+
|
122
|
+
it 'posts a body' do
|
123
|
+
response = subject.request(:post, '/reflect', :body => 'foo=bar')
|
124
|
+
expect(response.body).to eq('foo=bar')
|
125
|
+
end
|
126
|
+
|
127
|
+
it 'follows redirects properly' do
|
128
|
+
response = subject.request(:get, '/redirect')
|
129
|
+
expect(response.body).to eq('yay')
|
130
|
+
expect(response.status).to eq(200)
|
131
|
+
expect(response.headers).to eq('Content-Type' => 'text/awesome')
|
132
|
+
end
|
133
|
+
|
134
|
+
it 'redirects using GET on a 303' do
|
135
|
+
response = subject.request(:post, '/redirect', :body => 'foo=bar')
|
136
|
+
expect(response.body).to be_empty
|
137
|
+
expect(response.status).to eq(200)
|
138
|
+
end
|
139
|
+
|
140
|
+
it 'obeys the :max_redirects option' do
|
141
|
+
max_redirects = subject.options[:max_redirects]
|
142
|
+
subject.options[:max_redirects] = 0
|
143
|
+
response = subject.request(:get, '/redirect')
|
144
|
+
expect(response.status).to eq(302)
|
145
|
+
subject.options[:max_redirects] = max_redirects
|
146
|
+
end
|
147
|
+
|
148
|
+
it 'returns if raise_errors is false' do
|
149
|
+
subject.options[:raise_errors] = false
|
150
|
+
response = subject.request(:get, '/unauthorized')
|
151
|
+
|
152
|
+
expect(response.status).to eq(401)
|
153
|
+
expect(response.headers).to eq('Content-Type' => 'application/json')
|
154
|
+
expect(response.error).not_to be_nil
|
155
|
+
end
|
156
|
+
|
157
|
+
%w(/unauthorized /conflict /error).each do |error_path|
|
158
|
+
it "raises OAuth2::Error on error response to path #{error_path}" do
|
159
|
+
expect { subject.request(:get, error_path) }.to raise_error(OAuth2::Error)
|
160
|
+
end
|
161
|
+
end
|
162
|
+
|
163
|
+
it 'parses OAuth2 standard error response' do
|
164
|
+
begin
|
165
|
+
subject.request(:get, '/unauthorized')
|
166
|
+
rescue StandardError => e
|
167
|
+
expect(e.code).to eq(error_value)
|
168
|
+
expect(e.description).to eq(error_description_value)
|
169
|
+
expect(e.to_s).to match(/#{error_value}/)
|
170
|
+
expect(e.to_s).to match(/#{error_description_value}/)
|
171
|
+
end
|
172
|
+
end
|
173
|
+
|
174
|
+
it 'provides the response in the Exception' do
|
175
|
+
begin
|
176
|
+
subject.request(:get, '/error')
|
177
|
+
rescue StandardError => e
|
178
|
+
expect(e.response).not_to be_nil
|
179
|
+
expect(e.to_s).to match(/unknown error/)
|
180
|
+
end
|
181
|
+
end
|
182
|
+
end
|
183
|
+
|
184
|
+
it 'instantiates an AuthCode strategy with this client' do
|
185
|
+
expect(subject.auth_code).to be_kind_of(OAuth2::Strategy::AuthCode)
|
186
|
+
end
|
187
|
+
|
188
|
+
it 'instantiates an Implicit strategy with this client' do
|
189
|
+
expect(subject.implicit).to be_kind_of(OAuth2::Strategy::Implicit)
|
190
|
+
end
|
191
|
+
|
192
|
+
context 'with SSL options' do
|
193
|
+
subject do
|
194
|
+
cli = OAuth2::Client.new('abc', 'def', :site => 'https://api.example.com', :ssl => {:ca_file => 'foo.pem'})
|
195
|
+
cli.connection.build do |b|
|
196
|
+
b.adapter :test
|
197
|
+
end
|
198
|
+
cli
|
199
|
+
end
|
200
|
+
|
201
|
+
it 'passes the SSL options along to Faraday::Connection#ssl' do
|
202
|
+
expect(subject.connection.ssl.fetch(:ca_file)).to eq('foo.pem')
|
203
|
+
end
|
204
|
+
end
|
205
|
+
end
|
@@ -0,0 +1,101 @@
|
|
1
|
+
require 'helper'
|
2
|
+
|
3
|
+
describe OAuth2::Response do
|
4
|
+
describe '#initialize' do
|
5
|
+
let(:status) { 200 }
|
6
|
+
let(:headers) { {'foo' => 'bar'} }
|
7
|
+
let(:body) { 'foo' }
|
8
|
+
|
9
|
+
it 'returns the status, headers and body' do
|
10
|
+
response = double('response', :headers => headers,
|
11
|
+
:status => status,
|
12
|
+
:body => body)
|
13
|
+
subject = Response.new(response)
|
14
|
+
expect(subject.headers).to eq(headers)
|
15
|
+
expect(subject.status).to eq(status)
|
16
|
+
expect(subject.body).to eq(body)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
describe '.register_parser' do
|
21
|
+
let(:response) do
|
22
|
+
double('response', :headers => {'Content-Type' => 'application/foo-bar'},
|
23
|
+
:status => 200,
|
24
|
+
:body => 'baz')
|
25
|
+
end
|
26
|
+
before do
|
27
|
+
OAuth2::Response.register_parser(:foobar, 'application/foo-bar') do |body|
|
28
|
+
"foobar #{body}"
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
it 'adds to the content types and parsers' do
|
33
|
+
expect(OAuth2::Response::PARSERS.keys).to include(:foobar)
|
34
|
+
expect(OAuth2::Response::CONTENT_TYPES.keys).to include('application/foo-bar')
|
35
|
+
end
|
36
|
+
|
37
|
+
it 'is able to parse that content type automatically' do
|
38
|
+
expect(OAuth2::Response.new(response).parsed).to eq('foobar baz')
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
describe '#parsed' do
|
43
|
+
it 'parses application/x-www-form-urlencoded body' do
|
44
|
+
headers = {'Content-Type' => 'application/x-www-form-urlencoded'}
|
45
|
+
body = 'foo=bar&answer=42'
|
46
|
+
response = double('response', :headers => headers, :body => body)
|
47
|
+
subject = Response.new(response)
|
48
|
+
expect(subject.parsed.keys.size).to eq(2)
|
49
|
+
expect(subject.parsed['foo']).to eq('bar')
|
50
|
+
expect(subject.parsed['answer']).to eq('42')
|
51
|
+
end
|
52
|
+
|
53
|
+
it 'parses application/json body' do
|
54
|
+
headers = {'Content-Type' => 'application/json'}
|
55
|
+
body = MultiJson.encode(:foo => 'bar', :answer => 42)
|
56
|
+
response = double('response', :headers => headers, :body => body)
|
57
|
+
subject = Response.new(response)
|
58
|
+
expect(subject.parsed.keys.size).to eq(2)
|
59
|
+
expect(subject.parsed['foo']).to eq('bar')
|
60
|
+
expect(subject.parsed['answer']).to eq(42)
|
61
|
+
end
|
62
|
+
|
63
|
+
it 'parses alternative application/json extension bodies' do
|
64
|
+
headers = {'Content-Type' => 'application/hal+json'}
|
65
|
+
body = MultiJson.encode(:foo => 'bar', :answer => 42)
|
66
|
+
response = double('response', :headers => headers, :body => body)
|
67
|
+
subject = Response.new(response)
|
68
|
+
expect(subject.parsed.keys.size).to eq(2)
|
69
|
+
expect(subject.parsed['foo']).to eq('bar')
|
70
|
+
expect(subject.parsed['answer']).to eq(42)
|
71
|
+
end
|
72
|
+
|
73
|
+
it "doesn't try to parse other content-types" do
|
74
|
+
headers = {'Content-Type' => 'text/html'}
|
75
|
+
body = '<!DOCTYPE html><html><head></head><body></body></html>'
|
76
|
+
|
77
|
+
response = double('response', :headers => headers, :body => body)
|
78
|
+
|
79
|
+
expect(MultiJson).not_to receive(:decode)
|
80
|
+
expect(MultiJson).not_to receive(:load)
|
81
|
+
expect(Rack::Utils).not_to receive(:parse_query)
|
82
|
+
|
83
|
+
subject = Response.new(response)
|
84
|
+
expect(subject.parsed).to be_nil
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
context 'xml parser registration' do
|
89
|
+
it 'tries to load multi_xml and use it' do
|
90
|
+
expect(OAuth2::Response::PARSERS[:xml]).not_to be_nil
|
91
|
+
end
|
92
|
+
|
93
|
+
it 'is able to parse xml' do
|
94
|
+
headers = {'Content-Type' => 'text/xml'}
|
95
|
+
body = '<?xml version="1.0" standalone="yes" ?><foo><bar>baz</bar></foo>'
|
96
|
+
|
97
|
+
response = double('response', :headers => headers, :body => body)
|
98
|
+
expect(OAuth2::Response.new(response).parsed).to eq('foo' => {'bar' => 'baz'})
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
@@ -0,0 +1,56 @@
|
|
1
|
+
require 'helper'
|
2
|
+
|
3
|
+
describe OAuth2::Strategy::Assertion do
|
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) { {:hmac_secret => 'foo'} }
|
22
|
+
|
23
|
+
subject { client.assertion }
|
24
|
+
|
25
|
+
describe '#authorize_url' do
|
26
|
+
it 'raises NotImplementedError' do
|
27
|
+
expect { subject.authorize_url }.to raise_error(NotImplementedError)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
%w(json formencoded).each do |mode|
|
32
|
+
describe "#get_token (#{mode})" do
|
33
|
+
before do
|
34
|
+
@mode = mode
|
35
|
+
@access = subject.get_token(params)
|
36
|
+
end
|
37
|
+
|
38
|
+
it 'returns AccessToken with same Client' do
|
39
|
+
expect(@access.client).to eq(client)
|
40
|
+
end
|
41
|
+
|
42
|
+
it 'returns AccessToken with #token' do
|
43
|
+
expect(@access.token).to eq('salmon')
|
44
|
+
end
|
45
|
+
|
46
|
+
it 'returns AccessToken with #expires_in' do
|
47
|
+
expect(@access.expires_in).to eq(600)
|
48
|
+
end
|
49
|
+
|
50
|
+
it 'returns AccessToken with #expires_at' do
|
51
|
+
expect(@access.expires_at).not_to be_nil
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
end
|
@@ -0,0 +1,88 @@
|
|
1
|
+
require 'helper'
|
2
|
+
|
3
|
+
describe OAuth2::Strategy::AuthCode do
|
4
|
+
let(:code) { 'sushi' }
|
5
|
+
let(:kvform_token) { 'expires_in=600&access_token=salmon&refresh_token=trout&extra_param=steve' }
|
6
|
+
let(:facebook_token) { kvform_token.gsub('_in', '') }
|
7
|
+
let(:json_token) { MultiJson.encode(:expires_in => 600, :access_token => 'salmon', :refresh_token => 'trout', :extra_param => 'steve') }
|
8
|
+
|
9
|
+
let(:client) do
|
10
|
+
OAuth2::Client.new('abc', 'def', :site => 'http://api.example.com') do |builder|
|
11
|
+
builder.adapter :test do |stub|
|
12
|
+
stub.get("/oauth/token?client_id=abc&client_secret=def&code=#{code}&grant_type=authorization_code") do |env|
|
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
|
+
when 'from_facebook'
|
19
|
+
[200, {'Content-Type' => 'application/x-www-form-urlencoded'}, facebook_token]
|
20
|
+
end
|
21
|
+
end
|
22
|
+
stub.post('/oauth/token', 'client_id' => 'abc', 'client_secret' => 'def', 'code' => 'sushi', 'grant_type' => 'authorization_code') do |env|
|
23
|
+
case @mode
|
24
|
+
when 'formencoded'
|
25
|
+
[200, {'Content-Type' => 'application/x-www-form-urlencoded'}, kvform_token]
|
26
|
+
when 'json'
|
27
|
+
[200, {'Content-Type' => 'application/json'}, json_token]
|
28
|
+
when 'from_facebook'
|
29
|
+
[200, {'Content-Type' => 'application/x-www-form-urlencoded'}, facebook_token]
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
subject { client.auth_code }
|
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
|
+
%w(json formencoded from_facebook).each do |mode|
|
54
|
+
[:get, :post].each do |verb|
|
55
|
+
describe "#get_token (#{mode}, access_token_method=#{verb}" do
|
56
|
+
before do
|
57
|
+
@mode = mode
|
58
|
+
client.options[:token_method] = verb
|
59
|
+
@access = subject.get_token(code)
|
60
|
+
end
|
61
|
+
|
62
|
+
it 'returns AccessToken with same Client' do
|
63
|
+
expect(@access.client).to eq(client)
|
64
|
+
end
|
65
|
+
|
66
|
+
it 'returns AccessToken with #token' do
|
67
|
+
expect(@access.token).to eq('salmon')
|
68
|
+
end
|
69
|
+
|
70
|
+
it 'returns AccessToken with #refresh_token' do
|
71
|
+
expect(@access.refresh_token).to eq('trout')
|
72
|
+
end
|
73
|
+
|
74
|
+
it 'returns AccessToken with #expires_in' do
|
75
|
+
expect(@access.expires_in).to eq(600)
|
76
|
+
end
|
77
|
+
|
78
|
+
it 'returns AccessToken with #expires_at' do
|
79
|
+
expect(@access.expires_at).to be_kind_of(Integer)
|
80
|
+
end
|
81
|
+
|
82
|
+
it 'returns AccessToken with params accessible via []' do
|
83
|
+
expect(@access['extra_param']).to eq('steve')
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|