descope 1.0.6 → 1.0.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/.github/workflows/ci.yaml +2 -2
- data/.github/workflows/publish-gem.yaml +10 -3
- data/.gitignore +2 -0
- data/.ruby-version +1 -1
- data/Gemfile +7 -7
- data/Gemfile.lock +68 -55
- data/README.md +159 -51
- data/examples/ruby-on-rails-api/descope/Gemfile +8 -8
- data/examples/ruby-on-rails-api/descope/Gemfile.lock +1 -1
- data/examples/ruby-on-rails-api/descope/package-lock.json +187 -131
- data/examples/ruby-on-rails-api/descope/package.json +1 -1
- data/examples/ruby-on-rails-api/descope/yarn.lock +182 -84
- data/lib/descope/api/v1/auth/enchantedlink.rb +3 -1
- data/lib/descope/api/v1/auth/magiclink.rb +3 -1
- data/lib/descope/api/v1/auth/otp.rb +3 -1
- data/lib/descope/api/v1/auth/password.rb +6 -2
- data/lib/descope/api/v1/auth/totp.rb +3 -1
- data/lib/descope/api/v1/auth.rb +47 -12
- data/lib/descope/api/v1/management/common.rb +20 -5
- data/lib/descope/api/v1/management/sso_application.rb +236 -0
- data/lib/descope/api/v1/management/sso_settings.rb +2 -24
- data/lib/descope/api/v1/management/user.rb +151 -13
- data/lib/descope/api/v1/management.rb +2 -0
- data/lib/descope/api/v1/session.rb +37 -4
- data/lib/descope/mixins/common.rb +1 -0
- data/lib/descope/mixins/http.rb +60 -9
- data/lib/descope/mixins/initializer.rb +2 -1
- data/lib/descope/mixins/logging.rb +12 -4
- data/lib/descope/version.rb +1 -1
- data/spec/descope/api/v1/auth_spec.rb +29 -0
- data/spec/descope/api/v1/auth_token_extraction_spec.rb +126 -0
- data/spec/descope/api/v1/session_refresh_spec.rb +98 -0
- data/spec/factories/user.rb +1 -1
- data/spec/integration/lib.descope/api/v1/auth/enchantedlink_spec.rb +1 -1
- data/spec/integration/lib.descope/api/v1/auth/magiclink_spec.rb +1 -1
- data/spec/integration/lib.descope/api/v1/auth/otp_spec.rb +1 -1
- data/spec/integration/lib.descope/api/v1/auth/session_spec.rb +49 -0
- data/spec/integration/lib.descope/api/v1/auth/totp_spec.rb +1 -1
- data/spec/integration/lib.descope/api/v1/management/access_key_spec.rb +3 -0
- data/spec/integration/lib.descope/api/v1/management/audit_spec.rb +5 -3
- data/spec/integration/lib.descope/api/v1/management/authz_spec.rb +2 -0
- data/spec/integration/lib.descope/api/v1/management/flow_spec.rb +3 -1
- data/spec/integration/lib.descope/api/v1/management/permissions_spec.rb +4 -2
- data/spec/integration/lib.descope/api/v1/management/project_spec.rb +2 -0
- data/spec/integration/lib.descope/api/v1/management/roles_spec.rb +2 -0
- data/spec/integration/lib.descope/api/v1/management/user_spec.rb +55 -6
- data/spec/lib.descope/api/v1/auth/enchantedlink_spec.rb +11 -2
- data/spec/lib.descope/api/v1/auth/password_spec.rb +10 -1
- data/spec/lib.descope/api/v1/auth_spec.rb +167 -5
- data/spec/lib.descope/api/v1/cookie_domain_fix_integration_spec.rb +245 -0
- data/spec/lib.descope/api/v1/management/sso_application_spec.rb +217 -0
- data/spec/lib.descope/api/v1/management/sso_settings_spec.rb +2 -2
- data/spec/lib.descope/api/v1/management/user_spec.rb +134 -46
- data/spec/lib.descope/api/v1/session_spec.rb +119 -6
- data/spec/lib.descope/mixins/http_spec.rb +218 -0
- data/spec/support/client_config.rb +0 -1
- data/spec/support/utils.rb +6 -0
- metadata +13 -8
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
describe Descope::Mixins::HTTP do
|
|
6
|
+
before(:all) do
|
|
7
|
+
dummy_instance = DummyClass.new
|
|
8
|
+
dummy_instance.extend(Descope::Mixins::HTTP)
|
|
9
|
+
@instance = dummy_instance
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
describe '#parse_cookie_value' do
|
|
13
|
+
it 'extracts cookie value from Set-Cookie header' do
|
|
14
|
+
cookie_header = 'DS=jwt_token_value; Path=/; Domain=example.com; HttpOnly; Secure'
|
|
15
|
+
result = @instance.parse_cookie_value(cookie_header, 'DS')
|
|
16
|
+
expect(result).to eq('jwt_token_value')
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
it 'extracts cookie value with complex JWT token' do
|
|
20
|
+
jwt_token = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL2FwaS5kZXNjb3BlLmNvbS9QMmFiY2RlMTIzNDUifQ.signature'
|
|
21
|
+
cookie_header = "DSR=#{jwt_token}; Path=/; Domain=dev.example.com; HttpOnly; Secure; Max-Age=2592000"
|
|
22
|
+
result = @instance.parse_cookie_value(cookie_header, 'DSR')
|
|
23
|
+
expect(result).to eq(jwt_token)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
it 'returns nil when cookie name is not found' do
|
|
27
|
+
cookie_header = 'OTHER=value; Path=/; Domain=example.com'
|
|
28
|
+
result = @instance.parse_cookie_value(cookie_header, 'DS')
|
|
29
|
+
expect(result).to be_nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
it 'handles cookie value with special characters' do
|
|
33
|
+
cookie_header = 'DS=token.with-special_chars123; Path=/; Domain=example.com'
|
|
34
|
+
result = @instance.parse_cookie_value(cookie_header, 'DS')
|
|
35
|
+
expect(result).to eq('token.with-special_chars123')
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it 'handles cookie header with spaces around value' do
|
|
39
|
+
cookie_header = 'DS= spaced_token ; Path=/; Domain=example.com'
|
|
40
|
+
result = @instance.parse_cookie_value(cookie_header, 'DS')
|
|
41
|
+
expect(result).to eq('spaced_token')
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
describe '#safe_parse_json with cookie handling' do
|
|
46
|
+
let(:mock_body) do
|
|
47
|
+
{
|
|
48
|
+
'userId' => 'test123',
|
|
49
|
+
'cookieExpiration' => 1640704758,
|
|
50
|
+
'cookieDomain' => 'dev.example.com'
|
|
51
|
+
}.to_json
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
context 'when RestClient cookies are available (same domain)' do
|
|
55
|
+
it 'uses RestClient cookies when available' do
|
|
56
|
+
mock_cookies = {
|
|
57
|
+
'DS' => 'session_token_from_restclient',
|
|
58
|
+
'DSR' => 'refresh_token_from_restclient'
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
result = @instance.safe_parse_json(mock_body, cookies: mock_cookies, headers: {})
|
|
62
|
+
|
|
63
|
+
expect(result['cookies']).to eq(mock_cookies)
|
|
64
|
+
expect(result['cookies']['DS']).to eq('session_token_from_restclient')
|
|
65
|
+
expect(result['cookies']['DSR']).to eq('refresh_token_from_restclient')
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
it 'handles only refresh token in RestClient cookies' do
|
|
69
|
+
mock_cookies = { 'DSR' => 'refresh_token_only' }
|
|
70
|
+
|
|
71
|
+
result = @instance.safe_parse_json(mock_body, cookies: mock_cookies, headers: {})
|
|
72
|
+
|
|
73
|
+
expect(result['cookies']).to eq(mock_cookies)
|
|
74
|
+
expect(result['cookies']['DSR']).to eq('refresh_token_only')
|
|
75
|
+
expect(result['cookies']['DS']).to be_nil
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
context 'when RestClient cookies are empty (custom domain)' do
|
|
80
|
+
let(:set_cookie_headers) do
|
|
81
|
+
[
|
|
82
|
+
'DS=session_jwt_token; Path=/; Domain=dev.example.com; HttpOnly; Secure; SameSite=None',
|
|
83
|
+
'DSR=refresh_jwt_token; Path=/; Domain=dev.example.com; HttpOnly; Secure; SameSite=None; Max-Age=2592000'
|
|
84
|
+
]
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
it 'parses cookies from Set-Cookie headers when RestClient cookies are empty' do
|
|
88
|
+
mock_headers = { 'set-cookie' => set_cookie_headers }
|
|
89
|
+
|
|
90
|
+
result = @instance.safe_parse_json(mock_body, cookies: {}, headers: mock_headers)
|
|
91
|
+
|
|
92
|
+
expect(result['cookies']).to_not be_nil
|
|
93
|
+
expect(result['cookies']['DS']).to eq('session_jwt_token')
|
|
94
|
+
expect(result['cookies']['DSR']).to eq('refresh_jwt_token')
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
it 'parses cookies from Set-Cookie header when headers is a string' do
|
|
98
|
+
mock_headers = { 'Set-Cookie' => set_cookie_headers.first }
|
|
99
|
+
|
|
100
|
+
result = @instance.safe_parse_json(mock_body, cookies: {}, headers: mock_headers)
|
|
101
|
+
|
|
102
|
+
expect(result['cookies']).to_not be_nil
|
|
103
|
+
expect(result['cookies']['DS']).to eq('session_jwt_token')
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
it 'handles case-insensitive Set-Cookie header names' do
|
|
107
|
+
mock_headers = { 'Set-Cookie' => set_cookie_headers }
|
|
108
|
+
|
|
109
|
+
result = @instance.safe_parse_json(mock_body, cookies: {}, headers: mock_headers)
|
|
110
|
+
|
|
111
|
+
expect(result['cookies']['DS']).to eq('session_jwt_token')
|
|
112
|
+
expect(result['cookies']['DSR']).to eq('refresh_jwt_token')
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
it 'handles complex JWT tokens in Set-Cookie headers' do
|
|
116
|
+
jwt_session = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL2FwaS5kZXNjb3BlLmNvbSJ9.session_sig'
|
|
117
|
+
jwt_refresh = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL2FwaS5kZXNjb3BlLmNvbSJ9.refresh_sig'
|
|
118
|
+
|
|
119
|
+
complex_headers = [
|
|
120
|
+
"DS=#{jwt_session}; Path=/; Domain=custom.example.com; HttpOnly; Secure; SameSite=None",
|
|
121
|
+
"DSR=#{jwt_refresh}; Path=/; Domain=custom.example.com; HttpOnly; Secure; SameSite=None; Max-Age=2592000"
|
|
122
|
+
]
|
|
123
|
+
mock_headers = { 'set-cookie' => complex_headers }
|
|
124
|
+
|
|
125
|
+
result = @instance.safe_parse_json(mock_body, cookies: {}, headers: mock_headers)
|
|
126
|
+
|
|
127
|
+
expect(result['cookies']['DS']).to eq(jwt_session)
|
|
128
|
+
expect(result['cookies']['DSR']).to eq(jwt_refresh)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
it 'ignores non-Descope cookies in Set-Cookie headers' do
|
|
132
|
+
mixed_headers = [
|
|
133
|
+
'DS=session_token; Path=/; Domain=dev.example.com; HttpOnly',
|
|
134
|
+
'CLOUDFLARE_SESSION=cf_token; Path=/; Domain=.example.com; HttpOnly',
|
|
135
|
+
'DSR=refresh_token; Path=/; Domain=dev.example.com; HttpOnly'
|
|
136
|
+
]
|
|
137
|
+
mock_headers = { 'set-cookie' => mixed_headers }
|
|
138
|
+
|
|
139
|
+
result = @instance.safe_parse_json(mock_body, cookies: {}, headers: mock_headers)
|
|
140
|
+
|
|
141
|
+
expect(result['cookies']['DS']).to eq('session_token')
|
|
142
|
+
expect(result['cookies']['DSR']).to eq('refresh_token')
|
|
143
|
+
expect(result['cookies']['CLOUDFLARE_SESSION']).to be_nil
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
context 'when no cookies are available anywhere' do
|
|
148
|
+
it 'does not add cookies key to response' do
|
|
149
|
+
result = @instance.safe_parse_json(mock_body, cookies: {}, headers: {})
|
|
150
|
+
|
|
151
|
+
expect(result).not_to have_key('cookies')
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
it 'handles missing Set-Cookie headers gracefully' do
|
|
155
|
+
mock_headers = { 'content-type' => 'application/json' }
|
|
156
|
+
|
|
157
|
+
result = @instance.safe_parse_json(mock_body, cookies: {}, headers: mock_headers)
|
|
158
|
+
|
|
159
|
+
expect(result).not_to have_key('cookies')
|
|
160
|
+
expect(result['userId']).to eq('test123')
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
it 'handles nil headers gracefully' do
|
|
164
|
+
result = @instance.safe_parse_json(mock_body, cookies: {}, headers: nil)
|
|
165
|
+
|
|
166
|
+
expect(result).not_to have_key('cookies')
|
|
167
|
+
expect(result['userId']).to eq('test123')
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
context 'edge cases' do
|
|
172
|
+
it 'handles malformed Set-Cookie headers' do
|
|
173
|
+
malformed_headers = [
|
|
174
|
+
'MALFORMED_COOKIE_NO_EQUALS',
|
|
175
|
+
'DS=; Path=/; Domain=example.com', # Empty value
|
|
176
|
+
'=value_without_name; Path=/', # No name
|
|
177
|
+
]
|
|
178
|
+
mock_headers = { 'set-cookie' => malformed_headers }
|
|
179
|
+
|
|
180
|
+
result = @instance.safe_parse_json(mock_body, cookies: {}, headers: mock_headers)
|
|
181
|
+
|
|
182
|
+
# Should handle gracefully and not crash
|
|
183
|
+
expect(result['userId']).to eq('test123')
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
it 'prefers RestClient cookies over Set-Cookie headers when both available' do
|
|
187
|
+
mock_cookies = { 'DS' => 'restclient_token' }
|
|
188
|
+
set_cookie_headers = ['DS=header_token; Path=/; Domain=example.com']
|
|
189
|
+
mock_headers = { 'set-cookie' => set_cookie_headers }
|
|
190
|
+
|
|
191
|
+
result = @instance.safe_parse_json(mock_body, cookies: mock_cookies, headers: mock_headers)
|
|
192
|
+
|
|
193
|
+
# Should prefer RestClient cookies
|
|
194
|
+
expect(result['cookies']['DS']).to eq('restclient_token')
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
describe 'integration with request method' do
|
|
200
|
+
it 'passes headers parameter to safe_parse_json' do
|
|
201
|
+
# Mock RestClient response with custom domain cookies
|
|
202
|
+
mock_response = double('response')
|
|
203
|
+
allow(mock_response).to receive(:code).and_return(200)
|
|
204
|
+
allow(mock_response).to receive(:body).and_return('{"success": true}')
|
|
205
|
+
allow(mock_response).to receive(:cookies).and_return({})
|
|
206
|
+
allow(mock_response).to receive(:headers).and_return({
|
|
207
|
+
'set-cookie' => ['DS=test_token; Domain=custom.example.com']
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
allow(@instance).to receive(:call).and_return(mock_response)
|
|
211
|
+
|
|
212
|
+
result = @instance.request(:get, '/test', {}, {})
|
|
213
|
+
|
|
214
|
+
expect(result['cookies']).to_not be_nil
|
|
215
|
+
expect(result['cookies']['DS']).to eq('test_token')
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
data/spec/support/utils.rb
CHANGED
|
@@ -29,4 +29,10 @@ module SpecUtils
|
|
|
29
29
|
value.each { |v| deep_stringify_keys(v) if v.is_a? Hash } if value.is_a? Array
|
|
30
30
|
end
|
|
31
31
|
end
|
|
32
|
+
|
|
33
|
+
def build_prefix
|
|
34
|
+
# Use GITHUB_RUN_NUMBER as the primary identifier, fall back to a timestamp if not available
|
|
35
|
+
prefix = ENV['GITHUB_RUN_NUMBER'] || ENV['GITHUB_RUN_ID']
|
|
36
|
+
prefix ? "build#{prefix}-" : "local-#{Time.now.to_i}-"
|
|
37
|
+
end
|
|
32
38
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: descope
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.0.
|
|
4
|
+
version: 1.0.7
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Descope Inc.
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: bin
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
13
12
|
- !ruby/object:Gem::Dependency
|
|
14
13
|
name: addressable
|
|
@@ -277,6 +276,7 @@ files:
|
|
|
277
276
|
- lib/descope/api/v1/management/project.rb
|
|
278
277
|
- lib/descope/api/v1/management/role.rb
|
|
279
278
|
- lib/descope/api/v1/management/scim.rb
|
|
279
|
+
- lib/descope/api/v1/management/sso_application.rb
|
|
280
280
|
- lib/descope/api/v1/management/sso_settings.rb
|
|
281
281
|
- lib/descope/api/v1/management/tenant.rb
|
|
282
282
|
- lib/descope/api/v1/management/user.rb
|
|
@@ -294,11 +294,15 @@ files:
|
|
|
294
294
|
- lib/descope_client.rb
|
|
295
295
|
- release-please-config.json
|
|
296
296
|
- renovate.json
|
|
297
|
+
- spec/descope/api/v1/auth_spec.rb
|
|
298
|
+
- spec/descope/api/v1/auth_token_extraction_spec.rb
|
|
299
|
+
- spec/descope/api/v1/session_refresh_spec.rb
|
|
297
300
|
- spec/factories/user.rb
|
|
298
301
|
- spec/integration/lib.descope/api/v1/auth/enchantedlink_spec.rb
|
|
299
302
|
- spec/integration/lib.descope/api/v1/auth/magiclink_spec.rb
|
|
300
303
|
- spec/integration/lib.descope/api/v1/auth/otp_spec.rb
|
|
301
304
|
- spec/integration/lib.descope/api/v1/auth/password_spec.rb
|
|
305
|
+
- spec/integration/lib.descope/api/v1/auth/session_spec.rb
|
|
302
306
|
- spec/integration/lib.descope/api/v1/auth/totp_spec.rb
|
|
303
307
|
- spec/integration/lib.descope/api/v1/management/access_key_spec.rb
|
|
304
308
|
- spec/integration/lib.descope/api/v1/management/audit_spec.rb
|
|
@@ -316,6 +320,7 @@ files:
|
|
|
316
320
|
- spec/lib.descope/api/v1/auth/saml_spec.rb
|
|
317
321
|
- spec/lib.descope/api/v1/auth/totp_spec.rb
|
|
318
322
|
- spec/lib.descope/api/v1/auth_spec.rb
|
|
323
|
+
- spec/lib.descope/api/v1/cookie_domain_fix_integration_spec.rb
|
|
319
324
|
- spec/lib.descope/api/v1/management/access_key_spec.rb
|
|
320
325
|
- spec/lib.descope/api/v1/management/audit_spec.rb
|
|
321
326
|
- spec/lib.descope/api/v1/management/authz_spec.rb
|
|
@@ -325,11 +330,13 @@ files:
|
|
|
325
330
|
- spec/lib.descope/api/v1/management/project_spec.rb
|
|
326
331
|
- spec/lib.descope/api/v1/management/role_spec.rb
|
|
327
332
|
- spec/lib.descope/api/v1/management/scim_spec.rb
|
|
333
|
+
- spec/lib.descope/api/v1/management/sso_application_spec.rb
|
|
328
334
|
- spec/lib.descope/api/v1/management/sso_settings_spec.rb
|
|
329
335
|
- spec/lib.descope/api/v1/management/tenant_spec.rb
|
|
330
336
|
- spec/lib.descope/api/v1/management/user_spec.rb
|
|
331
337
|
- spec/lib.descope/api/v1/session_spec.rb
|
|
332
338
|
- spec/lib.descope/client_spec.rb
|
|
339
|
+
- spec/lib.descope/mixins/http_spec.rb
|
|
333
340
|
- spec/spec_helper.rb
|
|
334
341
|
- spec/support/client_config.rb
|
|
335
342
|
- spec/support/dummy_class.rb
|
|
@@ -339,10 +346,9 @@ licenses:
|
|
|
339
346
|
- MIT
|
|
340
347
|
metadata:
|
|
341
348
|
bug_tracker_uri: https://github.com/descope/descope-ruby-sdk/issues
|
|
342
|
-
changelog_uri: https://github.com/descope/descope-ruby-sdk/releases/tag/1.0.
|
|
349
|
+
changelog_uri: https://github.com/descope/descope-ruby-sdk/releases/tag/1.0.7
|
|
343
350
|
documentation_uri: https://docs.descope.com
|
|
344
|
-
source_code_uri: https://github.com/descope/descope-ruby-sdk/tree/1.0.
|
|
345
|
-
post_install_message:
|
|
351
|
+
source_code_uri: https://github.com/descope/descope-ruby-sdk/tree/1.0.7
|
|
346
352
|
rdoc_options: []
|
|
347
353
|
require_paths:
|
|
348
354
|
- lib
|
|
@@ -357,8 +363,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
357
363
|
- !ruby/object:Gem::Version
|
|
358
364
|
version: '3.5'
|
|
359
365
|
requirements: []
|
|
360
|
-
rubygems_version: 3.
|
|
361
|
-
signing_key:
|
|
366
|
+
rubygems_version: 3.6.9
|
|
362
367
|
specification_version: 4
|
|
363
368
|
summary: Descope Ruby API Client
|
|
364
369
|
test_files: []
|