gds-sso 14.1.1 → 15.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +23 -56
- data/Rakefile +11 -6
- data/app/controllers/api/user_controller.rb +30 -28
- data/app/controllers/authentications_controller.rb +3 -5
- data/app/views/layouts/unauthorised.html.erb +1 -1
- data/config/routes.rb +7 -6
- data/lib/gds-sso.rb +27 -18
- data/lib/gds-sso/api_access.rb +1 -1
- data/lib/gds-sso/bearer_token.rb +24 -24
- data/lib/gds-sso/config.rb +17 -9
- data/lib/gds-sso/controller_methods.rb +7 -8
- data/lib/gds-sso/failure_app.rb +8 -8
- data/lib/gds-sso/lint/user_spec.rb +27 -28
- data/lib/gds-sso/lint/user_test.rb +28 -28
- data/lib/gds-sso/railtie.rb +12 -0
- data/lib/gds-sso/user.rb +13 -13
- data/lib/gds-sso/version.rb +1 -1
- data/lib/gds-sso/warden_config.rb +21 -31
- data/spec/controller/api_user_controller_spec.rb +40 -37
- data/spec/controller/controller_methods_spec.rb +28 -28
- data/spec/internal/app/assets/config/manifest.js +0 -0
- data/spec/internal/app/controllers/application_controller.rb +1 -1
- data/spec/internal/app/controllers/example_controller.rb +1 -2
- data/spec/internal/config/initializers/gds-sso.rb +2 -2
- data/spec/internal/config/routes.rb +5 -2
- data/spec/internal/config/storage.yml +3 -0
- data/spec/internal/db/combustion_test.sqlite +0 -0
- data/spec/internal/db/schema.rb +9 -5
- data/spec/internal/log/test.log +1100 -1166
- data/spec/requests/end_to_end_spec.rb +45 -46
- data/spec/spec_helper.rb +12 -13
- data/spec/support/signon_integration_helpers.rb +9 -7
- data/spec/support/timecop.rb +1 -1
- data/spec/unit/api_access_spec.rb +7 -7
- data/spec/unit/bearer_token_spec.rb +14 -15
- data/spec/unit/config_spec.rb +5 -5
- data/spec/unit/mock_bearer_token_spec.rb +4 -4
- data/spec/unit/railtie_spec.rb +14 -0
- data/spec/unit/session_serialisation_spec.rb +5 -5
- data/spec/unit/user_spec.rb +23 -24
- metadata +88 -54
@@ -1,5 +1,5 @@
|
|
1
|
-
require
|
2
|
-
require
|
1
|
+
require "spec_helper"
|
2
|
+
require "timecop"
|
3
3
|
|
4
4
|
describe "Integration of client using GDS-SSO with signon" do
|
5
5
|
include SignonIntegrationHelpers
|
@@ -11,7 +11,7 @@ describe "Integration of client using GDS-SSO with signon" do
|
|
11
11
|
before :each do
|
12
12
|
# points to an internal app, using combustion gem
|
13
13
|
# see spec/internal
|
14
|
-
@client_host =
|
14
|
+
@client_host = "www.example-client.com"
|
15
15
|
Capybara.current_driver = :mechanize
|
16
16
|
Capybara::Mechanize.local_hosts << @client_host
|
17
17
|
|
@@ -20,96 +20,96 @@ describe "Integration of client using GDS-SSO with signon" do
|
|
20
20
|
|
21
21
|
describe "Web client accesses" do
|
22
22
|
before :each do
|
23
|
-
page.driver.header
|
23
|
+
page.driver.header "accept", "text/html"
|
24
24
|
end
|
25
25
|
|
26
26
|
specify "a non-restricted page can be accessed without authentication" do
|
27
27
|
visit "http://#{@client_host}/"
|
28
|
-
expect(page).to have_content(
|
28
|
+
expect(page).to have_content("jabberwocky")
|
29
29
|
end
|
30
30
|
|
31
31
|
specify "first access to a restricted page requires authentication and application approval" do
|
32
32
|
visit "http://#{@client_host}/restricted"
|
33
33
|
expect(page).to have_content("Sign in")
|
34
|
-
fill_in "Email", :
|
35
|
-
fill_in "Password", :
|
34
|
+
fill_in "Email", with: "test@example-client.com"
|
35
|
+
fill_in "Password", with: "q1w2e3r4t5y6u7i8o9p0"
|
36
36
|
click_on "Sign in"
|
37
37
|
|
38
|
-
expect(page).to have_content(
|
38
|
+
expect(page).to have_content("restricted kablooie")
|
39
39
|
end
|
40
40
|
|
41
41
|
specify "access to a restricted page for an approved application requires only authentication" do
|
42
42
|
# First we login to authorise the app
|
43
43
|
visit "http://#{@client_host}/restricted"
|
44
|
-
fill_in "Email", :
|
45
|
-
fill_in "Password", :
|
44
|
+
fill_in "Email", with: "test@example-client.com"
|
45
|
+
fill_in "Password", with: "q1w2e3r4t5y6u7i8o9p0"
|
46
46
|
click_on "Sign in"
|
47
47
|
|
48
48
|
# At this point the app should be authorised, we reset the session to simulate a new browser visit.
|
49
49
|
reset_session!
|
50
|
-
page.driver.header
|
50
|
+
page.driver.header "accept", "text/html"
|
51
51
|
|
52
52
|
visit "http://#{@client_host}/restricted"
|
53
53
|
expect(page).to have_content("Sign in")
|
54
54
|
|
55
|
-
fill_in "Email", :
|
56
|
-
fill_in "Password", :
|
55
|
+
fill_in "Email", with: "test@example-client.com"
|
56
|
+
fill_in "Password", with: "q1w2e3r4t5y6u7i8o9p0"
|
57
57
|
click_on "Sign in"
|
58
58
|
|
59
|
-
expect(page).to have_content(
|
59
|
+
expect(page).to have_content("restricted kablooie")
|
60
60
|
end
|
61
61
|
|
62
62
|
specify "access to a page that requires signin permission granted" do
|
63
63
|
# First we login to authorise the app
|
64
64
|
visit "http://#{@client_host}/this_requires_signin_permission"
|
65
|
-
fill_in "Email", :
|
66
|
-
fill_in "Password", :
|
65
|
+
fill_in "Email", with: "test@example-client.com"
|
66
|
+
fill_in "Password", with: "q1w2e3r4t5y6u7i8o9p0"
|
67
67
|
click_on "Sign in"
|
68
68
|
|
69
69
|
# At this point the app should be authorised, we reset the session to simulate a new browser visit.
|
70
70
|
reset_session!
|
71
|
-
page.driver.header
|
71
|
+
page.driver.header "accept", "text/html"
|
72
72
|
|
73
73
|
visit "http://#{@client_host}/this_requires_signin_permission"
|
74
74
|
expect(page).to have_content("Sign in")
|
75
75
|
|
76
|
-
fill_in "Email", :
|
77
|
-
fill_in "Password", :
|
76
|
+
fill_in "Email", with: "test@example-client.com"
|
77
|
+
fill_in "Password", with: "q1w2e3r4t5y6u7i8o9p0"
|
78
78
|
click_on "Sign in"
|
79
79
|
|
80
|
-
expect(page).to have_content(
|
80
|
+
expect(page).to have_content("you have signin permission")
|
81
81
|
end
|
82
82
|
|
83
83
|
describe "remotely signed out" do
|
84
84
|
specify "should prevent all access to the application until successful signin" do
|
85
85
|
# First we login and authorise the app
|
86
86
|
visit "http://#{@client_host}/restricted"
|
87
|
-
fill_in "Email", :
|
88
|
-
fill_in "Password", :
|
87
|
+
fill_in "Email", with: "test@example-client.com"
|
88
|
+
fill_in "Password", with: "q1w2e3r4t5y6u7i8o9p0"
|
89
89
|
click_on "Sign in"
|
90
90
|
|
91
|
-
page.driver.header
|
92
|
-
expect(page).to have_content(
|
91
|
+
page.driver.header "accept", "text/html"
|
92
|
+
expect(page).to have_content("restricted kablooie")
|
93
93
|
|
94
94
|
# logout from signon
|
95
95
|
visit "http://localhost:4567/users/sign_out"
|
96
96
|
|
97
97
|
# Simulate a POST to /auth/gds/api/users/:uid/reauth by signon
|
98
98
|
# This is already tested in api_user_controller_spec.rb
|
99
|
-
user = User.where(:
|
99
|
+
user = User.where(email: "test@example-client.com").first
|
100
100
|
user.set_remotely_signed_out!
|
101
101
|
|
102
102
|
# attempt to visit a restricted page
|
103
103
|
visit "http://#{@client_host}/restricted"
|
104
104
|
|
105
105
|
# be redirected to signon
|
106
|
-
expect(page).to have_content(
|
107
|
-
fill_in "Email", :
|
108
|
-
fill_in "Password", :
|
106
|
+
expect(page).to have_content("GOV.UK Signon")
|
107
|
+
fill_in "Email", with: "test@example-client.com"
|
108
|
+
fill_in "Password", with: "q1w2e3r4t5y6u7i8o9p0"
|
109
109
|
click_on "Sign in"
|
110
110
|
|
111
111
|
# then back again to the restricted page
|
112
|
-
expect(page).to have_content(
|
112
|
+
expect(page).to have_content("restricted kablooie")
|
113
113
|
end
|
114
114
|
end
|
115
115
|
|
@@ -117,11 +117,11 @@ describe "Integration of client using GDS-SSO with signon" do
|
|
117
117
|
it "should force you to re-authenticate with signon N hours after login" do
|
118
118
|
visit "http://#{@client_host}/restricted"
|
119
119
|
expect(page).to have_content("Sign in")
|
120
|
-
fill_in "Email", :
|
121
|
-
fill_in "Password", :
|
120
|
+
fill_in "Email", with: "test@example-client.com"
|
121
|
+
fill_in "Password", with: "q1w2e3r4t5y6u7i8o9p0"
|
122
122
|
click_on "Sign in"
|
123
123
|
|
124
|
-
expect(page).to have_content(
|
124
|
+
expect(page).to have_content("restricted kablooie")
|
125
125
|
|
126
126
|
visit "http://localhost:4567/users/sign_out"
|
127
127
|
|
@@ -135,11 +135,11 @@ describe "Integration of client using GDS-SSO with signon" do
|
|
135
135
|
it "should accept signon's remembered authentication N hours after login" do
|
136
136
|
visit "http://#{@client_host}/restricted"
|
137
137
|
expect(page).to have_content("Sign in")
|
138
|
-
fill_in "Email", :
|
139
|
-
fill_in "Password", :
|
138
|
+
fill_in "Email", with: "test@example-client.com"
|
139
|
+
fill_in "Password", with: "q1w2e3r4t5y6u7i8o9p0"
|
140
140
|
click_on "Sign in"
|
141
141
|
|
142
|
-
expect(page).to have_content(
|
142
|
+
expect(page).to have_content("restricted kablooie")
|
143
143
|
|
144
144
|
Timecop.travel(Time.now.utc + GDS::SSO::Config.auth_valid_for + 5.minutes) do
|
145
145
|
visit "http://#{@client_host}/restricted"
|
@@ -148,15 +148,14 @@ describe "Integration of client using GDS-SSO with signon" do
|
|
148
148
|
expect(page).to have_content("restricted kablooie")
|
149
149
|
end
|
150
150
|
|
151
|
-
|
152
151
|
it "should not require re-authentication with signon fewer than N hours after login" do
|
153
152
|
visit "http://#{@client_host}/restricted"
|
154
153
|
expect(page).to have_content("Sign in")
|
155
|
-
fill_in "Email", :
|
156
|
-
fill_in "Password", :
|
154
|
+
fill_in "Email", with: "test@example-client.com"
|
155
|
+
fill_in "Password", with: "q1w2e3r4t5y6u7i8o9p0"
|
157
156
|
click_on "Sign in"
|
158
157
|
|
159
|
-
expect(page).to have_content(
|
158
|
+
expect(page).to have_content("restricted kablooie")
|
160
159
|
|
161
160
|
Timecop.travel(Time.now.utc + GDS::SSO::Config.auth_valid_for - 5.minutes) do
|
162
161
|
visit "http://#{@client_host}/restricted"
|
@@ -169,38 +168,38 @@ describe "Integration of client using GDS-SSO with signon" do
|
|
169
168
|
|
170
169
|
describe "OAuth based API client accesses" do
|
171
170
|
before :each do
|
172
|
-
page.driver.header
|
171
|
+
page.driver.header "accept", "application/json"
|
173
172
|
authorize_signon_api_user
|
174
173
|
|
175
174
|
token = "caaeb53be5c7277fb0ef158181bfd1537b57f9e3b83eb795be3cd0af6e118b28"
|
176
|
-
page.driver.header
|
175
|
+
page.driver.header "authorization", "Bearer #{token}"
|
177
176
|
end
|
178
177
|
|
179
178
|
specify "access to a restricted page for an api client requires auth" do
|
180
|
-
page.driver.header
|
179
|
+
page.driver.header "authorization", "Bearer Bad Token"
|
181
180
|
visit "http://#{@client_host}/restricted"
|
182
181
|
expect(page.driver.response.status).to eq(401)
|
183
182
|
end
|
184
183
|
|
185
184
|
specify "setting a correct bearer token allows sign in" do
|
186
185
|
visit "http://#{@client_host}/restricted"
|
187
|
-
expect(page).to have_content(
|
186
|
+
expect(page).to have_content("restricted kablooie")
|
188
187
|
end
|
189
188
|
|
190
189
|
specify "setting a correct bearer token picks up permissions" do
|
191
190
|
visit "http://#{@client_host}/this_requires_signin_permission"
|
192
|
-
expect(page).to have_content(
|
191
|
+
expect(page).to have_content("you have signin permission")
|
193
192
|
end
|
194
193
|
|
195
194
|
specify "a token for one app cannot be used to access a different app" do
|
196
|
-
page.driver.header
|
195
|
+
page.driver.header "authorization", "Bearer 98c72f4da02fdc43398e029d05567542944d2a9b0df3c20b0accd8bd6c5dc728"
|
197
196
|
visit "http://#{@client_host}/restricted"
|
198
197
|
expect(page.driver.response.status).to eq(401)
|
199
198
|
end
|
200
199
|
end
|
201
200
|
|
202
201
|
context "when in api_only mode" do
|
203
|
-
around
|
202
|
+
around do |examples|
|
204
203
|
GDS::SSO::Config.api_only = true
|
205
204
|
Combustion::Application.reload_routes!
|
206
205
|
examples.run
|
data/spec/spec_helper.rb
CHANGED
@@ -1,21 +1,19 @@
|
|
1
1
|
# Yes, we really do want to turn off the test environment check here.
|
2
2
|
# Bad things happen if we don't ;-)
|
3
|
-
ENV[
|
3
|
+
ENV["GDS_SSO_STRATEGY"] = "real"
|
4
4
|
|
5
|
-
require
|
6
|
-
require
|
7
|
-
require
|
5
|
+
require "bundler/setup"
|
6
|
+
require "combustion"
|
7
|
+
require "capybara/rspec"
|
8
8
|
|
9
9
|
Combustion.initialize! :all
|
10
10
|
|
11
|
-
require
|
12
|
-
require
|
13
|
-
require
|
14
|
-
require
|
11
|
+
require "rspec/rails"
|
12
|
+
require "capybara/rails"
|
13
|
+
require "mechanize"
|
14
|
+
require "capybara/mechanize"
|
15
15
|
|
16
|
-
|
17
|
-
|
18
|
-
Dir[File.join(File.dirname(__FILE__), "support/**/*.rb")].each {|f| require f}
|
16
|
+
Dir[File.join(File.dirname(__FILE__), "support/**/*.rb")].sort.each { |f| require f }
|
19
17
|
|
20
18
|
RSpec.configure do |config|
|
21
19
|
config.run_all_when_everything_filtered = true
|
@@ -25,7 +23,8 @@ RSpec.configure do |config|
|
|
25
23
|
# order dependency and want to debug it, you can fix the order by providing
|
26
24
|
# the seed, which is printed after each run.
|
27
25
|
# --seed 1234
|
28
|
-
config.order =
|
26
|
+
config.order = "random"
|
29
27
|
|
30
|
-
config.include(BackportControllerTestParams) if Rails.version <
|
28
|
+
config.include(BackportControllerTestParams) if Rails.version < "5"
|
29
|
+
config.include(Warden::Test::Helpers)
|
31
30
|
end
|
@@ -1,15 +1,16 @@
|
|
1
|
-
require
|
1
|
+
require "net/http"
|
2
2
|
|
3
3
|
module SignonIntegrationHelpers
|
4
4
|
def wait_for_signon_to_start
|
5
5
|
retries = 0
|
6
6
|
url = GDS::SSO::Config.oauth_root_url
|
7
7
|
puts "Waiting for signon to start at #{url}"
|
8
|
-
|
9
|
-
print
|
8
|
+
until signon_started?(url)
|
9
|
+
print "."
|
10
10
|
if retries > 20
|
11
11
|
raise "Signon is not running at #{url}. Please start with `./start_signon.sh`. Under jenkins this should happen automatically."
|
12
12
|
end
|
13
|
+
|
13
14
|
retries += 1
|
14
15
|
sleep 1
|
15
16
|
end
|
@@ -35,20 +36,21 @@ module SignonIntegrationHelpers
|
|
35
36
|
end
|
36
37
|
|
37
38
|
def load_signon_fixture(filename)
|
38
|
-
require
|
39
|
+
require "erb"
|
39
40
|
parsed = ERB.new(File.read(signon_path + "/config/database.yml")).result
|
40
|
-
db = YAML.
|
41
|
+
db = YAML.safe_load(parsed, aliases: true)["test"]
|
41
42
|
|
42
43
|
cmd = "mysql #{db['database']} -u#{db['username']} -p#{db['password']} < #{fixture_file(filename)}"
|
43
44
|
system cmd or raise "Error loading signon fixture"
|
44
45
|
end
|
45
46
|
|
46
47
|
private
|
48
|
+
|
47
49
|
def fixture_file(filename)
|
48
|
-
File.join(File.dirname(__FILE__),
|
50
|
+
File.join(File.dirname(__FILE__), "../fixtures/integration", filename)
|
49
51
|
end
|
50
52
|
|
51
53
|
def signon_path
|
52
|
-
Rails.root.join(
|
54
|
+
Rails.root.join("..", "..", "tmp", "signon").to_s
|
53
55
|
end
|
54
56
|
end
|
data/spec/support/timecop.rb
CHANGED
@@ -1,17 +1,17 @@
|
|
1
|
-
require
|
2
|
-
require
|
1
|
+
require "spec_helper"
|
2
|
+
require "gds-sso/api_access"
|
3
3
|
|
4
4
|
describe GDS::SSO::ApiAccess do
|
5
5
|
it "should not consider IE7 accept header as an api call" do
|
6
|
-
ie7_accept_header =
|
7
|
-
|
8
|
-
|
9
|
-
expect(GDS::SSO::ApiAccess.api_call?(
|
6
|
+
ie7_accept_header = "image/gif, image/x-xbitmap, image/jpeg, image/pjpeg, " \
|
7
|
+
"application/x-shockwave-flash, application/xaml+xml, application/x-ms-xbap, " \
|
8
|
+
"application/x-ms-application, */*"
|
9
|
+
expect(GDS::SSO::ApiAccess.api_call?("HTTP_ACCEPT" => ie7_accept_header)).to be_falsey
|
10
10
|
end
|
11
11
|
|
12
12
|
context "with a bearer token" do
|
13
13
|
it "it is considered an api call" do
|
14
|
-
expect(GDS::SSO::ApiAccess.api_call?(
|
14
|
+
expect(GDS::SSO::ApiAccess.api_call?("HTTP_AUTHORIZATION" => "Bearer deadbeef12345678")).to be_truthy
|
15
15
|
end
|
16
16
|
end
|
17
17
|
end
|
@@ -1,28 +1,27 @@
|
|
1
|
-
require
|
2
|
-
require
|
1
|
+
require "spec_helper"
|
2
|
+
require "gds-sso/bearer_token"
|
3
3
|
|
4
4
|
describe GDS::SSO::BearerToken do
|
5
|
-
describe
|
6
|
-
it
|
5
|
+
describe ".locate" do
|
6
|
+
it "creates a new user for a token" do
|
7
7
|
response = double(body: {
|
8
8
|
user: {
|
9
|
-
uid:
|
10
|
-
email:
|
11
|
-
name:
|
12
|
-
permissions: [
|
13
|
-
organisation_slug:
|
14
|
-
organisation_content_id:
|
15
|
-
}
|
9
|
+
uid: "asd",
|
10
|
+
email: "user@example.com",
|
11
|
+
name: "A Name",
|
12
|
+
permissions: %w[signin],
|
13
|
+
organisation_slug: "hmrc",
|
14
|
+
organisation_content_id: "67a2b78d-eee3-45b3-80e2-792e7f71cecc",
|
15
|
+
},
|
16
16
|
}.to_json)
|
17
17
|
|
18
|
-
|
19
18
|
allow_any_instance_of(OAuth2::AccessToken).to receive(:get).and_return(response)
|
20
19
|
|
21
|
-
created_user = GDS::SSO::BearerToken.locate(
|
20
|
+
created_user = GDS::SSO::BearerToken.locate("MY-API-TOKEN")
|
22
21
|
|
23
|
-
expect(created_user.email).to eql(
|
22
|
+
expect(created_user.email).to eql("user@example.com")
|
24
23
|
|
25
|
-
same_user_again = GDS::SSO::BearerToken.locate(
|
24
|
+
same_user_again = GDS::SSO::BearerToken.locate("MY-API-TOKEN")
|
26
25
|
|
27
26
|
expect(same_user_again.id).to eql(created_user.id)
|
28
27
|
end
|
data/spec/unit/config_spec.rb
CHANGED
@@ -1,26 +1,26 @@
|
|
1
|
-
require
|
1
|
+
require "spec_helper"
|
2
2
|
|
3
3
|
describe GDS::SSO::Config do
|
4
4
|
describe "#permissions_for_dummy_user" do
|
5
5
|
context "with no additional mock permissions" do
|
6
6
|
it "returns signin" do
|
7
7
|
subject.additional_mock_permissions_required = nil
|
8
|
-
expect(subject.permissions_for_dummy_api_user).to eq([
|
8
|
+
expect(subject.permissions_for_dummy_api_user).to eq(%w[signin])
|
9
9
|
end
|
10
10
|
end
|
11
11
|
|
12
12
|
context "with an additional mock permission as a string" do
|
13
13
|
it "returns an array of permissions" do
|
14
14
|
subject.additional_mock_permissions_required = "internal_app"
|
15
|
-
expected_permissions = [
|
15
|
+
expected_permissions = %w[signin internal_app]
|
16
16
|
expect(subject.permissions_for_dummy_api_user).to eq(expected_permissions)
|
17
17
|
end
|
18
18
|
end
|
19
19
|
|
20
20
|
context "with additional mock permissions as an array" do
|
21
21
|
it "returns an array of permissions" do
|
22
|
-
subject.additional_mock_permissions_required = [
|
23
|
-
expected_permissions = [
|
22
|
+
subject.additional_mock_permissions_required = %w[another_permission yet_another_permission]
|
23
|
+
expected_permissions = %w[signin another_permission yet_another_permission]
|
24
24
|
expect(subject.permissions_for_dummy_api_user).to eq(expected_permissions)
|
25
25
|
end
|
26
26
|
end
|
@@ -1,5 +1,5 @@
|
|
1
|
-
require
|
2
|
-
require
|
1
|
+
require "spec_helper"
|
2
|
+
require "gds-sso/bearer_token"
|
3
3
|
|
4
4
|
describe GDS::SSO::MockBearerToken do
|
5
5
|
it "updates the permissions of the user" do
|
@@ -7,7 +7,7 @@ describe GDS::SSO::MockBearerToken do
|
|
7
7
|
# call .locate to create the dummy user initially
|
8
8
|
GDS::SSO::Config.additional_mock_permissions_required = nil
|
9
9
|
dummy_user = subject.locate("ABC")
|
10
|
-
expect(dummy_user.permissions).to match_array([
|
10
|
+
expect(dummy_user.permissions).to match_array(%w[signin])
|
11
11
|
|
12
12
|
# add an extra permission
|
13
13
|
GDS::SSO::Config.additional_mock_permissions_required = "extra_permission"
|
@@ -17,6 +17,6 @@ describe GDS::SSO::MockBearerToken do
|
|
17
17
|
|
18
18
|
# call .locate again...this should update our permissions
|
19
19
|
dummy_user_two = subject.locate("ABC")
|
20
|
-
expect(dummy_user_two.permissions).to match_array([
|
20
|
+
expect(dummy_user_two.permissions).to match_array(%w[signin extra_permission])
|
21
21
|
end
|
22
22
|
end
|