practical 0.1.0

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 (119) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +37 -0
  3. data/Rakefile +10 -0
  4. data/app/components/practical/views/base_component.rb +6 -0
  5. data/app/components/practical/views/button_component.rb +27 -0
  6. data/app/components/practical/views/datatable/filter_applied.rb +25 -0
  7. data/app/components/practical/views/datatable/filter_section_component.html.erb +9 -0
  8. data/app/components/practical/views/datatable/filter_section_component.rb +19 -0
  9. data/app/components/practical/views/datatable/sort_link_component.rb +48 -0
  10. data/app/components/practical/views/datatable.rb +36 -0
  11. data/app/components/practical/views/flash_messages_component.rb +65 -0
  12. data/app/components/practical/views/form/error_list_component.rb +15 -0
  13. data/app/components/practical/views/form/error_list_item_component.rb +20 -0
  14. data/app/components/practical/views/form/error_list_item_template_component.rb +9 -0
  15. data/app/components/practical/views/form/fallback_errors_section_component.html.erb +7 -0
  16. data/app/components/practical/views/form/fallback_errors_section_component.rb +21 -0
  17. data/app/components/practical/views/form/field_errors_component.rb +28 -0
  18. data/app/components/practical/views/form/field_title_component.rb +23 -0
  19. data/app/components/practical/views/form/fieldset_title_component.rb +20 -0
  20. data/app/components/practical/views/form/input_component.html.erb +7 -0
  21. data/app/components/practical/views/form/input_component.rb +22 -0
  22. data/app/components/practical/views/form/option_label_component.rb +21 -0
  23. data/app/components/practical/views/form/practical_editor_component.rb +26 -0
  24. data/app/components/practical/views/form/required_radio_collection_wrapper_component.rb +23 -0
  25. data/app/components/practical/views/form_wrapper.rb +21 -0
  26. data/app/components/practical/views/icon_component.rb +36 -0
  27. data/app/components/practical/views/icon_for_file_extension_component.rb +53 -0
  28. data/app/components/practical/views/modal_dialog_component.html.erb +10 -0
  29. data/app/components/practical/views/modal_dialog_component.rb +16 -0
  30. data/app/components/practical/views/navigation/breadcrumb_item_component.rb +20 -0
  31. data/app/components/practical/views/navigation/breadcrumbs_component.html.erb +31 -0
  32. data/app/components/practical/views/navigation/breadcrumbs_component.rb +41 -0
  33. data/app/components/practical/views/navigation/navigation_link_component.rb +39 -0
  34. data/app/components/practical/views/navigation/pagination/goto_form_component.html.erb +31 -0
  35. data/app/components/practical/views/navigation/pagination/goto_form_component.rb +34 -0
  36. data/app/components/practical/views/navigation/pagination_component.html.erb +11 -0
  37. data/app/components/practical/views/navigation/pagination_component.rb +98 -0
  38. data/app/components/practical/views/open_dialog_button_component.rb +16 -0
  39. data/app/components/practical/views/page_component.html.erb +53 -0
  40. data/app/components/practical/views/page_component.rb +12 -0
  41. data/app/components/practical/views/relative_time_component.rb +13 -0
  42. data/app/components/practical/views/tiptap_document_component.rb +311 -0
  43. data/app/components/practical/views/toast_component.html.erb +26 -0
  44. data/app/components/practical/views/toast_component.rb +19 -0
  45. data/app/controllers/concerns/practical/auth/passkeys/emergency_registrations.rb +57 -0
  46. data/app/controllers/concerns/practical/auth/passkeys/web_authn_debug_context.rb +13 -0
  47. data/app/controllers/concerns/practical/views/flash_helpers.rb +37 -0
  48. data/app/controllers/concerns/practical/views/json_redirection.rb +7 -0
  49. data/app/lib/practical/defaults/shrine.rb +48 -0
  50. data/app/lib/practical/test/helpers/administrator/test_helpers.rb +7 -0
  51. data/app/lib/practical/test/helpers/extra_assertions.rb +7 -0
  52. data/app/lib/practical/test/helpers/flash_assertions.rb +8 -0
  53. data/app/lib/practical/test/helpers/integration/assertions.rb +23 -0
  54. data/app/lib/practical/test/helpers/passkey/system/base.rb +52 -0
  55. data/app/lib/practical/test/helpers/passkey/system/rack_test.rb +45 -0
  56. data/app/lib/practical/test/helpers/passkey/system/selenium.rb +107 -0
  57. data/app/lib/practical/test/helpers/passkey/test_helper.rb +128 -0
  58. data/app/lib/practical/test/helpers/postmark.rb +11 -0
  59. data/app/lib/practical/test/helpers/relation_builder_assertions.rb +18 -0
  60. data/app/lib/practical/test/helpers/setup/debug.rb +8 -0
  61. data/app/lib/practical/test/helpers/setup/faker_seed_pinning.rb +8 -0
  62. data/app/lib/practical/test/helpers/setup/simplecov.rb +17 -0
  63. data/app/lib/practical/test/helpers/shrine/test_data.rb +101 -0
  64. data/app/lib/practical/test/helpers/spy_assertions.rb +7 -0
  65. data/app/lib/practical/test/helpers/system/assertions.rb +33 -0
  66. data/app/lib/practical/test/helpers/system/capybara_prep.rb +10 -0
  67. data/app/lib/practical/test/shared/auth/passkeys/controllers/emergency_registration/base.rb +372 -0
  68. data/app/lib/practical/test/shared/auth/passkeys/controllers/emergency_registration/self_service.rb +66 -0
  69. data/app/lib/practical/test/shared/auth/passkeys/controllers/reauthentication/base.rb +119 -0
  70. data/app/lib/practical/test/shared/auth/passkeys/controllers/registrations/no_self_destroy.rb +13 -0
  71. data/app/lib/practical/test/shared/auth/passkeys/controllers/registrations/no_self_signup.rb +22 -0
  72. data/app/lib/practical/test/shared/auth/passkeys/controllers/registrations/self_destroy.rb +134 -0
  73. data/app/lib/practical/test/shared/auth/passkeys/controllers/registrations/self_signup.rb +221 -0
  74. data/app/lib/practical/test/shared/auth/passkeys/controllers/registrations/update.rb +220 -0
  75. data/app/lib/practical/test/shared/auth/passkeys/controllers/sessions/base.rb +108 -0
  76. data/app/lib/practical/test/shared/auth/passkeys/forms/emergency_registration.rb +82 -0
  77. data/app/lib/practical/test/shared/auth/passkeys/models/emergency_registration/base.rb +89 -0
  78. data/app/lib/practical/test/shared/auth/passkeys/models/emergency_registration/use_for_and_notify.rb +48 -0
  79. data/app/lib/practical/test/shared/auth/passkeys/models/passkey.rb +101 -0
  80. data/app/lib/practical/test/shared/auth/passkeys/models/resource_with_passkeys.rb +57 -0
  81. data/app/lib/practical/test/shared/auth/passkeys/policies/passkey.rb +18 -0
  82. data/app/lib/practical/test/shared/auth/passkeys/services/send_emergency_registration.rb +41 -0
  83. data/app/lib/practical/test/shared/models/normalized_email.rb +23 -0
  84. data/app/lib/practical/test/shared/models/user.rb +27 -0
  85. data/app/lib/practical/test/shared/models/utility/ip_address.rb +42 -0
  86. data/app/lib/practical/test/shared/models/utility/user_agent.rb +43 -0
  87. data/app/lib/practical/views/button/styling.rb +23 -0
  88. data/app/lib/practical/views/error_handling.rb +33 -0
  89. data/app/lib/practical/views/form_builders/base.rb +152 -0
  90. data/app/lib/practical/views/icon_set.rb +156 -0
  91. data/app/lib/practical/views/web_awesome/style_utility/appearance_variant.rb +19 -0
  92. data/app/lib/practical/views/web_awesome/style_utility/base.rb +21 -0
  93. data/app/lib/practical/views/web_awesome/style_utility/color_variant.rb +17 -0
  94. data/app/lib/practical/views/web_awesome/style_utility/size.rb +31 -0
  95. data/config/locales/auth.en.yml +38 -0
  96. data/config/locales/devise.passkeys.en.yml +18 -0
  97. data/config/locales/practical_framework.en.yml +9 -0
  98. data/config/routes.rb +4 -0
  99. data/db/seeds/setup.rb +13 -0
  100. data/db/seeds/users/default.rb +34 -0
  101. data/lib/generators/practical/test/helper/USAGE +8 -0
  102. data/lib/generators/practical/test/helper/helper_generator.rb +9 -0
  103. data/lib/generators/practical/test/helper/templates/helper.rb.tt +4 -0
  104. data/lib/generators/practical/test/shared_test/USAGE +9 -0
  105. data/lib/generators/practical/test/shared_test/shared_test_generator.rb +7 -0
  106. data/lib/generators/practical/test/shared_test/templates/shared_test.rb.tt +9 -0
  107. data/lib/generators/practical/views/component/USAGE +9 -0
  108. data/lib/generators/practical/views/component/component_generator.rb +20 -0
  109. data/lib/practical/framework/engine.rb +35 -0
  110. data/lib/practical/helpers/form_with_helper.rb +10 -0
  111. data/lib/practical/helpers/icon_helper.rb +18 -0
  112. data/lib/practical/helpers/text_helper.rb +20 -0
  113. data/lib/practical/helpers/translation_helper.rb +25 -0
  114. data/lib/practical/version.rb +5 -0
  115. data/lib/practical/views/element_helper.rb +48 -0
  116. data/lib/practical.rb +21 -0
  117. data/lib/tasks/practical/coverage.rake +19 -0
  118. data/lib/tasks/practical/framework_tasks.rake +6 -0
  119. metadata +303 -0
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Practical::Test::Shared::Auth::Passkeys::Controllers::Registrations::SelfDestroy
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ test "destroy deletes the given resource when reauthenticated" do
8
+ sign_in_as_resource
9
+
10
+ client = webauthn_client
11
+ create_passkey_for_user_and_return_webauthn_credential(user: resource_instance)
12
+
13
+ new_reauthentication_challenge_action
14
+ assert_response :ok
15
+ assert_reauthentication_token_challenge
16
+
17
+ challenge = response.parsed_body["challenge"]
18
+ credential = get_credential_payload_from_challenge(client: client, challenge: challenge)
19
+
20
+ reauthenticate_action(params: {passkey_credential: credential.to_json})
21
+ assert_response :ok
22
+ assert_equal expected_stored_reauthentication_token, response.parsed_body["reauthentication_token"]
23
+ assert_nil expected_stored_reauthentication_challenge
24
+
25
+ reauthentication_token = response.parsed_body["reauthentication_token"]
26
+ params = params_for_destruction(reauthentication_token: reauthentication_token)
27
+
28
+ resource_id = resource_instance.id
29
+
30
+ assert_difference "#{resource_class}.count", -1 do
31
+ destroy_registration_action(params: params)
32
+ assert_redirected_to destroy_success_url
33
+ end
34
+
35
+ assert_nil resource_class.find_by(id: resource_id)
36
+ end
37
+
38
+ test "destroy requires a reauthentication token" do
39
+ sign_in_as_resource
40
+
41
+ client = webauthn_client
42
+ create_passkey_for_user_and_return_webauthn_credential(user: resource_instance)
43
+
44
+ new_reauthentication_challenge_action
45
+ assert_response :ok
46
+ assert_reauthentication_token_challenge
47
+
48
+ challenge = response.parsed_body["challenge"]
49
+ credential = get_credential_payload_from_challenge(client: client, challenge: challenge)
50
+
51
+ reauthenticate_action(params: {passkey_credential: credential.to_json})
52
+ assert_response :ok
53
+ assert_equal expected_stored_reauthentication_token, response.parsed_body["reauthentication_token"]
54
+ assert_nil expected_stored_reauthentication_challenge
55
+
56
+ reauthentication_token = " "
57
+ params = params_for_destruction(reauthentication_token: reauthentication_token)
58
+
59
+ resource_id = resource_instance.id
60
+
61
+ assert_no_difference "#{resource_class}.count" do
62
+ destroy_registration_action(params: params)
63
+ assert_response :bad_request
64
+ assert_not_reauthenticated_message
65
+ end
66
+
67
+ assert_not_nil resource_class.find_by(id: resource_id)
68
+ end
69
+
70
+ test "destroy requires the reauthentication token to match the stored value" do
71
+ sign_in_as_resource
72
+
73
+ client = webauthn_client
74
+ create_passkey_for_user_and_return_webauthn_credential(user: resource_instance)
75
+
76
+ new_reauthentication_challenge_action
77
+ assert_response :ok
78
+ assert_reauthentication_token_challenge
79
+
80
+ challenge = response.parsed_body["challenge"]
81
+ credential = get_credential_payload_from_challenge(client: client, challenge: challenge)
82
+
83
+ reauthenticate_action(params: {passkey_credential: credential.to_json})
84
+ assert_response :ok
85
+ assert_equal expected_stored_reauthentication_token, response.parsed_body["reauthentication_token"]
86
+ assert_nil expected_stored_reauthentication_challenge
87
+
88
+ reauthentication_token = SecureRandom.hex
89
+ params = params_for_destruction(reauthentication_token: reauthentication_token)
90
+
91
+ resource_id = resource_instance.id
92
+
93
+ assert_no_difference "#{resource_class}.count" do
94
+ destroy_registration_action(params: params)
95
+ assert_response :bad_request
96
+ assert_not_reauthenticated_message
97
+ end
98
+
99
+ assert_not_nil resource_class.find_by(id: resource_id)
100
+ end
101
+
102
+ test "destroy ignores an attmept to delete a different resource" do
103
+ sign_in_as_resource
104
+
105
+ client = webauthn_client
106
+ create_passkey_for_user_and_return_webauthn_credential(user: resource_instance)
107
+
108
+ new_reauthentication_challenge_action
109
+ assert_response :ok
110
+ assert_reauthentication_token_challenge
111
+
112
+ challenge = response.parsed_body["challenge"]
113
+ credential = get_credential_payload_from_challenge(client: client, challenge: challenge)
114
+
115
+ reauthenticate_action(params: {passkey_credential: credential.to_json})
116
+ assert_response :ok
117
+ assert_equal expected_stored_reauthentication_token, response.parsed_body["reauthentication_token"]
118
+ assert_nil expected_stored_reauthentication_challenge
119
+
120
+ reauthentication_token = " "
121
+ params = params_trying_to_destroy_other_resource(reauthentication_token: reauthentication_token)
122
+
123
+ resource_id = resource_instance.id
124
+
125
+ assert_no_difference "#{resource_class}.count" do
126
+ destroy_registration_action(params: params)
127
+ assert_response :bad_request
128
+ assert_not_reauthenticated_message
129
+ end
130
+
131
+ assert_not_nil resource_class.find_by(id: resource_id)
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Practical::Test::Shared::Auth::Passkeys::Controllers::Registrations::SelfSignup
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(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(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(params: params)
42
+ assert_response :bad_request
43
+ assert_passkey_label_missing_error_message
44
+ end
45
+
46
+ test "new registration action renders successfully" do
47
+ new_registration_action
48
+ assert_response :ok
49
+ end
50
+
51
+ test "create registration action creates a new resource" do
52
+ email = Faker::Internet.email
53
+ passkey_label = SecureRandom.hex
54
+
55
+ params = params_for_registration_challenge(email: email, passkey_label: passkey_label)
56
+ new_registration_challenge_action(params: params)
57
+ assert_response :ok
58
+ webauthn_id = response.parsed_body["user"]["id"]
59
+ user_data = expected_user_data_for_challenge(email: email, webauthn_id: webauthn_id, name: email)
60
+
61
+ assert_passkey_registration_challenge(
62
+ data: response.parsed_body,
63
+ stored_challenge: expected_stored_challenge,
64
+ relying_party_data: expected_relying_party_data,
65
+ user_data: user_data,
66
+ credentials_to_exclude: []
67
+ )
68
+
69
+ client = webauthn_client
70
+ challenge = expected_stored_challenge
71
+ raw_credential = create_credential_and_return_payload_from_challenge(client: client, challenge: challenge)
72
+
73
+ params = params_for_registration(email: email, passkey_label: passkey_label, raw_credential: raw_credential)
74
+
75
+ assert_difference "#{passkey_class}.count", +1 do
76
+ assert_difference "#{resource_class}.count", +1 do
77
+ create_resource_action(params: params)
78
+ assert_redirected_to expected_success_url
79
+ end
80
+ end
81
+
82
+ new_resource = resource_class.last
83
+ credential = hydrate_response_from_raw_credential(client: client, relying_party: webauthn_relying_party, raw_credential: raw_credential).credential
84
+
85
+ new_passkey = new_resource.passkeys.last
86
+ assert_equal passkey_label, new_passkey.label
87
+ assert_equal Base64.strict_encode64(credential.id), new_passkey.external_id
88
+ assert_not_nil new_passkey.public_key
89
+ assert_not_nil new_passkey.last_used_at
90
+ end
91
+
92
+ test "create registration action does not create a duplicate resource" do
93
+ email = existing_resource.email
94
+ passkey_label = SecureRandom.hex
95
+
96
+ params = params_for_registration_challenge(email: email, passkey_label: passkey_label)
97
+ new_registration_challenge_action(params: params)
98
+ assert_response :ok
99
+ webauthn_id = response.parsed_body["user"]["id"]
100
+ user_data = expected_user_data_for_challenge(email: email, webauthn_id: webauthn_id, name: email)
101
+
102
+ assert_passkey_registration_challenge(
103
+ data: response.parsed_body,
104
+ stored_challenge: expected_stored_challenge,
105
+ relying_party_data: expected_relying_party_data,
106
+ user_data: user_data,
107
+ credentials_to_exclude: []
108
+ )
109
+
110
+ client = webauthn_client
111
+ challenge = expected_stored_challenge
112
+ raw_credential = create_credential_and_return_payload_from_challenge(client: client, challenge: challenge)
113
+
114
+ params = params_for_registration(email: email, passkey_label: passkey_label, raw_credential: raw_credential)
115
+
116
+ assert_no_difference "#{passkey_class}.count" do
117
+ assert_no_difference "#{resource_class}.count" do
118
+ create_resource_action(params: params)
119
+ assert_response :unprocessable_entity
120
+ end
121
+ end
122
+ end
123
+
124
+ test "create registration action requires an email" do
125
+ email = Faker::Internet.email
126
+ passkey_label = SecureRandom.hex
127
+
128
+ params = params_for_registration_challenge(email: email, passkey_label: passkey_label)
129
+ new_registration_challenge_action(params: params)
130
+ assert_response :ok
131
+ webauthn_id = response.parsed_body["user"]["id"]
132
+ user_data = expected_user_data_for_challenge(email: email, webauthn_id: webauthn_id, name: email)
133
+
134
+ assert_passkey_registration_challenge(
135
+ data: response.parsed_body,
136
+ stored_challenge: expected_stored_challenge,
137
+ relying_party_data: expected_relying_party_data,
138
+ user_data: user_data,
139
+ credentials_to_exclude: []
140
+ )
141
+
142
+ client = webauthn_client
143
+ challenge = expected_stored_challenge
144
+ raw_credential = create_credential_and_return_payload_from_challenge(client: client, challenge: challenge)
145
+
146
+ params = params_for_registration(email: " ", passkey_label: passkey_label, raw_credential: raw_credential)
147
+
148
+ assert_no_difference "#{passkey_class}.count" do
149
+ assert_no_difference "#{resource_class}.count" do
150
+ create_resource_action(params: params)
151
+ assert_response :bad_request
152
+ end
153
+ end
154
+ end
155
+
156
+ test "create registration action requires a passkey_label" do
157
+ email = Faker::Internet.email
158
+ passkey_label = SecureRandom.hex
159
+
160
+ params = params_for_registration_challenge(email: email, passkey_label: passkey_label)
161
+ new_registration_challenge_action(params: params)
162
+ assert_response :ok
163
+ webauthn_id = response.parsed_body["user"]["id"]
164
+ user_data = expected_user_data_for_challenge(email: email, webauthn_id: webauthn_id, name: email)
165
+
166
+ assert_passkey_registration_challenge(
167
+ data: response.parsed_body,
168
+ stored_challenge: expected_stored_challenge,
169
+ relying_party_data: expected_relying_party_data,
170
+ user_data: user_data,
171
+ credentials_to_exclude: []
172
+ )
173
+
174
+ client = webauthn_client
175
+ challenge = expected_stored_challenge
176
+ raw_credential = create_credential_and_return_payload_from_challenge(client: client, challenge: challenge)
177
+
178
+ params = params_for_registration(email: email, passkey_label: " ", raw_credential: raw_credential)
179
+
180
+ assert_no_difference "#{passkey_class}.count" do
181
+ assert_no_difference "#{resource_class}.count" do
182
+ create_resource_action(params: params)
183
+ assert_response :bad_request
184
+ end
185
+ end
186
+ end
187
+
188
+ test "create registration action requires a passkey_credential" do
189
+ email = Faker::Internet.email
190
+ passkey_label = SecureRandom.hex
191
+
192
+ params = params_for_registration_challenge(email: email, passkey_label: passkey_label)
193
+ new_registration_challenge_action(params: params)
194
+ assert_response :ok
195
+ webauthn_id = response.parsed_body["user"]["id"]
196
+ user_data = expected_user_data_for_challenge(email: email, webauthn_id: webauthn_id, name: email)
197
+
198
+ assert_passkey_registration_challenge(
199
+ data: response.parsed_body,
200
+ stored_challenge: expected_stored_challenge,
201
+ relying_party_data: expected_relying_party_data,
202
+ user_data: user_data,
203
+ credentials_to_exclude: []
204
+ )
205
+
206
+ client = webauthn_client
207
+ challenge = expected_stored_challenge
208
+ raw_credential = create_credential_and_return_payload_from_challenge(client: client, challenge: challenge)
209
+
210
+ params = params_for_registration(email: email, passkey_label: passkey_label, raw_credential: " ")
211
+
212
+ assert_no_difference "#{passkey_class}.count" do
213
+ assert_no_difference "#{resource_class}.count" do
214
+ assert_raises NoMethodError do
215
+ create_resource_action(params: params)
216
+ end
217
+ end
218
+ end
219
+ end
220
+ end
221
+ end
@@ -0,0 +1,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Practical::Test::Shared::Auth::Passkeys::Controllers::Registrations::Update
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ test "edit renders successfully for the resource" do
8
+ sign_in_as_resource
9
+ edit_registration_action
10
+ assert_response :ok
11
+ end
12
+
13
+ test "edit does not render a different resource" do
14
+ sign_in_as_resource
15
+ attempt_to_edit_other_resource_action
16
+ assert_response :ok
17
+ assert_not_includes response.body, other_resource.email
18
+ end
19
+
20
+ test "update updates the resource after reauthentication" do
21
+ sign_in_as_resource
22
+
23
+ client = webauthn_client
24
+ create_passkey_for_user_and_return_webauthn_credential(user: resource_instance)
25
+
26
+ new_reauthentication_challenge_action
27
+ assert_response :ok
28
+ assert_reauthentication_token_challenge
29
+
30
+ challenge = response.parsed_body["challenge"]
31
+ credential = get_credential_payload_from_challenge(client: client, challenge: challenge)
32
+
33
+ reauthenticate_action(params: {passkey_credential: credential.to_json})
34
+ assert_response :ok
35
+ assert_equal expected_stored_reauthentication_token, response.parsed_body["reauthentication_token"]
36
+ assert_nil expected_stored_reauthentication_challenge
37
+
38
+ new_email = Faker::Internet.email
39
+
40
+ reauthentication_token = response.parsed_body["reauthentication_token"]
41
+ params = params_for_updating_resource(email: new_email, reauthentication_token: reauthentication_token)
42
+
43
+ assert_no_difference "#{resource_class}.count" do
44
+ update_registration_action(params: params)
45
+ assert_json_redirected_to expected_update_success_url
46
+ end
47
+
48
+ resource_instance.reload
49
+ assert_equal new_email, resource_instance.email
50
+ end
51
+
52
+ test "update requires a reauthentication token" do
53
+ sign_in_as_resource
54
+
55
+ client = webauthn_client
56
+ create_passkey_for_user_and_return_webauthn_credential(user: resource_instance)
57
+
58
+ new_reauthentication_challenge_action
59
+ assert_response :ok
60
+ assert_reauthentication_token_challenge
61
+
62
+ challenge = response.parsed_body["challenge"]
63
+ credential = get_credential_payload_from_challenge(client: client, challenge: challenge)
64
+
65
+ reauthenticate_action(params: {passkey_credential: credential.to_json})
66
+ assert_response :ok
67
+ assert_equal expected_stored_reauthentication_token, response.parsed_body["reauthentication_token"]
68
+ assert_nil expected_stored_reauthentication_challenge
69
+
70
+ old_email = resource_instance.email
71
+ new_email = Faker::Internet.email
72
+
73
+ reauthentication_token = ""
74
+ params = params_for_updating_resource(email: new_email, reauthentication_token: reauthentication_token)
75
+
76
+ assert_no_difference "#{resource_class}.count" do
77
+ update_registration_action(params: params)
78
+ assert_response :bad_request
79
+ assert_not_reauthenticated_message
80
+ end
81
+
82
+ resource_instance.reload
83
+ assert_equal old_email, resource_instance.email
84
+ end
85
+
86
+ test "update requires the reauthentication token to match the stored value" do
87
+ sign_in_as_resource
88
+
89
+ client = webauthn_client
90
+ create_passkey_for_user_and_return_webauthn_credential(user: resource_instance)
91
+
92
+ new_reauthentication_challenge_action
93
+ assert_response :ok
94
+ assert_reauthentication_token_challenge
95
+
96
+ challenge = response.parsed_body["challenge"]
97
+ credential = get_credential_payload_from_challenge(client: client, challenge: challenge)
98
+
99
+ reauthenticate_action(params: {passkey_credential: credential.to_json})
100
+ assert_response :ok
101
+ assert_equal expected_stored_reauthentication_token, response.parsed_body["reauthentication_token"]
102
+ assert_nil expected_stored_reauthentication_challenge
103
+
104
+ old_email = resource_instance.email
105
+ new_email = Faker::Internet.email
106
+
107
+ reauthentication_token = SecureRandom.hex
108
+ params = params_for_updating_resource(email: new_email, reauthentication_token: reauthentication_token)
109
+
110
+ assert_no_difference "#{resource_class}.count" do
111
+ update_registration_action(params: params)
112
+ assert_response :bad_request
113
+ assert_not_reauthenticated_message
114
+ end
115
+
116
+ resource_instance.reload
117
+ assert_equal old_email, resource_instance.email
118
+ end
119
+
120
+ test "update renders an error if the email is missing" do
121
+ sign_in_as_resource
122
+
123
+ client = webauthn_client
124
+ create_passkey_for_user_and_return_webauthn_credential(user: resource_instance)
125
+
126
+ new_reauthentication_challenge_action
127
+ assert_response :ok
128
+ assert_reauthentication_token_challenge
129
+
130
+ challenge = response.parsed_body["challenge"]
131
+ credential = get_credential_payload_from_challenge(client: client, challenge: challenge)
132
+
133
+ reauthenticate_action(params: {passkey_credential: credential.to_json})
134
+ assert_response :ok
135
+ assert_equal expected_stored_reauthentication_token, response.parsed_body["reauthentication_token"]
136
+ assert_nil expected_stored_reauthentication_challenge
137
+
138
+ old_email = resource_instance.email
139
+ new_email = " "
140
+
141
+ reauthentication_token = response.parsed_body["reauthentication_token"]
142
+ params = params_for_updating_resource(email: new_email, reauthentication_token: reauthentication_token)
143
+
144
+ assert_no_difference "#{resource_class}.count" do
145
+ update_registration_action(params: params)
146
+ assert_response :unprocessable_entity
147
+ assert_form_error_for_email(message: "can't be blank")
148
+ end
149
+
150
+ resource_instance.reload
151
+ assert_equal old_email, resource_instance.email
152
+ end
153
+
154
+ test "update renders an error if the email is a duplicate" do
155
+ sign_in_as_resource
156
+
157
+ client = webauthn_client
158
+ create_passkey_for_user_and_return_webauthn_credential(user: resource_instance)
159
+
160
+ new_reauthentication_challenge_action
161
+ assert_response :ok
162
+ assert_reauthentication_token_challenge
163
+
164
+ challenge = response.parsed_body["challenge"]
165
+ credential = get_credential_payload_from_challenge(client: client, challenge: challenge)
166
+
167
+ reauthenticate_action(params: {passkey_credential: credential.to_json})
168
+ assert_response :ok
169
+ assert_equal expected_stored_reauthentication_token, response.parsed_body["reauthentication_token"]
170
+ assert_nil expected_stored_reauthentication_challenge
171
+
172
+ old_email = resource_instance.email
173
+ new_email = other_resource.email
174
+
175
+ reauthentication_token = response.parsed_body["reauthentication_token"]
176
+ params = params_for_updating_resource(email: new_email, reauthentication_token: reauthentication_token)
177
+
178
+ assert_no_difference "#{resource_class}.count" do
179
+ update_registration_action(params: params)
180
+ assert_response :unprocessable_entity
181
+ assert_form_error_for_email(message: "has already been taken")
182
+ end
183
+
184
+ resource_instance.reload
185
+ assert_equal old_email, resource_instance.email
186
+ end
187
+
188
+ test "update ignores when trying to update a different resource" do
189
+ sign_in_as_resource
190
+
191
+ client = webauthn_client
192
+ create_passkey_for_user_and_return_webauthn_credential(user: resource_instance)
193
+
194
+ new_reauthentication_challenge_action
195
+ assert_response :ok
196
+ assert_reauthentication_token_challenge
197
+
198
+ challenge = response.parsed_body["challenge"]
199
+ credential = get_credential_payload_from_challenge(client: client, challenge: challenge)
200
+
201
+ reauthenticate_action(params: {passkey_credential: credential.to_json})
202
+ assert_response :ok
203
+ assert_equal expected_stored_reauthentication_token, response.parsed_body["reauthentication_token"]
204
+ assert_nil expected_stored_reauthentication_challenge
205
+
206
+ new_email = Faker::Internet.email
207
+
208
+ reauthentication_token = response.parsed_body["reauthentication_token"]
209
+ params = params_trying_to_update_other_resource(email: new_email, reauthentication_token: reauthentication_token)
210
+
211
+ assert_no_difference "#{resource_class}.count" do
212
+ update_registration_action(params: params)
213
+ assert_json_redirected_to expected_update_success_url
214
+ end
215
+
216
+ resource_instance.reload
217
+ assert_equal new_email, resource_instance.email
218
+ end
219
+ end
220
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Practical::Test::Shared::Auth::Passkeys::Controllers::Sessions::Base
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ test "new: renders successfully" do
8
+ get_new_session_url
9
+ assert_response :ok
10
+ end
11
+
12
+ test "new_challenge: does not require the resource to be authenticated and sets the session variable that stores the new challenge" do
13
+ get root_url
14
+ assert_resource_not_signed_in
15
+ issue_new_challenge_action
16
+ assert_response :ok
17
+ assert_equal get_session_challenge, response.parsed_body["challenge"]
18
+ end
19
+
20
+ test "new_challenge: overrides the session variable that stores the new challenge" do
21
+ issue_new_challenge_action
22
+ assert_response :ok
23
+
24
+ old_session_challenge = get_session_challenge
25
+
26
+ issue_new_challenge_action
27
+ assert_response :ok
28
+
29
+ assert_not_equal old_session_challenge, get_session_challenge
30
+ assert_not_equal old_session_challenge, response.parsed_body["challenge"]
31
+ end
32
+
33
+ test """create:
34
+ - logs the resource in
35
+ - clears the challenge session variable
36
+ - sets the remember cookie value
37
+ """ do
38
+ issue_new_challenge_action
39
+ assert_response :ok
40
+
41
+ assert_passkey_authentication_challenge(
42
+ data: response.parsed_body,
43
+ stored_challenge: expected_stored_challenge,
44
+ credentials_to_allow: []
45
+ )
46
+
47
+ challenge = response.parsed_body["challenge"]
48
+ credential = get_credential_payload_from_challenge(client: client, challenge: challenge)
49
+
50
+ assert_nil remember_cookie_value
51
+
52
+ authenticate_action(params: {resource_key => {passkey_credential: credential.to_json}})
53
+ assert_redirected_to root_url
54
+
55
+ assert_resource_signed_in
56
+ assert_resource_remembered
57
+ assert_nil get_session_challenge
58
+ end
59
+
60
+ test """create:
61
+ - raises an error if the challenge does not match what is in the session
62
+ - clears the challenge session variable
63
+ """ do
64
+ issue_new_challenge_action
65
+ assert_response :ok
66
+
67
+ assert_passkey_authentication_challenge(
68
+ data: response.parsed_body,
69
+ stored_challenge: expected_stored_challenge,
70
+ credentials_to_allow: []
71
+ )
72
+
73
+ challenge = SecureRandom.hex
74
+ credential = get_credential_payload_from_challenge(client: client, challenge: challenge)
75
+
76
+ authenticate_action(params: {resource_key => {passkey_credential: credential.to_json}})
77
+ assert_response :unprocessable_entity
78
+
79
+ assert_nil get_session_challenge
80
+ assert_resource_not_signed_in
81
+ end
82
+
83
+ test """create:
84
+ - raises an error if the credential is invalid
85
+ - clears the challenge session variable
86
+ """ do
87
+ issue_new_challenge_action
88
+ assert_response :ok
89
+
90
+ assert_passkey_authentication_challenge(
91
+ data: response.parsed_body,
92
+ stored_challenge: expected_stored_challenge,
93
+ credentials_to_allow: []
94
+ )
95
+
96
+ challenge = response.parsed_body["challenge"]
97
+ credential = get_credential_payload_from_challenge(client: client, challenge: challenge)
98
+
99
+ invalidate_all_credentials
100
+
101
+ authenticate_action(params: {resource_key => {passkey_credential: credential.to_json}})
102
+ assert_response :unprocessable_entity
103
+
104
+ assert_nil get_session_challenge
105
+ assert_resource_not_signed_in
106
+ end
107
+ end
108
+ end