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,311 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Practical::Views::TiptapDocumentComponent < Practical::Views::BaseComponent
4
+ class UnknownNodeTypeError < StandardError; end
5
+ class UnknownMarkupTypeError < StandardError; end
6
+
7
+ module NodeRendering
8
+ def render_node(node:)
9
+ case node[:type].to_sym
10
+ when :text
11
+ render Text.new(node_content: node)
12
+ when :paragraph
13
+ render Paragraph.new(node_content: node)
14
+ when :heading
15
+ render Heading.new(node_content: node)
16
+ when :codeBlock
17
+ render CodeBlock.new(node_content: node)
18
+ when :listItem
19
+ render ListItem.new(node_content: node)
20
+ when :bulletList
21
+ render UnorderedList.new(node_content: node)
22
+ when :orderedList
23
+ render OrderedList.new(node_content: node)
24
+ when :"attachment-figure", :"previewable-attachment-figure"
25
+ render Attachment.new(node_content: node)
26
+ when :blockquote
27
+ render Blockquote.new(node_content: node)
28
+ when :hardBreak
29
+ render HardBreak.new(node_content: node)
30
+ else
31
+ raise UnknownNodeTypeError
32
+ end
33
+ end
34
+ end
35
+
36
+ include NodeRendering
37
+
38
+ attr_reader :document
39
+
40
+ def initialize(document:)
41
+ raise ArgumentError if document["type"] != "doc"
42
+ @document = document.with_indifferent_access
43
+ end
44
+
45
+ def call
46
+ safe_join([document[:content].map do |node|
47
+ render_node(node: node)
48
+ end])
49
+ end
50
+
51
+ class Node < Practical::Views::BaseComponent
52
+ include NodeRendering
53
+
54
+ attr_reader :node_content
55
+
56
+ def initialize(node_content:)
57
+ @node_content = node_content
58
+ end
59
+
60
+ def render_node_contents
61
+ if node_content.present? && node_content[:content].present?
62
+ safe_join(node_content[:content].map{|node| render_node(node: node)})
63
+ end
64
+ end
65
+ end
66
+
67
+ class HardBreak < Node
68
+ def call
69
+ tag.br
70
+ end
71
+ end
72
+
73
+ class Text < Node
74
+ SORTED_MARKUP_TYPES = [
75
+ "rhino-strike", "link", "bold", "italic"
76
+ ].freeze
77
+
78
+ def applicable_markup_types
79
+ SORTED_MARKUP_TYPES.select{|type| node_content[:marks].any?{ |mark| mark[:type] == type }}
80
+ end
81
+
82
+ def call
83
+ if node_content[:marks].present? && node_content[:marks].any?
84
+ render_with_marks(markup_to_apply: applicable_markup_types)
85
+ else
86
+ render_plaintext
87
+ end
88
+ end
89
+
90
+ def render_with_marks(markup_to_apply:)
91
+ markup_type = markup_to_apply.shift
92
+ case markup_type
93
+ when "italic"
94
+ tag.em{ render_with_marks(markup_to_apply: markup_to_apply) }
95
+ when "bold"
96
+ tag.strong { render_with_marks(markup_to_apply: markup_to_apply) }
97
+ when "rhino-strike"
98
+ tag.del { render_with_marks(markup_to_apply: markup_to_apply) }
99
+ when "link"
100
+ tag.a(**link_attributes) { render_with_marks(markup_to_apply: markup_to_apply) }
101
+ when nil
102
+ render_plaintext
103
+ else
104
+ raise UnknownMarkupTypeError
105
+ end
106
+ end
107
+
108
+ def link_attributes
109
+ node_content[:marks]&.find{|mark| mark[:type] == "link" }&.dig(:attrs)&.slice(:href, :target, :rel).to_h
110
+ end
111
+
112
+ def render_plaintext
113
+ helpers.sanitize(node_content[:text])
114
+ end
115
+ end
116
+
117
+ class Paragraph < Node
118
+ def call
119
+ tag.p {
120
+ render_node_contents
121
+ }
122
+ end
123
+ end
124
+
125
+ class Heading < Node
126
+ def call
127
+ heading_element {
128
+ render_node_contents
129
+ }
130
+ end
131
+
132
+ def heading_element(&block)
133
+ case node_content.dig(:attrs, :level)
134
+ when 1
135
+ tag.h1(&block)
136
+ when 2
137
+ tag.h2(&block)
138
+ when 3
139
+ tag.h3(&block)
140
+ when 4
141
+ tag.h4(&block)
142
+ when 5
143
+ tag.h5(&block)
144
+ when 6
145
+ tag.h6(&block)
146
+ end
147
+ end
148
+ end
149
+
150
+ class Blockquote < Node
151
+ def call
152
+ tag.blockquote {
153
+ render_node_contents
154
+ }
155
+ end
156
+ end
157
+
158
+ class CodeBlock < Node
159
+ def call
160
+ tag.pre {
161
+ tag.code { render_node_contents }
162
+ }
163
+ end
164
+ end
165
+
166
+ class ListItem < Node
167
+ def call
168
+ tag.li {
169
+ render_node_contents
170
+ }
171
+ end
172
+ end
173
+
174
+ class UnorderedList < Node
175
+ def call
176
+ tag.ul {
177
+ render_node_contents
178
+ }
179
+ end
180
+ end
181
+
182
+ class OrderedList < Node
183
+ def call
184
+ tag.ol(start: node_content.dig(:attrs, :start)) {
185
+ render_node_contents
186
+ }
187
+ end
188
+ end
189
+
190
+ class Attachment < Node
191
+ def call
192
+ tag.figure(class: 'wa-stack wa-gap-s') {
193
+ if missing_attachment?
194
+ missing_attachment_figure
195
+ else
196
+ attachment_figure
197
+ end
198
+ }
199
+ end
200
+
201
+ def missing_attachment_figure
202
+ safe_join([
203
+ tag.div {
204
+ render Practical::Views::IconForFileExtensionComponent.new(extension: "missing")
205
+ },
206
+
207
+ tag.section(class: 'attachment-details') {
208
+ tag.p(t("tiptap_document.attachment_missing.text"))
209
+ },
210
+
211
+ figure_caption
212
+ ])
213
+ end
214
+
215
+ def attachment_figure
216
+ if previewable?
217
+ image = tag.div {
218
+ tag.img(src: url, width: width, height: height)
219
+ }
220
+ else
221
+ image = tag.div {
222
+ render Practical::Views::IconForFileExtensionComponent.new(extension: extension)
223
+ }
224
+ end
225
+
226
+ safe_join([
227
+ image,
228
+ attachment_details_and_download,
229
+ figure_caption
230
+ ])
231
+ end
232
+
233
+ def figure_caption
234
+ if node_content[:content].present?
235
+ tag.figcaption { render_node_contents }
236
+ end
237
+ end
238
+
239
+ def attachment_details_and_download
240
+ tag.section(class: 'attachment-details') {
241
+ tag.p {
242
+ tag.a(href: url, target: "_blank") {
243
+ "#{filename} – #{human_file_size}"
244
+ }
245
+ }
246
+ }
247
+ end
248
+
249
+ def attachment
250
+ @attachment ||= GlobalID::Locator.locate_signed(sgid.to_s, for: :document)&.attachment
251
+ end
252
+
253
+ def missing_attachment?
254
+ attachment.nil?
255
+ end
256
+
257
+ def attrs
258
+ node_content[:attrs]
259
+ end
260
+
261
+ def previewable?
262
+ attrs.dig(:previewable)
263
+ end
264
+
265
+ def sgid
266
+ attrs.dig(:sgid)
267
+ end
268
+
269
+ def filename
270
+ attachment.original_filename
271
+ end
272
+
273
+ def human_file_size
274
+ helpers.number_to_human_size(attachment.size)
275
+ end
276
+
277
+ def url
278
+ attachment.url
279
+ end
280
+
281
+ def extension
282
+ attachment.extension
283
+ end
284
+
285
+ def stored_width
286
+ attrs.dig(:width)
287
+ end
288
+
289
+ def stored_height
290
+ attrs.dig(:height)
291
+ end
292
+
293
+ def has_dimensions?
294
+ !stored_width.blank? && !stored_height.blank?
295
+ end
296
+
297
+ def width
298
+ return stored_width if has_dimensions?
299
+ default_figure_size
300
+ end
301
+
302
+ def height
303
+ return stored_height if has_dimensions?
304
+ default_figure_size
305
+ end
306
+
307
+ def default_figure_size
308
+ 100
309
+ end
310
+ end
311
+ end
@@ -0,0 +1,26 @@
1
+ <toast-dialog>
2
+ <dialog open class='wa-off toast'>
3
+ <%= tag.section(**finalized_callout_options) do %>
4
+ <div class="wa-flank:end wa-gap-xs">
5
+ <% if icon? %>
6
+ <section class='wa-flank'>
7
+ <%= icon %>
8
+ <p><%= content %></p>
9
+ </section>
10
+ <% else %>
11
+ <p><%= content %></p>
12
+ <% end %>
13
+
14
+ <form method="dialog">
15
+ <%= render Practical::Views::ButtonComponent.new(type: :submit, appearance: :plain, ) do %>
16
+ <wa-progress-ring value="100" remaining-time-indicator>
17
+ <%= render icon_set.dialog_close_icon %>
18
+ </wa-progress-ring>
19
+ <% end %>
20
+ </form>
21
+ </div>
22
+ <% end %>
23
+
24
+ <auto-expire duration-seconds="8" tick-rate-ms="150"></auto-expire>
25
+ </dialog>
26
+ </toast-dialog>
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Practical::Views::ToastComponent < Practical::Views::BaseComponent
4
+ include Practical::Views::Button::Styling
5
+ attr_accessor :appearance, :color_variant, :size, :options
6
+
7
+ renders_one :icon
8
+
9
+ def initialize(appearance: nil, color_variant: nil, size: nil, options: {})
10
+ @options = options
11
+ initialize_style_utilities(appearance: appearance, color_variant: color_variant, size: size)
12
+ end
13
+
14
+ def finalized_callout_options
15
+ mix({
16
+ class: class_names("wa-callout", css_classes_from_style_utilities)
17
+ }, options)
18
+ end
19
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Practical::Auth::Passkeys::EmergencyRegistrations
4
+ extend ActiveSupport::Concern
5
+ include Practical::Auth::Passkeys::WebAuthnDebugContext
6
+
7
+ def new_challenge
8
+ options_for_registration = generate_registration_options(
9
+ relying_party: relying_party,
10
+ user_details: user_details_for_registration,
11
+ exclude: exclude_external_ids_for_registration
12
+ )
13
+
14
+ store_challenge_in_session(options_for_registration: options_for_registration)
15
+
16
+ render json: options_for_registration
17
+ end
18
+
19
+ def raw_credential
20
+ passkey_params[:passkey_credential]
21
+ end
22
+
23
+ def verify_credential_integrity
24
+ return render_credential_missing_or_could_not_be_parsed_error if parsed_credential.nil?
25
+ return render_credential_missing_or_could_not_be_parsed_error unless parsed_credential.kind_of?(Hash)
26
+ rescue JSON::JSONError, TypeError
27
+ return render_credential_missing_or_could_not_be_parsed_error
28
+ end
29
+
30
+ def verify_passkey_challenge
31
+ @webauthn_credential = verify_registration(relying_party: relying_party)
32
+ rescue ::WebAuthn::Error => e
33
+ Honeybadger.notify(e, context: honeybadger_webauthn_context)
34
+ error_key = Warden::WebAuthn::ErrorKeyFinder.webauthn_error_key(exception: e)
35
+ render_passkey_error(message: find_message(error_key))
36
+ return false
37
+ end
38
+
39
+ def request_form_params
40
+ params.require(:new_emergency_passkey_registration_form).permit(:email)
41
+ end
42
+
43
+ def render_credential_missing_or_could_not_be_parsed_error
44
+ render_passkey_error(message: find_message(:credential_missing_or_could_not_be_parsed))
45
+ delete_registration_challenge
46
+ return false
47
+ end
48
+
49
+ def render_passkey_error(message:)
50
+ errors = Practical::Views::ErrorHandling.build_error_json(
51
+ model: temporary_form_with_passkey_credential_error(message: message),
52
+ helpers: helpers
53
+ )
54
+
55
+ render json: errors, status: :unprocessable_entity
56
+ end
57
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Practical::Auth::Passkeys::WebAuthnDebugContext
4
+ def honeybadger_webauthn_context
5
+ debug_credential = WebAuthn::Credential.from_create(parsed_credential, relying_party: relying_party)
6
+ debug_client_data_json = debug_credential.response.client_data.as_json
7
+
8
+ return {
9
+ debug_client_data_json: debug_client_data_json,
10
+ relying_party_json: relying_party.as_json
11
+ }
12
+ end
13
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Practical::Views::FlashHelpers
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ add_flash_types :success
8
+ end
9
+
10
+ def flash_message_with_icon(message:, icon:)
11
+ {message: message, icon: icon}
12
+ end
13
+
14
+ def flash_notice_with_icon(message:, icon: default_notice_icon)
15
+ flash_message_with_icon(message: message, icon: icon)
16
+ end
17
+
18
+ def flash_alert_with_icon(message:, icon: default_alert_icon)
19
+ flash_message_with_icon(message: message, icon: icon)
20
+ end
21
+
22
+ def flash_success_with_icon(message:, icon: default_success_icon)
23
+ flash_message_with_icon(message: message, icon: icon)
24
+ end
25
+
26
+ def default_notice_icon
27
+ return helpers.icon_set.info_icon
28
+ end
29
+
30
+ def default_alert_icon
31
+ return helpers.icon_set.alert_icon
32
+ end
33
+
34
+ def default_success_icon
35
+ return helpers.icon_set.success_icon
36
+ end
37
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Practical::Views::JSONRedirection
4
+ def json_redirect(location:)
5
+ render json: {location: location}, status: 322
6
+ end
7
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Practical::Defaults::Shrine
4
+ def self.max_file_size
5
+ (20*1024*1024).freeze # 20 MB
6
+ end
7
+
8
+ def self.mime_types
9
+ %w(image/jpeg
10
+ image/png
11
+ image/webp
12
+ image/tiff
13
+ image/gif
14
+ image/heic
15
+ text/csv
16
+ application/pdf
17
+ application/vnd.openxmlformats-officedocument.wordprocessingml.document
18
+ application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
19
+ application/msword
20
+ application/vnd.ms-excel
21
+ text/plain
22
+ application/rtf
23
+ text/rtf
24
+ application/vnd.apple.numbers
25
+ ).freeze
26
+ end
27
+
28
+ def self.extensions
29
+ %w(jpg
30
+ jpeg
31
+ png
32
+ webp
33
+ tiff
34
+ tif
35
+ gif
36
+ heic
37
+ csv
38
+ pdf
39
+ docx
40
+ xlsx
41
+ doc
42
+ xls
43
+ txt
44
+ rtf
45
+ numbers
46
+ ).freeze
47
+ end
48
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Practical::Test::Helpers::Administrator::TestHelpers
4
+ def switch_to_admin_host
5
+ host!(AppSettings.generate_uri(subdomain: "admin"))
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Practical::Test::Helpers::ExtraAssertions
4
+ def assert_equal_set(expected, actual)
5
+ assert_equal Array.wrap(expected).to_set, Array.wrap(actual).to_set
6
+ end
7
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Practical::Test::Helpers::FlashAssertions
4
+ def assert_flash_message(type:, message:, icon_name:)
5
+ assert_equal message, flash[type][:message], flash
6
+ assert_includes flash[type][:icon].name.to_s, icon_name, flash
7
+ end
8
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Practical::Test::Helpers::Integration::Assertions
4
+ def assert_error_json_contains(container_id:, element_id:, message:, type:)
5
+ found_message = response.parsed_body.find do |error_json|
6
+ error_json["container_id"] == container_id &&
7
+ error_json["element_to_invalidate_id"] == element_id &&
8
+ error_json["message"] == message &&
9
+ error_json["type"] == type
10
+ end
11
+
12
+ assert_not_nil found_message, response.parsed_body
13
+ end
14
+
15
+ def assert_json_redirected_to(location)
16
+ assert_equal "322", response.code
17
+ assert_equal location, response.parsed_body["location"]
18
+ end
19
+
20
+ def assert_error_dom(container_id:, message:)
21
+ assert_dom("##{container_id}", text: message)
22
+ end
23
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "webauthn/fake_client"
4
+
5
+ module Practical::Test::Helpers::Passkey::System::Base
6
+ def fake_authenticator
7
+ @fake_authenticator ||= WebAuthn::FakeAuthenticator.new
8
+ end
9
+
10
+ def user_webauthn_client
11
+ @webauthn_client ||= WebAuthn::FakeClient.new(relying_party_origin, authenticator: fake_authenticator)
12
+ end
13
+
14
+ def administrator_webauthn_client
15
+ @webauthn_client ||= WebAuthn::FakeClient.new(admin_relying_party_origin, authenticator: fake_authenticator)
16
+ end
17
+
18
+ def user_relying_party
19
+ return WebAuthn::RelyingParty.new(
20
+ allowed_origins: relying_party_origin,
21
+ name: I18n.translate("app_title.text")
22
+ )
23
+ end
24
+
25
+ def create_webauthn_credential_from_scratch(webauthn_client:, rp_id: nil, relying_party:)
26
+ rp_id ||= relying_party.id || URI.parse(webauthn_client.origin).host
27
+
28
+ create_result = webauthn_client.create(rp_id: rp_id)
29
+
30
+ attestation_object =
31
+ if webauthn_client.encoding
32
+ relying_party.encoder.decode(create_result["response"]["attestationObject"])
33
+ else
34
+ create_result["response"]["attestationObject"]
35
+ end
36
+
37
+ client_data_json =
38
+ if webauthn_client.encoding
39
+ relying_party.encoder.decode(create_result["response"]["clientDataJSON"])
40
+ else
41
+ create_result["response"]["clientDataJSON"]
42
+ end
43
+
44
+ response = WebAuthn::AuthenticatorAttestationResponse.new(
45
+ attestation_object: attestation_object,
46
+ client_data_json: client_data_json,
47
+ relying_party: relying_party
48
+ )
49
+
50
+ return response.credential
51
+ end
52
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "webauthn/fake_client"
4
+
5
+ module Practical::Test::Helpers::Passkey::System::RackTest
6
+ include Practical::Test::Helpers::Passkey::System::Base
7
+
8
+ def create_passkey_for_user_and_return_webauthn_credential(user:)
9
+ webauthn_credential = create_webauthn_credential_from_scratch(webauthn_client: user_webauthn_client,
10
+ rp_id: user_relying_party_id,
11
+ relying_party: user_relying_party
12
+ )
13
+ fake_authenticator.instance_variable_get("@credentials")[user_relying_party_id]
14
+ [webauthn_credential.id]
15
+ [:credential_key]
16
+
17
+ user.passkeys.create!(
18
+ label: SecureRandom.hex,
19
+ external_id: Base64.strict_encode64(webauthn_credential.id),
20
+ public_key: Base64.strict_encode64(webauthn_credential.public_key),
21
+ sign_count: 0
22
+ )
23
+
24
+ return webauthn_credential
25
+ end
26
+
27
+ def create_passkey_for_administrator_and_return_webauthn_credential(administrator:)
28
+ webauthn_credential = create_webauthn_credential_from_scratch(webauthn_client: administrator_webauthn_client,
29
+ rp_id: admin_relying_party_id,
30
+ relying_party: admin_relying_party
31
+ )
32
+ fake_authenticator.instance_variable_get("@credentials")[admin_relying_party_id]
33
+ [webauthn_credential.id]
34
+ [:credential_key]
35
+
36
+ administrator.passkeys.create!(
37
+ label: SecureRandom.hex,
38
+ external_id: Base64.strict_encode64(webauthn_credential.id),
39
+ public_key: Base64.strict_encode64(webauthn_credential.public_key),
40
+ sign_count: 0
41
+ )
42
+
43
+ return webauthn_credential
44
+ end
45
+ end