practical 0.1.0 → 3.0.0.pre.alpha1

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.
Files changed (78) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -4
  3. data/app/components/practical/views/flash_messages_component.rb +0 -1
  4. data/app/components/practical/views/form/fallback_errors_section_component.rb +5 -3
  5. data/app/components/practical/views/form/option_label_component.rb +0 -1
  6. data/app/components/practical/views/navigation/breadcrumb_item_component.rb +0 -1
  7. data/app/components/practical/views/navigation/breadcrumbs_component.rb +2 -1
  8. data/app/{controllers/concerns/practical/auth/passkeys → concerns/practical/auth/passkeys/controllers}/emergency_registrations.rb +2 -2
  9. data/app/{controllers/concerns/practical/auth/passkeys → concerns/practical/auth/passkeys/controllers}/web_authn_debug_context.rb +1 -1
  10. data/app/concerns/practical/memberships/controllers/membership_invitations/register_with_passkey.rb +92 -0
  11. data/app/lib/practical/forms/datatables/base.rb +80 -0
  12. data/app/lib/practical/loaders/base.rb +44 -0
  13. data/app/lib/practical/relation_builders/base.rb +35 -0
  14. data/app/lib/practical/test/shared/attachment/models/attachment/base.rb +123 -0
  15. data/app/lib/practical/test/shared/attachment/models/attachment/for_organization.rb +39 -0
  16. data/app/lib/practical/test/shared/attachment/models/organization/has_attachments.rb +12 -0
  17. data/app/lib/practical/test/shared/auth/passkeys/controllers/emergency_registration/base.rb +9 -6
  18. data/app/lib/practical/test/shared/auth/passkeys/controllers/emergency_registration/cross_pollination.rb +49 -0
  19. data/app/lib/practical/test/shared/auth/passkeys/controllers/passkey_management/base.rb +508 -0
  20. data/app/lib/practical/test/shared/auth/passkeys/controllers/reauthentication/base.rb +27 -9
  21. data/app/lib/practical/test/shared/auth/passkeys/controllers/reauthentication/cross_pollination.rb +19 -0
  22. data/app/lib/practical/test/shared/auth/passkeys/controllers/registrations/self_destroy.rb +26 -8
  23. data/app/lib/practical/test/shared/auth/passkeys/controllers/registrations/self_signup.rb +3 -2
  24. data/app/lib/practical/test/shared/auth/passkeys/controllers/registrations/update.rb +55 -19
  25. data/app/lib/practical/test/shared/auth/passkeys/controllers/sessions/cross_pollination.rb +29 -0
  26. data/app/lib/practical/test/shared/auth/passkeys/forms/emergency_registration.rb +0 -1
  27. data/app/lib/practical/test/shared/auth/passkeys/models/{passkey.rb → passkey/base.rb} +1 -1
  28. data/app/lib/practical/test/shared/auth/passkeys/models/passkey/emergency_registration.rb +23 -0
  29. data/app/lib/practical/test/shared/auth/passkeys/models/{resource_with_passkeys.rb → resource_with_passkeys/base.rb} +1 -1
  30. data/app/lib/practical/test/shared/auth/passkeys/models/resource_with_passkeys/emergency_registration.rb +41 -0
  31. data/app/lib/practical/test/shared/memberships/controllers/membership_invitations/base.rb +165 -0
  32. data/app/lib/practical/test/shared/memberships/controllers/membership_invitations/register_with_passkey.rb +417 -0
  33. data/app/lib/practical/test/shared/memberships/controllers/organization/membership.rb +400 -0
  34. data/app/lib/practical/test/shared/memberships/controllers/organization/membership_invitation.rb +148 -0
  35. data/app/lib/practical/test/shared/memberships/controllers/user/membership.rb +119 -0
  36. data/app/lib/practical/test/shared/memberships/controllers/user/membership_invitation.rb +57 -0
  37. data/app/lib/practical/test/shared/memberships/forms/create_new_user_with_membership_invitation.rb +197 -0
  38. data/app/lib/practical/test/shared/memberships/forms/organization/membership.rb +162 -0
  39. data/app/lib/practical/test/shared/memberships/forms/organization/new_membership_invitation.rb +195 -0
  40. data/app/lib/practical/test/shared/memberships/forms/user/membership.rb +87 -0
  41. data/app/lib/practical/test/shared/memberships/models/membership/base.rb +45 -0
  42. data/app/lib/practical/test/shared/memberships/models/membership_invitation/base.rb +85 -0
  43. data/app/lib/practical/test/shared/memberships/models/membership_invitation/sending.rb +76 -0
  44. data/app/lib/practical/test/shared/memberships/models/membership_invitation/use_for_and_notify.rb +55 -0
  45. data/app/lib/practical/test/shared/memberships/models/organization/base.rb +25 -0
  46. data/app/lib/practical/test/shared/memberships/models/user/base.rb +23 -0
  47. data/app/lib/practical/test/shared/memberships/policies/organization/base_resource.rb +29 -0
  48. data/app/lib/practical/test/shared/memberships/policies/organization/membership.rb +103 -0
  49. data/app/lib/practical/test/shared/memberships/policies/organization/membership_invitation.rb +94 -0
  50. data/app/lib/practical/test/shared/memberships/policies/organization/resource/inherits.rb +10 -0
  51. data/app/lib/practical/test/shared/memberships/policies/organization.rb +70 -0
  52. data/app/lib/practical/test/shared/memberships/policies/user/membership.rb +78 -0
  53. data/app/lib/practical/test/shared/memberships/policies/user/membership_invitation.rb +31 -0
  54. data/app/lib/practical/test/shared/models/normalized_email.rb +0 -1
  55. data/app/lib/practical/test/shared/policies/user/base.rb +14 -0
  56. data/app/lib/practical/views/error_handling.rb +2 -0
  57. data/app/lib/practical/views/error_response.rb +27 -0
  58. data/app/lib/practical/views/form_builders/base.rb +5 -4
  59. data/app/lib/practical/views/form_builders/collection_option.rb +5 -0
  60. data/app/lib/practical/views/icon_set.rb +12 -6
  61. data/config/locales/auth.en.yml +18 -0
  62. data/config/locales/memberships.en.yml +129 -0
  63. data/db/seeds/memberships/default.rb +68 -0
  64. data/db/seeds/moderators/default.rb +36 -0
  65. data/db/seeds/setup.rb +16 -0
  66. data/db/seeds/test/cases/membership_invitations.rb +31 -0
  67. data/db/seeds/users/default.rb +17 -15
  68. data/lib/generators/practical/test/shared_test/shared_test_generator.rb +2 -0
  69. data/lib/practical/framework/engine.rb +8 -0
  70. data/lib/practical/helpers/honeybadger_helper.rb +11 -0
  71. data/lib/practical/helpers/selector_helper.rb +8 -0
  72. data/lib/practical/version.rb +1 -1
  73. data/lib/practical/views/element_helper.rb +2 -0
  74. data/lib/practical/views/theme_helper.rb +13 -0
  75. data/lib/practical.rb +4 -1
  76. data/lib/tasks/practical/utility.rake +20 -0
  77. metadata +54 -11
  78. data/lib/tasks/practical/framework_tasks.rake +0 -6
@@ -0,0 +1,417 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Practical::Test::Shared::Memberships::Controllers::MembershipInvitations::RegisterWithPasskey
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ test "new registration challenge action returns a registration challenge" do
8
+ email = Faker::Internet.email
9
+ passkey_label = SecureRandom.hex
10
+
11
+ params = params_for_registration_challenge(email: email, passkey_label: passkey_label)
12
+ new_registration_challenge_action(token: visible_unused_token, params: params)
13
+ assert_response :ok
14
+ webauthn_id = response.parsed_body["user"]["id"]
15
+ user_data = expected_user_data_for_challenge(email: email, webauthn_id: webauthn_id, name: email)
16
+
17
+ assert_passkey_registration_challenge(
18
+ data: response.parsed_body,
19
+ stored_challenge: expected_stored_challenge,
20
+ relying_party_data: expected_relying_party_data,
21
+ user_data: user_data,
22
+ credentials_to_exclude: []
23
+ )
24
+ end
25
+
26
+ test "new registration challenge action requires an email" do
27
+ email = ""
28
+ passkey_label = SecureRandom.hex
29
+
30
+ params = params_for_registration_challenge(email: email, passkey_label: passkey_label)
31
+ new_registration_challenge_action(token: visible_unused_token, params: params)
32
+ assert_response :bad_request
33
+ assert_email_missing_error_message
34
+ end
35
+
36
+ test "new registration challenge action requires a passkey_label" do
37
+ email = Faker::Internet.email
38
+ passkey_label = " "
39
+
40
+ params = params_for_registration_challenge(email: email, passkey_label: passkey_label)
41
+ new_registration_challenge_action(token: visible_unused_token, params: params)
42
+ assert_response :bad_request
43
+ assert_passkey_label_missing_error_message
44
+ end
45
+
46
+ test "new registration challenge action returns 404 if a used membership_invitation token is given" do
47
+ email = Faker::Internet.email
48
+ passkey_label = SecureRandom.hex
49
+
50
+ params = params_for_registration_challenge(email: email, passkey_label: passkey_label)
51
+ new_registration_challenge_action(token: used_token, params: params)
52
+ assert_response :not_found
53
+ end
54
+
55
+ test "new registration challenge action returns 404 if a hidden membership_invitation token is given" do
56
+ email = Faker::Internet.email
57
+ passkey_label = SecureRandom.hex
58
+
59
+ params = params_for_registration_challenge(email: email, passkey_label: passkey_label)
60
+ new_registration_challenge_action(token: hidden_token, params: params)
61
+ assert_response :not_found
62
+ end
63
+
64
+ test "new registration challenge action aises ActiveSupport::MessageVerifier::InvalidSignature if a bad token is given" do
65
+ email = Faker::Internet.email
66
+ passkey_label = SecureRandom.hex
67
+
68
+ params = params_for_registration_challenge(email: email, passkey_label: passkey_label)
69
+
70
+ assert_raises ActiveSupport::MessageVerifier::InvalidSignature do
71
+ new_registration_challenge_action(token: bad_token, params: params)
72
+ end
73
+ end
74
+
75
+ test "new registration challenge action aises ActiveSupport::MessageVerifier::InvalidSignature if a raw token is given" do
76
+ email = Faker::Internet.email
77
+ passkey_label = SecureRandom.hex
78
+
79
+ params = params_for_registration_challenge(email: email, passkey_label: passkey_label)
80
+
81
+ assert_raises ActiveSupport::MessageVerifier::InvalidSignature do
82
+ new_registration_challenge_action(token: raw_token, params: params)
83
+ end
84
+ end
85
+
86
+ test "create_user_and_use: action creates a new resource and links the invitation to the resource" do
87
+ email = Faker::Internet.email
88
+ passkey_label = SecureRandom.hex
89
+ token = visible_unused_token
90
+
91
+ params = params_for_registration_challenge(email: email, passkey_label: passkey_label)
92
+ new_registration_challenge_action(token: token, params: params)
93
+ assert_response :ok
94
+ webauthn_id = response.parsed_body["user"]["id"]
95
+ user_data = expected_user_data_for_challenge(email: email, webauthn_id: webauthn_id, name: email)
96
+
97
+ assert_passkey_registration_challenge(
98
+ data: response.parsed_body,
99
+ stored_challenge: expected_stored_challenge,
100
+ relying_party_data: expected_relying_party_data,
101
+ user_data: user_data,
102
+ credentials_to_exclude: []
103
+ )
104
+
105
+ client = webauthn_client
106
+ challenge = expected_stored_challenge
107
+ raw_credential = create_credential_and_return_payload_from_challenge(client: client, challenge: challenge)
108
+
109
+ params = params_for_registration(email: email, passkey_label: passkey_label, raw_credential: raw_credential)
110
+
111
+ assert_difference "#{passkey_class}.count", +1 do
112
+ assert_difference "#{resource_class}.count", +1 do
113
+ assert_difference "#{membership_class}.count", +1 do
114
+ create_resource_action(token: token, params: params)
115
+ assert_redirected_to create_resource_success_url
116
+ end
117
+ end
118
+ end
119
+
120
+ new_resource = resource_class.last
121
+ credential = hydrate_response_from_raw_credential(client: client, relying_party: webauthn_relying_party,
122
+ raw_credential: raw_credential).credential
123
+
124
+ new_passkey = new_resource.passkeys.last
125
+ assert_equal passkey_label, new_passkey.label
126
+ assert_equal Base64.strict_encode64(credential.id), new_passkey.external_id
127
+ assert_not_nil new_passkey.public_key
128
+ assert_nil new_passkey.last_used_at
129
+
130
+ assert_resource_created_flash_message
131
+ assert_user_created_and_membership_accepted
132
+ end
133
+
134
+ test "create_user_and_use: action does not create a duplicate resource" do
135
+ email = resource_instance.email
136
+ passkey_label = SecureRandom.hex
137
+ token = visible_unused_token
138
+
139
+ params = params_for_registration_challenge(email: email, passkey_label: passkey_label)
140
+ new_registration_challenge_action(token: token, params: params)
141
+ assert_response :ok
142
+ webauthn_id = response.parsed_body["user"]["id"]
143
+ user_data = expected_user_data_for_challenge(email: email, webauthn_id: webauthn_id, name: email)
144
+
145
+ assert_passkey_registration_challenge(
146
+ data: response.parsed_body,
147
+ stored_challenge: expected_stored_challenge,
148
+ relying_party_data: expected_relying_party_data,
149
+ user_data: user_data,
150
+ credentials_to_exclude: []
151
+ )
152
+
153
+ client = webauthn_client
154
+ challenge = expected_stored_challenge
155
+ raw_credential = create_credential_and_return_payload_from_challenge(client: client, challenge: challenge)
156
+
157
+ params = params_for_registration(email: email, passkey_label: passkey_label, raw_credential: raw_credential)
158
+
159
+ assert_no_difference "#{passkey_class}.count" do
160
+ assert_no_difference "#{resource_class}.count" do
161
+ assert_no_difference "#{membership_class}.count" do
162
+ create_resource_action(token: token, params: params)
163
+ assert_response :unprocessable_entity
164
+ end
165
+ end
166
+ end
167
+ end
168
+
169
+ test "create_user_and_use: action requires an email" do
170
+ email = Faker::Internet.email
171
+ passkey_label = SecureRandom.hex
172
+ token = visible_unused_token
173
+
174
+ params = params_for_registration_challenge(email: email, passkey_label: passkey_label)
175
+ new_registration_challenge_action(token: token, params: params)
176
+ assert_response :ok
177
+ webauthn_id = response.parsed_body["user"]["id"]
178
+ user_data = expected_user_data_for_challenge(email: email, webauthn_id: webauthn_id, name: email)
179
+
180
+ assert_passkey_registration_challenge(
181
+ data: response.parsed_body,
182
+ stored_challenge: expected_stored_challenge,
183
+ relying_party_data: expected_relying_party_data,
184
+ user_data: user_data,
185
+ credentials_to_exclude: []
186
+ )
187
+
188
+ client = webauthn_client
189
+ challenge = expected_stored_challenge
190
+ raw_credential = create_credential_and_return_payload_from_challenge(client: client, challenge: challenge)
191
+
192
+ params = params_for_registration(email: " ", passkey_label: passkey_label, raw_credential: raw_credential)
193
+
194
+ assert_no_difference "#{passkey_class}.count" do
195
+ assert_no_difference "#{resource_class}.count" do
196
+ assert_no_difference "#{membership_class}.count" do
197
+ create_resource_action(token: token, params: params)
198
+ assert_response :unprocessable_entity
199
+ end
200
+ end
201
+ end
202
+ end
203
+
204
+ test "create_user_and_use: action requires a passkey_label" do
205
+ email = Faker::Internet.email
206
+ passkey_label = SecureRandom.hex
207
+ token = visible_unused_token
208
+
209
+ params = params_for_registration_challenge(email: email, passkey_label: passkey_label)
210
+ new_registration_challenge_action(token: token, params: params)
211
+ assert_response :ok
212
+ webauthn_id = response.parsed_body["user"]["id"]
213
+ user_data = expected_user_data_for_challenge(email: email, webauthn_id: webauthn_id, name: email)
214
+
215
+ assert_passkey_registration_challenge(
216
+ data: response.parsed_body,
217
+ stored_challenge: expected_stored_challenge,
218
+ relying_party_data: expected_relying_party_data,
219
+ user_data: user_data,
220
+ credentials_to_exclude: []
221
+ )
222
+
223
+ client = webauthn_client
224
+ challenge = expected_stored_challenge
225
+ raw_credential = create_credential_and_return_payload_from_challenge(client: client, challenge: challenge)
226
+
227
+ params = params_for_registration(email: email, passkey_label: " ", raw_credential: raw_credential)
228
+
229
+ assert_no_difference "#{passkey_class}.count" do
230
+ assert_no_difference "#{resource_class}.count" do
231
+ assert_no_difference "#{membership_class}.count" do
232
+ create_resource_action(token: token, params: params)
233
+ assert_response :unprocessable_entity
234
+ end
235
+ end
236
+ end
237
+ end
238
+
239
+ test "create_user_and_use: action requires a passkey_credential" do
240
+ email = Faker::Internet.email
241
+ passkey_label = SecureRandom.hex
242
+ token = visible_unused_token
243
+
244
+ params = params_for_registration_challenge(email: email, passkey_label: passkey_label)
245
+ new_registration_challenge_action(token: token, params: params)
246
+ assert_response :ok
247
+ webauthn_id = response.parsed_body["user"]["id"]
248
+ user_data = expected_user_data_for_challenge(email: email, webauthn_id: webauthn_id, name: email)
249
+
250
+ assert_passkey_registration_challenge(
251
+ data: response.parsed_body,
252
+ stored_challenge: expected_stored_challenge,
253
+ relying_party_data: expected_relying_party_data,
254
+ user_data: user_data,
255
+ credentials_to_exclude: []
256
+ )
257
+
258
+ client = webauthn_client
259
+ challenge = expected_stored_challenge
260
+ raw_credential = create_credential_and_return_payload_from_challenge(client: client, challenge: challenge)
261
+
262
+ params = params_for_registration(email: email, passkey_label: passkey_label, raw_credential: " ")
263
+
264
+ assert_no_difference "#{passkey_class}.count" do
265
+ assert_no_difference "#{resource_class}.count" do
266
+ assert_no_difference "#{membership_class}.count" do
267
+ assert_raises NoMethodError do
268
+ create_resource_action(token: token, params: params)
269
+ end
270
+ end
271
+ end
272
+ end
273
+ end
274
+
275
+ test "create_user_and_use: returns 404 if a used membership_invitation token is given" do
276
+ email = Faker::Internet.email
277
+ passkey_label = SecureRandom.hex
278
+ token = visible_unused_token
279
+
280
+ params = params_for_registration_challenge(email: email, passkey_label: passkey_label)
281
+ new_registration_challenge_action(token: token, params: params)
282
+ assert_response :ok
283
+ webauthn_id = response.parsed_body["user"]["id"]
284
+ user_data = expected_user_data_for_challenge(email: email, webauthn_id: webauthn_id, name: email)
285
+
286
+ assert_passkey_registration_challenge(
287
+ data: response.parsed_body,
288
+ stored_challenge: expected_stored_challenge,
289
+ relying_party_data: expected_relying_party_data,
290
+ user_data: user_data,
291
+ credentials_to_exclude: []
292
+ )
293
+
294
+ client = webauthn_client
295
+ challenge = expected_stored_challenge
296
+ raw_credential = create_credential_and_return_payload_from_challenge(client: client, challenge: challenge)
297
+
298
+ params = params_for_registration(email: email, passkey_label: passkey_label, raw_credential: raw_credential)
299
+
300
+ assert_no_difference "#{passkey_class}.count" do
301
+ assert_no_difference "#{resource_class}.count" do
302
+ assert_no_difference "#{membership_class}.count" do
303
+ create_resource_action(token: used_token, params: params)
304
+ assert_response :not_found
305
+ end
306
+ end
307
+ end
308
+ end
309
+
310
+ test "create_user_and_use: returns 404 if a hidden membership_invitation token is given" do
311
+ email = Faker::Internet.email
312
+ passkey_label = SecureRandom.hex
313
+ token = visible_unused_token
314
+
315
+ params = params_for_registration_challenge(email: email, passkey_label: passkey_label)
316
+ new_registration_challenge_action(token: token, params: params)
317
+ assert_response :ok
318
+ webauthn_id = response.parsed_body["user"]["id"]
319
+ user_data = expected_user_data_for_challenge(email: email, webauthn_id: webauthn_id, name: email)
320
+
321
+ assert_passkey_registration_challenge(
322
+ data: response.parsed_body,
323
+ stored_challenge: expected_stored_challenge,
324
+ relying_party_data: expected_relying_party_data,
325
+ user_data: user_data,
326
+ credentials_to_exclude: []
327
+ )
328
+
329
+ client = webauthn_client
330
+ challenge = expected_stored_challenge
331
+ raw_credential = create_credential_and_return_payload_from_challenge(client: client, challenge: challenge)
332
+
333
+ params = params_for_registration(email: email, passkey_label: passkey_label, raw_credential: raw_credential)
334
+
335
+ assert_no_difference "#{passkey_class}.count" do
336
+ assert_no_difference "#{resource_class}.count" do
337
+ assert_no_difference "#{membership_class}.count" do
338
+ create_resource_action(token: hidden_token, params: params)
339
+ assert_response :not_found
340
+ end
341
+ end
342
+ end
343
+ end
344
+
345
+ test "create_user_and_use: raises ActiveSupport::MessageVerifier::InvalidSignature if a bad token is given" do
346
+ email = Faker::Internet.email
347
+ passkey_label = SecureRandom.hex
348
+ token = visible_unused_token
349
+
350
+ params = params_for_registration_challenge(email: email, passkey_label: passkey_label)
351
+ new_registration_challenge_action(token: token, params: params)
352
+ assert_response :ok
353
+ webauthn_id = response.parsed_body["user"]["id"]
354
+ user_data = expected_user_data_for_challenge(email: email, webauthn_id: webauthn_id, name: email)
355
+
356
+ assert_passkey_registration_challenge(
357
+ data: response.parsed_body,
358
+ stored_challenge: expected_stored_challenge,
359
+ relying_party_data: expected_relying_party_data,
360
+ user_data: user_data,
361
+ credentials_to_exclude: []
362
+ )
363
+
364
+ client = webauthn_client
365
+ challenge = expected_stored_challenge
366
+ raw_credential = create_credential_and_return_payload_from_challenge(client: client, challenge: challenge)
367
+
368
+ params = params_for_registration(email: email, passkey_label: passkey_label, raw_credential: raw_credential)
369
+
370
+ assert_no_difference "#{passkey_class}.count" do
371
+ assert_no_difference "#{resource_class}.count" do
372
+ assert_no_difference "#{membership_class}.count" do
373
+ assert_raises ActiveSupport::MessageVerifier::InvalidSignature do
374
+ create_resource_action(token: bad_token, params: params)
375
+ end
376
+ end
377
+ end
378
+ end
379
+ end
380
+
381
+ test "create_user_and_use: raises ActiveSupport::MessageVerifier::InvalidSignature if a raw token is given" do
382
+ email = Faker::Internet.email
383
+ passkey_label = SecureRandom.hex
384
+ token = visible_unused_token
385
+
386
+ params = params_for_registration_challenge(email: email, passkey_label: passkey_label)
387
+ new_registration_challenge_action(token: token, params: params)
388
+ assert_response :ok
389
+ webauthn_id = response.parsed_body["user"]["id"]
390
+ user_data = expected_user_data_for_challenge(email: email, webauthn_id: webauthn_id, name: email)
391
+
392
+ assert_passkey_registration_challenge(
393
+ data: response.parsed_body,
394
+ stored_challenge: expected_stored_challenge,
395
+ relying_party_data: expected_relying_party_data,
396
+ user_data: user_data,
397
+ credentials_to_exclude: []
398
+ )
399
+
400
+ client = webauthn_client
401
+ challenge = expected_stored_challenge
402
+ raw_credential = create_credential_and_return_payload_from_challenge(client: client, challenge: challenge)
403
+
404
+ params = params_for_registration(email: email, passkey_label: passkey_label, raw_credential: raw_credential)
405
+
406
+ assert_no_difference "#{passkey_class}.count" do
407
+ assert_no_difference "#{resource_class}.count" do
408
+ assert_no_difference "#{membership_class}.count" do
409
+ assert_raises ActiveSupport::MessageVerifier::InvalidSignature do
410
+ create_resource_action(token: raw_token, params: params)
411
+ end
412
+ end
413
+ end
414
+ end
415
+ end
416
+ end
417
+ end