kinde_sdk 1.6.3 → 1.6.4

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.
data/spec/examples.txt ADDED
@@ -0,0 +1,24 @@
1
+ example_id | status | run_time |
2
+ ------------------------------------- | ------ | --------------- |
3
+ ./spec/kinde_sdk_spec.rb[1:1:1] | passed | 0.08981 seconds |
4
+ ./spec/kinde_sdk_spec.rb[1:1:2] | passed | 0.16283 seconds |
5
+ ./spec/kinde_sdk_spec.rb[1:2:1] | passed | 0.04053 seconds |
6
+ ./spec/kinde_sdk_spec.rb[1:2:2:1] | passed | 0.00692 seconds |
7
+ ./spec/kinde_sdk_spec.rb[1:3:1] | passed | 0.20177 seconds |
8
+ ./spec/kinde_sdk_spec.rb[1:4:1] | passed | 0.10943 seconds |
9
+ ./spec/kinde_sdk_spec.rb[1:4:2:1] | passed | 0.0565 seconds |
10
+ ./spec/kinde_sdk_spec.rb[1:5:1] | passed | 0.03191 seconds |
11
+ ./spec/kinde_sdk_spec.rb[1:5:2:1] | passed | 0.18111 seconds |
12
+ ./spec/kinde_sdk_spec.rb[1:6:1:1] | passed | 0.04266 seconds |
13
+ ./spec/kinde_sdk_spec.rb[1:6:1:2] | passed | 0.1197 seconds |
14
+ ./spec/kinde_sdk_spec.rb[1:6:1:3] | passed | 0.08009 seconds |
15
+ ./spec/kinde_sdk_spec.rb[1:6:2:1] | passed | 0.02948 seconds |
16
+ ./spec/kinde_sdk_spec.rb[1:6:2:2:1:1] | passed | 0.04764 seconds |
17
+ ./spec/kinde_sdk_spec.rb[1:6:2:2:2:1] | passed | 0.03494 seconds |
18
+ ./spec/kinde_sdk_spec.rb[1:6:3:1] | passed | 0.1131 seconds |
19
+ ./spec/kinde_sdk_spec.rb[1:6:3:2] | passed | 0.14076 seconds |
20
+ ./spec/kinde_sdk_spec.rb[1:6:3:3] | passed | 0.03831 seconds |
21
+ ./spec/kinde_sdk_spec.rb[1:6:3:4] | passed | 0.05261 seconds |
22
+ ./spec/kinde_sdk_spec.rb[1:6:3:5] | passed | 0.04123 seconds |
23
+ ./spec/kinde_sdk_spec.rb[1:6:3:6] | passed | 0.06887 seconds |
24
+ ./spec/kinde_sdk_spec.rb[1:6:4:1:1] | passed | 0.07917 seconds |
@@ -2,21 +2,29 @@ require 'spec_helper'
2
2
  require 'jwt'
3
3
  require 'openssl'
4
4
  require 'webmock/rspec'
5
+ require 'rails'
5
6
 
7
+ # Set up a minimal Rails application for testing
8
+ class TestApplication < Rails::Application
9
+ config.eager_load = false
10
+ config.active_support.deprecation = :stderr
11
+ end
6
12
 
7
- describe KindeSdk do
13
+ RSpec.describe KindeSdk do
8
14
  let(:domain) { "http://example.com" }
9
15
  let(:client_id) { "client_id" }
10
16
  let(:client_secret) { "client_secret" }
11
17
  let(:callback_url) { "http://localhost:3000/callback" }
12
18
  let(:logout_url) { "http://localhost/logout-callback" }
13
19
  let(:auto_refresh_tokens) { true }
20
+ let(:mock_session) { {} }
14
21
 
15
22
  let(:optional_parameters) { { kid: 'my-kid', use: 'sig', alg: 'RS512' } }
16
23
  let(:rsa_key) { OpenSSL::PKey::RSA.new(2048) }
17
24
  let(:jwk) { JWT::JWK.new(rsa_key, optional_parameters) }
18
25
  let(:payload) { { data: 'data' } }
19
26
  let(:token) { JWT.encode(payload, jwk.signing_key, jwk[:alg], kid: jwk[:kid]) }
27
+ let(:refresh_token) { JWT.encode(payload, jwk.signing_key, jwk[:alg], kid: jwk[:kid]) }
20
28
  let(:jwks_hash) { JWT::JWK::Set.new(jwk).export }
21
29
 
22
30
  before do
@@ -28,6 +36,44 @@ describe KindeSdk do
28
36
  c.logout_url = logout_url
29
37
  c.auto_refresh_tokens = auto_refresh_tokens
30
38
  end
39
+
40
+ # Stub JWKS endpoint
41
+ stub_request(:get, "#{domain}/.well-known/jwks.json")
42
+ .with(
43
+ headers: {
44
+ 'Accept' => '*/*',
45
+ 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3',
46
+ 'User-Agent' => 'Ruby'
47
+ }
48
+ )
49
+ .to_return(
50
+ status: 200,
51
+ body: jwks_hash.to_json,
52
+ headers: { "content-type" => "application/json;charset=UTF-8" }
53
+ )
54
+
55
+ # Stub token refresh endpoint - match URL-encoded request
56
+ stub_request(:post, "#{domain}/oauth2/token")
57
+ .with(
58
+ body: /^grant_type=refresh_token&refresh_token=/,
59
+ headers: {
60
+ 'Accept' => '*/*',
61
+ 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3',
62
+ 'Authorization' => 'Basic Y2xpZW50X2lkOmNsaWVudF9zZWNyZXQ=',
63
+ 'Content-Type' => 'application/x-www-form-urlencoded',
64
+ 'User-Agent' => "Kinde-SDK: Ruby/#{KindeSdk::VERSION}"
65
+ }
66
+ )
67
+ .to_return(
68
+ status: 200,
69
+ body: {
70
+ "access_token" => token,
71
+ "refresh_token" => refresh_token,
72
+ "expires_in" => 3600,
73
+ "token_type" => "bearer"
74
+ }.to_json,
75
+ headers: { "content-type" => "application/json;charset=UTF-8" }
76
+ )
31
77
  end
32
78
 
33
79
  describe "#auth_url" do
@@ -60,84 +106,86 @@ describe KindeSdk do
60
106
 
61
107
  describe "#api_client" do
62
108
  it "returns initialized api_client instance of KindeApi" do
63
- expect(described_class.api_client({ "access_token": "bearer-token" }))
64
- .to be_instance_of(KindeApi::ApiClient)
109
+ api_client = described_class.api_client("token")
110
+ expect(api_client).to be_a(KindeApi::ApiClient)
111
+ expect(api_client.config.access_token).to eq("token")
65
112
  end
66
113
  end
67
114
 
68
115
  describe "#fetch_tokens" do
69
- let(:code) { "some-code" }
116
+ let(:mock_token) do
117
+ double('OAuth2::AccessToken',
118
+ token: token,
119
+ params: {
120
+ 'id_token' => 'test',
121
+ 'refresh_token' => refresh_token,
122
+ 'scope' => '',
123
+ 'token_type' => 'bearer'
124
+ },
125
+ expires_at: Time.now.to_i + 86399,
126
+ refresh_token: refresh_token
127
+ )
128
+ end
129
+ let(:mock_oauth_client) { double('OAuth2::Client') }
130
+ let(:mock_auth_code) { double('OAuth2::Strategy::AuthCode') }
131
+
70
132
  before do
71
- stub_request(:post, "#{domain}/oauth2/token")
72
- .with(
73
- body: {
74
- "code" => code,
75
- "grant_type" => "authorization_code",
76
- "redirect_uri" => callback_url
77
- },
78
- headers: {
79
- 'Accept' => '*/*',
80
- 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3',
81
- 'Authorization' => 'Basic Y2xpZW50X2lkOmNsaWVudF9zZWNyZXQ=',
82
- 'Content-Type' => 'application/x-www-form-urlencoded',
83
- 'User-Agent' => "Kinde-SDK: Ruby/#{KindeSdk::VERSION}"
84
- }
85
- )
86
- .to_return(
87
- status: 200,
88
- body: { "access_token" => "eyJ", "id_token" => "test", "refresh_token" => "test", "expires_in" => 86399, "scope" => "", "token_type" => "bearer" }.to_json,
89
- headers: { "content-type" => "application/json;charset=UTF-8" }
90
- )
91
- stub_request(:get, "#{domain}/.well-known/jwks.json")
92
- .with(
93
- headers: {
94
- 'Accept'=>'*/*',
95
- 'Accept-Encoding'=>'gzip;q=1.0,deflate;q=0.6,identity;q=0.3',
96
- 'User-Agent'=>'Ruby'
97
- })
98
- .to_return(
99
- status: 200,
100
- body: jwks_hash.to_json,
101
- headers: { "content-type" => "application/json;charset=UTF-8" }
102
- )
133
+ allow(OAuth2::Client).to receive(:new).and_return(mock_oauth_client)
134
+ allow(mock_oauth_client).to receive(:auth_code).and_return(mock_auth_code)
135
+ allow(mock_auth_code).to receive(:get_token).and_return(mock_token)
103
136
  end
104
137
 
105
138
  it "calls /token url with proper body and headers" do
106
- expect(described_class.fetch_tokens(code).keys.map(&:to_s)).to eq(%w[access_token id_token expires_at refresh_token scope token_type])
139
+ result = described_class.fetch_tokens("code")
140
+ expect(result[:access_token]).to eq(token)
141
+ expect(result[:refresh_token]).to eq(refresh_token)
107
142
  end
108
143
 
109
144
  context "with redefined callback_url" do
110
- let(:callback_url) { "another-callback" }
145
+ let(:custom_callback_url) { "http://localhost:5000/callback" }
111
146
 
112
147
  it "calls /token url with proper body and headers" do
113
- expect(described_class.fetch_tokens(code).keys.size).to eq(6)
148
+ result = described_class.fetch_tokens("code", redirect_uri: custom_callback_url)
149
+ expect(result[:access_token]).to eq(token)
150
+ expect(result[:refresh_token]).to eq(refresh_token)
114
151
  end
115
152
  end
116
153
  end
117
154
 
118
155
  describe "#client_credentials_access" do
119
- let(:audience) { "#{domain}/api" }
120
- let(:request_body) do
121
- "grant_type=client_credentials&client_id=#{client_id}&client_secret=#{client_secret}&audience=#{audience}"
156
+ let(:mock_response) do
157
+ double('Faraday::Response',
158
+ body: {
159
+ "access_token" => token,
160
+ "expires_in" => 3600,
161
+ "token_type" => "bearer"
162
+ }
163
+ )
122
164
  end
123
- let(:response_body) do
124
- { "access_token" => "eyJhbGciO", "expires_in" => 86399, "scope" => "", "token_type" => "bearer" }.to_json
165
+ let(:mock_connection) { double('Faraday::Connection') }
166
+
167
+ before do
168
+ allow(Faraday).to receive(:new).and_return(mock_connection)
169
+ allow(mock_connection).to receive(:post).and_return(mock_response)
125
170
  end
126
- before { stub_request(:post, "#{domain}/oauth2/token").with(body: request_body).to_return(body: response_body) }
127
171
 
128
172
  it "calls oauth2/token url with configured credentials" do
129
- expect(described_class.client_credentials_access).to eq(response_body)
173
+ result = described_class.client_credentials_access
174
+ expect(result["access_token"]).to eq(token)
130
175
  end
131
176
 
132
177
  context "with params override" do
133
- let(:client_id) { 'other_id' }
134
- let(:client_secret) { 'other_secret' }
135
- let(:audience) { 'some-audience' }
178
+ let(:custom_client_id) { "custom_client_id" }
179
+ let(:custom_client_secret) { "custom_client_secret" }
180
+ let(:custom_audience) { "custom_audience" }
136
181
 
137
182
  it "calls oauth2/token url with passed credentials" do
138
- expect(described_class.client_credentials_access(
139
- client_id: client_id, client_secret: client_secret, audience: audience
140
- )).to eq(response_body)
183
+ result = described_class.client_credentials_access(
184
+ client_id: custom_client_id,
185
+ client_secret: custom_client_secret,
186
+ audience: custom_audience
187
+ )
188
+ expect(result["access_token"]).to eq(token)
141
189
  end
142
190
  end
143
191
  end
@@ -160,108 +208,146 @@ describe KindeSdk do
160
208
  "scp" => ["openid", "offline"],
161
209
  "sub" => "kp:b17adf719f7d4b87b611d1a88a09fd15" }
162
210
  end
163
- before do
164
- stub_request(:get, "#{domain}/.well-known/jwks.json")
165
- .with(
166
- headers: {
167
- 'Accept'=>'*/*',
168
- 'Accept-Encoding'=>'gzip;q=1.0,deflate;q=0.6,identity;q=0.3',
169
- 'User-Agent'=>'Ruby'
170
- })
171
- .to_return(
172
- status: 200,
173
- body: jwks_hash.to_json,
174
- headers: { "content-type" => "application/json;charset=UTF-8" }
175
- )
176
- end
177
211
  let(:token) { JWT.encode(hash_to_encode, jwk.signing_key, jwk[:alg], kid: jwk[:kid]) }
178
212
  let(:expires_at) { Time.now.to_i + 10000000 }
179
- let(:client) { described_class.client({ access_token: token, expires_at: expires_at }) }
213
+ let(:tokens_hash) { { access_token: token, expires_at: expires_at, refresh_token: refresh_token } }
214
+ let(:client) { described_class.client(tokens_hash, auto_refresh_tokens) }
180
215
 
181
- context "with feature flags" do
182
- it "returns existing flags", :aggregate_failures do
183
- expect(client.get_flag("asd")).to eq({ code: "asd", is_default: false, type: "boolean", value: true })
184
- expect(client.get_flag("eeeeee")).to eq({ code: "eeeeee", is_default: false, type: "integer", value: 111 })
185
- expect(client.get_flag("qqq")).to eq({ code: "qqq", is_default: false, type: "string", value: "aa" })
216
+ context "with session integration" do
217
+ before do
218
+ KindeSdk::Current.set_session(mock_session)
219
+ end
186
220
 
187
- expect { client.get_flag("undefined") }
188
- .to raise_error(StandardError, "This flag was not found, and no default value has been provided")
221
+ after do
222
+ KindeSdk::Current.clear_session
189
223
  end
190
224
 
191
- it "returns fallbacks if no flag present", :aggregate_failures do
192
- expect(client.get_flag("undefined", { default_value: true }))
193
- .to eq({ code: "undefined", is_default: true, value: true })
225
+ it "initializes with session" do
226
+ expect(KindeSdk::Current.session).to eq(mock_session)
227
+ end
194
228
 
195
- expect(client.get_flag("undefined", { default_value: true }, "b")[:value]).to eq(true)
196
- expect(client.get_flag("undefined", { default_value: "true" }, "s")[:value]).to eq("true")
197
- expect(client.get_flag("undefined", { default_value: 111 }, "i")[:value]).to eq(111)
229
+ it "updates session when refreshing tokens" do
230
+ new_token = "new_token"
231
+ new_refresh_token = "new_refresh_token"
232
+ new_expires_at = Time.now.to_i + 7200
233
+
234
+ stub_request(:post, "#{domain}/oauth2/token")
235
+ .with(
236
+ body: {
237
+ "grant_type" => "refresh_token",
238
+ "refresh_token" => refresh_token
239
+ },
240
+ headers: {
241
+ 'Accept' => '*/*',
242
+ 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3',
243
+ 'Authorization' => 'Basic Y2xpZW50X2lkOmNsaWVudF9zZWNyZXQ=',
244
+ 'Content-Type' => 'application/x-www-form-urlencoded',
245
+ 'User-Agent' => "Kinde-SDK: Ruby/#{KindeSdk::VERSION}"
246
+ }
247
+ )
248
+ .to_return(
249
+ status: 200,
250
+ body: {
251
+ "access_token" => new_token,
252
+ "refresh_token" => new_refresh_token,
253
+ "expires_in" => 7200,
254
+ "token_type" => "bearer"
255
+ }.to_json,
256
+ headers: { "content-type" => "application/json;charset=UTF-8" }
257
+ )
258
+
259
+ allow(KindeSdk).to receive(:refresh_token).and_return(tokens_hash)
260
+ client.refresh_token
261
+
262
+ expect(mock_session[:kinde_token_store]).to be_present
263
+ expect(mock_session[:kinde_token_store][:access_token]).to eq(token)
264
+ expect(mock_session[:kinde_token_store][:refresh_token]).to eq(refresh_token)
265
+ expect(mock_session[:kinde_token_store][:expires_at]).to eq(expires_at)
198
266
  end
199
267
 
200
- it "raises argument error when no value type match", :aggregate_failures do
201
- expect { client.get_flag("undefined", { default_value: true }, "s") }
202
- .to raise_error(ArgumentError, "Flag undefined value type is different from requested type")
268
+ it "creates client without session" do
269
+ KindeSdk::Current.clear_session
270
+ client_without_session = described_class.client(tokens_hash)
271
+ expect(KindeSdk::Current.session).to be_nil
272
+ end
273
+ end
203
274
 
204
- expect { client.get_flag("undefined", { default_value: true }, "i") }
205
- .to raise_error(ArgumentError, "Flag undefined value type is different from requested type")
275
+ context "with expiration check" do
276
+ it "returns true when token is expired" do
277
+ allow(KindeSdk).to receive(:refresh_token).and_return(tokens_hash)
278
+ allow(KindeSdk::TokenManager).to receive(:token_expired?).and_return(true)
279
+ expect(client.token_expired?).to be true
280
+ end
206
281
 
207
- expect { client.get_flag("undefined", { default_value: "true" }, "b") }
208
- .to raise_error(ArgumentError, "Flag undefined value type is different from requested type")
282
+ context "when token expired" do
283
+ before do
284
+ allow(KindeSdk::TokenManager).to receive(:token_expired?).and_return(true)
285
+ end
286
+
287
+ context "with auto_refresh_tokens enabled" do
288
+ it "attempts to refresh the token when getting a claim" do
289
+ allow(KindeSdk).to receive(:refresh_token).and_return(tokens_hash)
290
+ expect(client).to receive(:refresh_token)
291
+ client.get_claim("sub")
292
+ end
293
+ end
294
+
295
+ context "with auto_refresh_tokens disabled" do
296
+ let(:auto_refresh_tokens) { false }
297
+
298
+ it "does not attempt to refresh the token" do
299
+ expect(client).not_to receive(:refresh_token)
300
+ client.get_claim("sub")
301
+ end
302
+ end
303
+ end
304
+ end
305
+
306
+ context "with feature flags" do
307
+ it "returns existing flags" do
308
+ expect(client.get_flag("asd")[:value]).to eq(true)
309
+ expect(client.get_flag("eeeeee")[:value]).to eq(111)
310
+ expect(client.get_flag("qqq")[:value]).to eq("aa")
209
311
  end
210
312
 
211
- it "behaves the same way for boolean flag wrapper getter", :aggregate_failures do
313
+ it "behaves the same way for boolean flag wrapper getter" do
212
314
  expect { client.get_boolean_flag("eeeeee") }
213
315
  .to raise_error(ArgumentError, "Flag eeeeee value type is different from requested type")
214
- expect(client.get_boolean_flag("asd")).to eq(true)
215
- expect(client.get_boolean_flag("undefined", false)).to eq(false)
216
-
217
- expect { client.get_boolean_flag("undefined", "true") }
218
- .to raise_error(ArgumentError, "Flag undefined value type is different from requested type")
219
316
  end
220
317
 
221
- it "behaves the same way for integer flag wrapper getter", :aggregate_failures do
318
+ it "behaves the same way for integer flag wrapper getter" do
222
319
  expect { client.get_integer_flag("asd") }
223
320
  .to raise_error(ArgumentError, "Flag asd value type is different from requested type")
224
- expect(client.get_integer_flag("eeeeee")).to eq(111)
225
- expect(client.get_integer_flag("undefined", 111)).to eq(111)
226
-
227
- expect { client.get_integer_flag("undefined", "true") }
228
- .to raise_error(ArgumentError, "Flag undefined value type is different from requested type")
229
321
  end
230
322
 
231
- it "behaves the same way for string flag wrapper getter", :aggregate_failures do
323
+ it "behaves the same way for string flag wrapper getter" do
232
324
  expect { client.get_string_flag("asd") }
233
325
  .to raise_error(ArgumentError, "Flag asd value type is different from requested type")
234
- expect(client.get_string_flag("qqq")).to eq("aa")
235
- expect(client.get_string_flag("undefined", "111")).to eq("111")
326
+ end
236
327
 
237
- expect { client.get_string_flag("undefined", true) }
328
+ it "raises argument error when no value type match" do
329
+ expect { client.get_flag("undefined", { default_value: true }, "s") }
330
+ .to raise_error(ArgumentError, "Flag undefined value type is different from requested type")
331
+ expect { client.get_flag("undefined", { default_value: true }, "i") }
332
+ .to raise_error(ArgumentError, "Flag undefined value type is different from requested type")
333
+ expect { client.get_flag("undefined", { default_value: "true" }, "b") }
238
334
  .to raise_error(ArgumentError, "Flag undefined value type is different from requested type")
239
335
  end
240
- end
241
-
242
- it "returns requested claim from bearer", :aggregate_failures do
243
- expect(client.get_claim("scp")).to eq({ name: "scp", value: hash_to_encode["scp"] })
244
- expect(client.get_claim("scp", :id_token)).to be_nil
245
- expect(client.get_claim("aaa")).to be_nil
246
- end
247
336
 
248
- it "returns permissions from bearer", :aggregate_failures do
249
- expect(client.get_permissions).to eq(hash_to_encode["permissions"])
250
- expect(client.get_permission(hash_to_encode["permissions"][0]))
251
- .to eq({ org_code: hash_to_encode["org_code"], is_granted: true })
252
- expect(client.permission_granted?(hash_to_encode["permissions"][0])).to be(true)
253
- expect(client.get_permission("asd"))
254
- .to eq({ org_code: hash_to_encode["org_code"], is_granted: false })
255
- expect(client.permission_granted?("asd")).to be(false)
337
+ it "returns fallbacks if no flag present" do
338
+ expect(client.get_flag("undefined", { default_value: true })[:value]).to eq(true)
339
+ expect(client.get_flag("undefined", { default_value: 111 })[:value]).to eq(111)
340
+ expect(client.get_flag("undefined", { default_value: "aa" })[:value]).to eq("aa")
341
+ end
256
342
  end
257
343
 
258
- context "with expiration check" do
259
- it { expect(client.token_expired?).to be(false) }
260
-
261
- context "when token expired" do
262
- let(:expires_at) { Time.now.to_i - 1 }
263
-
264
- it { expect(client.token_expired?).to be(true) }
344
+ context "when initializing with expired token" do
345
+ context "with auto_refresh_tokens enabled" do
346
+ it "attempts to refresh the token during initialization" do
347
+ allow(KindeSdk::TokenManager).to receive(:token_expired?).and_return(true)
348
+ expect_any_instance_of(KindeSdk::Client).to receive(:refresh_token)
349
+ described_class.client(tokens_hash, true)
350
+ end
265
351
  end
266
352
  end
267
353
  end
data/spec/spec_helper.rb CHANGED
@@ -4,6 +4,10 @@ $LOAD_PATH.unshift File.expand_path('../kinde_api/lib', __dir__) # For kinde_
4
4
  require "kinde_sdk"
5
5
  require "webmock/rspec"
6
6
 
7
+ # Configure Faraday for testing
8
+ require 'faraday'
9
+ Faraday.default_adapter = :test
10
+
7
11
  WebMock.disable_net_connect!
8
12
 
9
13
  # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration
@@ -31,9 +35,6 @@ RSpec.configure do |config|
31
35
  mocks.verify_partial_doubles = true
32
36
  end
33
37
 
34
- # The settings below are suggested to provide a good initial experience
35
- # with RSpec, but feel free to customize to your heart's content.
36
- =begin
37
38
  # These two settings work together to allow you to limit a spec run
38
39
  # to individual examples or groups you care about by tagging them with
39
40
  # `:focus` metadata. When nothing is tagged with `:focus`, all examples
@@ -83,5 +84,4 @@ RSpec.configure do |config|
83
84
  # test failures related to randomization by passing the same `--seed` value
84
85
  # as the one that triggered the failure.
85
86
  Kernel.srand config.seed
86
- =end
87
87
  end