inline_forms 8.1.56 → 8.1.58

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 (33) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +97 -0
  3. data/app/assets/stylesheets/inline_forms/_theme.scss +13 -0
  4. data/app/assets/stylesheets/inline_forms/devise.scss +4 -1
  5. data/app/assets/stylesheets/inline_forms/foundation_and_overrides.scss +1 -0
  6. data/app/assets/stylesheets/inline_forms/inline_forms.scss +35 -16
  7. data/app/controllers/inline_forms_controller.rb +50 -6
  8. data/app/helpers/inline_forms_file_trash_helper.rb +10 -0
  9. data/app/models/concerns/inline_forms/stored_files.rb +5 -1
  10. data/app/models/inline_forms/trashed_file.rb +16 -2
  11. data/app/views/devise/sessions/_form.html.erb +2 -2
  12. data/app/views/inline_forms/_list.html.erb +24 -7
  13. data/app/views/inline_forms/_versions.html.erb +1 -1
  14. data/app/views/inline_forms/_versions_file_entry.html.erb +1 -1
  15. data/app/views/inline_forms/_versions_list.html.erb +14 -12
  16. data/app/views/layouts/application.html.erb +10 -3
  17. data/app/views/layouts/devise.html.erb +1 -1
  18. data/app/views/layouts/inline_forms.html.erb +9 -2
  19. data/lib/inline_forms/form_elements/dropdown_helper.rb +1 -1
  20. data/lib/inline_forms/form_elements/file_field_helper.rb +3 -1
  21. data/lib/inline_forms/form_elements/info_list_helper.rb +3 -1
  22. data/lib/inline_forms/version.rb +1 -1
  23. data/lib/locales/inline_forms.en.yml +23 -1
  24. data/lib/locales/inline_forms.nl.yml +26 -1
  25. data/test/dummy/config/application.rb +11 -0
  26. data/test/integration/csp_nonce_test.rb +50 -0
  27. data/test/integration/dropdown_test.rb +17 -0
  28. data/test/integration/file_slots_test.rb +91 -0
  29. data/test/integration/list_params_test.rb +200 -0
  30. data/test/integration/locale_test.rb +131 -0
  31. data/test/integration/presentation_escaping_test.rb +80 -0
  32. metadata +11 -2
  33. data/app/views/inline_forms/_flash.html.erb +0 -13
@@ -1,5 +1,5 @@
1
1
  <!DOCTYPE html>
2
- <html>
2
+ <html lang="<%= I18n.locale %>">
3
3
  <head>
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
@@ -11,6 +11,9 @@
11
11
  <%= stylesheet_link_tag "trix" %>
12
12
  <%= inline_forms_user_color_overrides_style %>
13
13
  <%= csrf_meta_tags %>
14
+ <%# Publishes the CSP nonce (nothing without a nonce generator) for the %>
15
+ <%# scripts Turbo inserts. %>
16
+ <%= csp_meta_tag %>
14
17
  </head>
15
18
 
16
19
  <body class="<%= inline_forms_body_theme_class %>">
@@ -19,10 +22,14 @@
19
22
  <%# Turbo (Hotwire) is loaded as an ES module so it does not collide with %>
20
23
  <%# the classic-script Sprockets bundle above. Drive uses the Turbo default %>
21
24
  <%# (enabled). %>
22
- <script type="module">
25
+ <%# The nonce: hosts run a CSP with script-src 'nonce-…' (nil, so no %>
26
+ <%# attribute, without a nonce generator). data-turbo-eval="false": Turbo %>
27
+ <%# Drive must not re-run it on visits (Turbo is loaded; a re-inserted %>
28
+ <%# copy would carry a newer nonce the page's policy rejects). %>
29
+ <%= tag.script type: "module", nonce: content_security_policy_nonce, data: { turbo_eval: false } do %>
23
30
  import { Turbo } from "<%= asset_path('turbo.min.js') %>"
24
31
  window.Turbo = Turbo
25
- </script>
32
+ <% end %>
26
33
  <%# Trix 2 (vendored; no CDN). Self-registers its custom elements on load. %>
27
34
  <%= javascript_include_tag "trix.min" %>
28
35
  </body>
@@ -1,5 +1,5 @@
1
1
  <!DOCTYPE html>
2
- <html>
2
+ <html lang="<%= I18n.locale %>">
3
3
  <head>
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
@@ -14,6 +14,9 @@
14
14
  <%= stylesheet_link_tag "trix" %>
15
15
  <%= inline_forms_user_color_overrides_style %>
16
16
  <%= csrf_meta_tags %>
17
+ <%# Publishes the CSP nonce (nothing without a nonce generator) for the %>
18
+ <%# scripts Turbo inserts. %>
19
+ <%= csp_meta_tag %>
17
20
  </head>
18
21
 
19
22
  <body class="<%= inline_forms_body_theme_class %>">
@@ -40,10 +43,14 @@
40
43
  <%# Turbo (Hotwire) is loaded as an ES module so it does not collide with %>
41
44
  <%# the classic-script Sprockets bundle above. Drive stays at the default %>
42
45
  <%# (enabled); inline interactions use `<turbo-frame>` + HTML responses. %>
43
- <script type="module">
46
+ <%# The nonce: hosts run a CSP with script-src 'nonce-…' (nil, so no %>
47
+ <%# attribute, without a nonce generator). data-turbo-eval="false": Turbo %>
48
+ <%# Drive must not re-run it on visits (Turbo is loaded; a re-inserted %>
49
+ <%# copy would carry a newer nonce the page's policy rejects). %>
50
+ <%= tag.script type: "module", nonce: content_security_policy_nonce, data: { turbo_eval: false } do %>
44
51
  import { Turbo } from "<%= asset_path('turbo.min.js') %>"
45
52
  window.Turbo = Turbo
46
- </script>
53
+ <% end %>
47
54
  <%# Trix 2 (vendored; no CDN). Self-registers its custom elements on load. %>
48
55
  <%= javascript_include_tag "trix.min" %>
49
56
  </body>
@@ -29,7 +29,7 @@ module InlineForms
29
29
  if klass.method_defined? :disabled_for_dropdown?
30
30
  options_disabled = values.map{|v| v.id if v.disabled_for_dropdown?}.compact
31
31
  end
32
- values.sort_by {|v|v.send presentation}
32
+ values = values.sort_by {|v|v.send presentation}
33
33
  # the leading underscore is to avoid name conflicts, like 'email' and 'email_type' will result in 'email' and 'email[email_type_id]' in the form!
34
34
  collection_select( ('_' + object.class.to_s.underscore).to_sym,
35
35
  attribute.to_s.foreign_key.to_sym,
@@ -9,7 +9,9 @@ module InlineForms
9
9
  o = object.send(attribute)
10
10
  msg = o.to_s
11
11
  if o.send(:present?)
12
- msg = "replace | <a href='#{o.send(:url)}'>#{o.send(:path).gsub(/^.*\//,'')}</a>".html_safe
12
+ # The stored file name and url are data: escape them before the
13
+ # string is marked html_safe.
14
+ msg = "replace | <a href='#{ERB::Util.h(o.send(:url))}'>#{ERB::Util.h(o.send(:path).gsub(/^.*\//,''))}</a>".html_safe
13
15
  end
14
16
  link_to_inline_edit object, attribute, msg, from_callee: __callee__
15
17
  end
@@ -11,7 +11,9 @@ module InlineForms
11
11
  out = "<div class='row #{cycle('odd', 'even')}'>--</div>" if object.send(attribute).empty?
12
12
  object.send(attribute).sort.each do | item |
13
13
  out << "<div class='row #{cycle('odd', 'even')}'>"
14
- out << item._presentation
14
+ # _presentation is model data (e.g. a User's name listed on a Role):
15
+ # escape it, the whole string is marked html_safe below.
16
+ out << ERB::Util.h(item._presentation)
15
17
  out << '</div>'
16
18
  end
17
19
  out.html_safe
@@ -1,5 +1,5 @@
1
1
  # -*- encoding : utf-8 -*-
2
2
 
3
3
  module InlineForms
4
- VERSION = "8.1.56"
4
+ VERSION = "8.1.58"
5
5
  end
@@ -14,6 +14,7 @@ en:
14
14
  sign_up: sign up
15
15
  login_with_email_and_password: login with email and password
16
16
  sign_in: sign in
17
+ login: login
17
18
  links:
18
19
  no_unlock_instructions: did you not receive unlock instructions?
19
20
  sign_up: sign up
@@ -26,11 +27,26 @@ en:
26
27
  view:
27
28
  close: close
28
29
  add_new: make a new %{model}
30
+ create_failed: Could not create %{model}.
29
31
  undo: undo
30
32
  restore: restore
31
33
  trash: trash
32
34
  list_versions: List versions
33
35
  close_versions_list: Close versions list
36
+ undelete: undelete
37
+ versions: Versions (%{count})
38
+ versions_event: Event
39
+ versions_when: When
40
+ versions_done_by: Done by
41
+ versions_changeset: Changeset
42
+ versions_old_value: old value
43
+ versions_new_value: new value
44
+ versions_empty: empty
45
+ versions_rich_text: rich text
46
+ version_events:
47
+ create: create
48
+ update: update
49
+ destroy: destroy
34
50
  files:
35
51
  download: download
36
52
  replace: replace file
@@ -116,7 +132,13 @@ en:
116
132
  expired: expired
117
133
  pending_migration:
118
134
  notice: "pending migration \u2014 run rails db:migrate"
119
- succes: success.
135
+ # The create flash (InlineFormsController#create). Was misspelled
136
+ # `succes`, so t("success") was missing in every app.
137
+ success: "%{message} created."
138
+ # The engine header (inline_forms/_header): the More menu and logout.
139
+ common:
140
+ more: more...
141
+ logout: logout
120
142
  activerecord:
121
143
  models:
122
144
  language: language
@@ -14,6 +14,7 @@ nl:
14
14
  sign_up: inschrijven
15
15
  login_with_email_and_password: aanmelden door email-adres en wachtwoord in te voeren.
16
16
  sign_in: aanmelden
17
+ login: inloggen
17
18
  links:
18
19
  no_unlock_instructions: geen instructies ontvangen om blokkade op te heffen?
19
20
  sign_up: inschrijven
@@ -26,8 +27,26 @@ nl:
26
27
  view:
27
28
  close: sluiten
28
29
  add_new: Maak een nieuw %{model}
30
+ create_failed: Kan %{model} niet aanmaken.
29
31
  undo: terughalen
32
+ restore: terugzetten
30
33
  trash: weggooien
34
+ undelete: terughalen
35
+ list_versions: versies tonen
36
+ close_versions_list: versies verbergen
37
+ versions: Versies (%{count})
38
+ versions_event: Gebeurtenis
39
+ versions_when: Wanneer
40
+ versions_done_by: Door
41
+ versions_changeset: Wijzigingen
42
+ versions_old_value: oude waarde
43
+ versions_new_value: nieuwe waarde
44
+ versions_empty: leeg
45
+ versions_rich_text: opgemaakte tekst
46
+ version_events:
47
+ create: aangemaakt
48
+ update: gewijzigd
49
+ destroy: verwijderd
31
50
  files:
32
51
  download: downloaden
33
52
  replace: bestand vervangen
@@ -113,7 +132,13 @@ nl:
113
132
  expired: verlopen
114
133
  pending_migration:
115
134
  notice: "migratie in afwachting \u2014 draai rails db:migrate"
116
- succes: het ding is aangemaakt.
135
+ # The create flash (InlineFormsController#create). Was misspelled
136
+ # `succes`, so t("success") was missing in every app.
137
+ success: "%{message} is aangemaakt."
138
+ # The engine header (inline_forms/_header): the More menu and logout.
139
+ common:
140
+ more: meer...
141
+ logout: uitloggen
117
142
  activerecord:
118
143
  models:
119
144
  language: taal
@@ -17,6 +17,9 @@ require "action_view/railtie"
17
17
  Bundler.require(*Rails.groups)
18
18
 
19
19
  require "inline_forms"
20
+ # Host apps list rails-i18n in their Gemfile (the installer writes it): the
21
+ # Rails strings for nl (time/date formats, ...) the gem's views localize with.
22
+ require "rails-i18n"
20
23
  # The schema-GUI engine (separate gem, same repo; on the load path via the
21
24
  # Gemfile's test group). Its integration tests live in test/integration/.
22
25
  require "inline_forms_schema_edit"
@@ -34,5 +37,13 @@ module Dummy
34
37
  config.active_record.maintain_test_schema = false
35
38
  # Raise in-place so integration failures carry the real backtrace.
36
39
  config.action_dispatch.show_exceptions = :none
40
+ # A nonce-based CSP like StProject's (report-only): the layouts' inline
41
+ # script must carry the per-request nonce (test/integration/csp_nonce_test.rb).
42
+ config.content_security_policy do |policy|
43
+ policy.script_src :self
44
+ end
45
+ config.content_security_policy_nonce_generator = ->(_request) { SecureRandom.base64(16) }
46
+ config.content_security_policy_nonce_directives = %w[script-src]
47
+ config.content_security_policy_report_only = true
37
48
  end
38
49
  end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../integration_test_helper"
4
+
5
+ # Host apps run a CSP with script-src 'nonce-…' (StProject; the dummy app is
6
+ # configured the same way, report-only). Every inline <script> the gem's
7
+ # layouts render carries the request's nonce, and the nonce is published in
8
+ # a csp-nonce meta tag for the scripts Turbo inserts. The unused
9
+ # inline_forms/_flash partial (an inline jQuery <script>) is gone.
10
+ class CspNonceTest < InlineFormsIntegrationTestCase
11
+ def header_nonce
12
+ policy = response.headers["Content-Security-Policy-Report-Only"].to_s
13
+ policy[/'nonce-([^']+)'/, 1]
14
+ end
15
+
16
+ def inline_scripts(html)
17
+ html.scan(/<script\b[^>]*>/).reject { |tag| tag.include?(" src=") }
18
+ end
19
+
20
+ def assert_inline_scripts_nonced(html, nonce)
21
+ scripts = inline_scripts(html)
22
+ assert_predicate scripts, :any?, "the layout's Turbo module script"
23
+ scripts.each { |tag| assert_includes tag, %(nonce="#{nonce}"), tag }
24
+ end
25
+
26
+ test "the inline_forms layout nonces its inline script and publishes the nonce" do
27
+ get widgets_path
28
+
29
+ assert_response :success
30
+ nonce = header_nonce
31
+ assert nonce, "precondition: the dummy sends a nonce-based CSP"
32
+ assert_inline_scripts_nonced(response.body, nonce)
33
+ assert_match %r{<script type="module" nonce="#{Regexp.escape(nonce)}" data-turbo-eval="false">\s*import \{ Turbo \}}, response.body
34
+ assert_includes response.body, %(<meta name="csp-nonce" content="#{nonce}" />)
35
+ end
36
+
37
+ test "the application layout nonces its inline script too" do
38
+ get widgets_path
39
+ nonce = header_nonce
40
+ html = controller.render_to_string(template: "stats/show", layout: "application")
41
+
42
+ assert_inline_scripts_nonced(html, nonce)
43
+ assert_includes html, %(<meta name="csp-nonce" content="#{nonce}" />)
44
+ end
45
+
46
+ test "the unused inline_forms/_flash partial with its inline script is gone" do
47
+ get widgets_path
48
+ refute controller.lookup_context.exists?("flash", [ "inline_forms" ], true)
49
+ end
50
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../integration_test_helper"
4
+
5
+ # dropdown_edit sorts its options by presentation; the sort_by result used to
6
+ # be discarded, so the options came out in id order.
7
+ class DropdownTest < InlineFormsIntegrationTestCase
8
+ test "dropdown_edit lists the options sorted by presentation" do
9
+ %w[Zeta Alpha Mu].each { |name| Machine.create!(name: name) }
10
+ part = Part.create!(name: "Gear")
11
+
12
+ get machines_path
13
+ html = controller.view_context.dropdown_edit(part, :machine)
14
+
15
+ assert_equal %w[Alpha Mu Zeta], html.scan(%r{<option value="\d+">([^<]*)</option>}).flatten
16
+ end
17
+ end
@@ -299,6 +299,44 @@ class FileSlotsTest < InlineFormsIntegrationTestCase
299
299
  assert_nil @doc.reload.data
300
300
  end
301
301
 
302
+ # The stored SHA-256 is checked before bytes go back into a slot: a
303
+ # tampered (or corrupted) trash row is refused, nothing is written, and the
304
+ # row stays in the trash.
305
+ test "restore refuses a trash row whose bytes do not match its checksum" do
306
+ with_file
307
+ entry = @doc.inline_forms_remove_file!(:filename, by: 1)
308
+ InlineForms::TrashedFile.where(id: entry.id).update_all(data: OTHER)
309
+
310
+ post restore_file_document_path(@doc, attribute: "filename", trash_id: entry.id, update: @frame),
311
+ headers: stream_headers(@frame)
312
+
313
+ assert_response :success
314
+ assert_includes response.body, "can no longer be restored"
315
+ @doc.reload
316
+ assert_nil @doc.filename
317
+ assert_nil @doc.data
318
+ assert entry.reload.trashed?, "not marked restored"
319
+ assert_equal OTHER, entry.file_data, "the row is left as it is, for a look"
320
+ end
321
+
322
+ test "restore! raises and logs on a checksum mismatch, writing nothing" do
323
+ with_file
324
+ entry = @doc.inline_forms_remove_file!(:filename, by: 1)
325
+ InlineForms::TrashedFile.where(id: entry.id).update_all(checksum: Digest::SHA256.hexdigest("something else"))
326
+ log = StringIO.new
327
+ original_logger = Rails.logger
328
+ Rails.logger = ActiveSupport::Logger.new(log)
329
+
330
+ assert_raises(InlineForms::TrashedFile::NotRestorable) { entry.reload.restore!(by: 1) }
331
+
332
+ assert_includes log.string, "checksum mismatch entry=#{entry.id}"
333
+ assert_nil @doc.reload.data
334
+ assert entry.reload.trashed?
335
+ assert_equal 0, trash.where(reason: "replaced").count
336
+ ensure
337
+ Rails.logger = original_logger
338
+ end
339
+
302
340
  test "a trash entry of another record is not found" do
303
341
  other = Document.create!(title: "Dossier B", filename: upload(PLAN))
304
342
  entry = other.inline_forms_remove_file!(:filename, by: 1)
@@ -414,6 +452,43 @@ class FileSlotsTest < InlineFormsIntegrationTestCase
414
452
  assert_includes response.body, "restorable until"
415
453
  end
416
454
 
455
+ # FileSlotsConcern has no per-tab attribute list, so the field it
456
+ # re-renders used to build its links' slots= from the model's full list:
457
+ # after a restore on one tab, the next action's panel (and the versions
458
+ # panel) listed the other tabs' slots. The page's own slots param now
459
+ # carries through.
460
+ test "a file action re-renders with the page's slots only" do
461
+ with_file
462
+ @doc.update!(plan_filename: upload(OTHER, "other-tab.pdf"))
463
+ @doc.inline_forms_remove_file!(:plan_filename, by: 1)
464
+ entry = @doc.inline_forms_remove_file!(:filename, by: 1)
465
+
466
+ post restore_file_document_path(@doc, attribute: "filename", trash_id: entry.id, update: @frame,
467
+ slots: "filename", panel: "open"),
468
+ headers: stream_headers(@frame)
469
+
470
+ assert_response :success
471
+ assert_equal PLAN, @doc.reload.data
472
+ remove_link = response.body[%r{href="([^"]*/remove_file\?[^"]*)"}, 1]
473
+ assert remove_link, "the restored field offers remove"
474
+ assert_equal "filename", Rack::Utils.parse_query(URI.parse(CGI.unescapeHTML(remove_link)).query)["slots"]
475
+ refute_includes response.body, "other-tab.pdf", "neither the trash panel nor the versions panel lists the other slot"
476
+ end
477
+
478
+ test "a file action's slots param is reduced to declared, listed slots" do
479
+ with_file
480
+ @doc.update!(plan_filename: upload(OTHER, "other-tab.pdf"))
481
+ @doc.inline_forms_remove_file!(:plan_filename, by: 1)
482
+ entry = @doc.inline_forms_remove_file!(:filename, by: 1)
483
+
484
+ post restore_file_document_path(@doc, attribute: "filename", trash_id: entry.id, update: @frame,
485
+ slots: "filename,title,data", panel: "open"),
486
+ headers: stream_headers(@frame)
487
+
488
+ remove_link = response.body[%r{href="([^"]*/remove_file\?[^"]*)"}, 1]
489
+ assert_equal "filename", Rack::Utils.parse_query(URI.parse(CGI.unescapeHTML(remove_link)).query)["slots"]
490
+ end
491
+
417
492
  test "the panel needs :read_file_trash" do
418
493
  Ability.restrictions = -> { cannot :read_file_trash, Document }
419
494
  panel = "document_#{@doc.id}_file_trash"
@@ -456,6 +531,22 @@ class FileSlotsTest < InlineFormsIntegrationTestCase
456
531
  assert_nil restored.data
457
532
  end
458
533
 
534
+ test "undoing a destroy refuses a tampered trash row: nothing is restored" do
535
+ with_file
536
+ row = "document_#{@doc.id}"
537
+ delete document_path(@doc, update: row), headers: frame_headers(row)
538
+ entry = trash.where(reason: "record_destroyed").sole
539
+ InlineForms::TrashedFile.where(id: entry.id).update_all(data: OTHER)
540
+
541
+ version = PaperTrail::Version.where(item_type: "Document", item_id: @doc.id, event: "destroy").last
542
+ post revert_document_path(version.id, update: "documents_list"), headers: stream_headers("documents_list")
543
+
544
+ assert_response :unprocessable_entity
545
+ refute Document.exists?(@doc.id), "the revert rolled back"
546
+ assert entry.reload.trashed?
547
+ assert_equal OTHER, entry.file_data
548
+ end
549
+
459
550
  test "reverting an update never touches a slot, even from an old version carrying a filename" do
460
551
  with_file
461
552
  @doc.update!(title: "Renamed")
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../integration_test_helper"
4
+
5
+ # Request params that end up in a list's markup.
6
+ #
7
+ # * parent_class / parent_id: _list built the nested frame as
8
+ # `raw "<turbo-frame id=\"#{update_span}\" …>"` with update_span made from
9
+ # params[:parent_id], and constantized params[:parent_class]; nothing found
10
+ # the parent before the render, so a crafted parent_id was reflected into
11
+ # the page. The controller now refuses malformed parent params (400) in
12
+ # index/new/create/show, and the frame is built with tag.turbo_frame.
13
+ # * pagination: the page links carry every filter of the query string, not
14
+ # just `search`.
15
+ #
16
+ # Part and DailyReport belong to Machine; Widget is a top-level list
17
+ # (per_page 7, see ApplicationRecord).
18
+ class ListParamsTest < InlineFormsIntegrationTestCase
19
+ PAYLOAD = %("><img src=x onerror=alert(1)>)
20
+
21
+ setup do
22
+ @machine = Machine.create!(name: "Press")
23
+ @part = Part.create!(name: "Gear", machine: @machine)
24
+ @list_frame = "machine_#{@machine.id}_parts_list"
25
+ end
26
+
27
+ def assert_refused_without_payload
28
+ assert_response :bad_request
29
+ assert controller.instance_variable_defined?(:@_authorized),
30
+ "the refusal runs after authorization (check_authorization stays satisfied)"
31
+ refute_includes response.body, "<img"
32
+ end
33
+
34
+ def nested(parent_id: @machine.id, parent_class: "Machine", update: @list_frame)
35
+ { parent_class: parent_class, parent_id: parent_id, update: update, ul_needed: 1 }
36
+ end
37
+
38
+ # ---- malformed parent params --------------------------------------------
39
+
40
+ test "a crafted parent_id on the nested list is a 400 and never rendered" do
41
+ get parts_path(nested(parent_id: PAYLOAD, update: "x")), headers: frame_headers("x")
42
+ assert_refused_without_payload
43
+ end
44
+
45
+ test "a crafted parent_id is refused on a full-page visit too" do
46
+ get parts_path(nested(parent_id: PAYLOAD, update: "x"))
47
+ assert_refused_without_payload
48
+ end
49
+
50
+ test "a parent_class that is not an association of the model is a 400" do
51
+ %w[Widget Kernel machine Machines ActiveRecord::Base].each do |parent_class|
52
+ get parts_path(nested(parent_class: parent_class)), headers: frame_headers(@list_frame)
53
+ assert_response :bad_request, "parent_class=#{parent_class}"
54
+ end
55
+ end
56
+
57
+ test "a non-integer parent_id is a 400, with or without parent_class" do
58
+ [ "1 OR 1=1", "-1", "1.5", "" ].each do |parent_id|
59
+ get parts_path(nested(parent_id: parent_id)), headers: frame_headers(@list_frame)
60
+ assert_response :bad_request, "parent_id=#{parent_id.inspect}"
61
+ end
62
+ get parts_path(parent_id: PAYLOAD, update: "x")
63
+ assert_refused_without_payload
64
+ end
65
+
66
+ test "new with crafted parent params is a 400" do
67
+ get new_part_path(nested(parent_id: PAYLOAD, update: "machine_#{@machine.id}_parts")),
68
+ headers: frame_headers("machine_#{@machine.id}_parts")
69
+ assert_refused_without_payload
70
+
71
+ get new_part_path(nested(parent_class: "Widget", update: "machine_#{@machine.id}_parts")),
72
+ headers: frame_headers("machine_#{@machine.id}_parts")
73
+ assert_response :bad_request
74
+ end
75
+
76
+ test "create with crafted parent params is a 400 and creates nothing" do
77
+ frame = "machine_#{@machine.id}_parts"
78
+ assert_no_difference -> { Part.count } do
79
+ post parts_path(nested(parent_id: PAYLOAD, update: frame)), params: { name: "Bolt" }, headers: frame_headers(frame)
80
+ assert_refused_without_payload
81
+ post parts_path(nested(parent_class: "Widget", update: frame)), params: { name: "Bolt" }, headers: frame_headers(frame)
82
+ assert_response :bad_request
83
+ end
84
+ end
85
+
86
+ test "show with a crafted parent_id is a 400" do
87
+ row = "machine_#{@machine.id}_part_#{@part.id}"
88
+ get part_path(@part, update: row, parent_class: "Machine", parent_id: PAYLOAD), headers: frame_headers(row)
89
+ assert_refused_without_payload
90
+ end
91
+
92
+ # ---- well-formed nested lists still work --------------------------------
93
+
94
+ test "a well-formed nested list renders its frame and rows" do
95
+ get parts_path(nested), headers: frame_headers(@list_frame)
96
+
97
+ assert_response :success
98
+ assert_includes response.body, %(<turbo-frame id="#{@list_frame}" class="list_container">)
99
+ assert_includes response.body, "Gear"
100
+ end
101
+
102
+ test "well-formed nested new and create still work" do
103
+ frame = "machine_#{@machine.id}_parts"
104
+ get new_part_path(nested(update: frame)), headers: frame_headers(frame)
105
+ assert_response :success
106
+
107
+ assert_difference -> { @machine.parts.count }, 1 do
108
+ post parts_path(nested(update: frame)), params: { name: "Bolt" }, headers: frame_headers(frame)
109
+ end
110
+ assert_response :success
111
+ end
112
+
113
+ test "the nested list of a not_accessible_through_html? child still renders" do
114
+ DailyReport.create!(name: "Monday", machine: @machine)
115
+ frame = "machine_#{@machine.id}_daily_reports_list"
116
+ get daily_reports_path(parent_class: "Machine", parent_id: @machine.id, update: frame, ul_needed: 1),
117
+ headers: frame_headers(frame)
118
+
119
+ assert_response :success
120
+ assert_includes response.body, "Monday"
121
+ end
122
+
123
+ test "the list frame id is attribute-escaped by the partial itself" do
124
+ html = PartsController.render(
125
+ partial: "inline_forms/list",
126
+ assigns: { Klass: Part, parent_class: "Machine", parent_id: PAYLOAD, ul_needed: "1",
127
+ objects: Part.where(id: @part.id).paginate(page: 1) }
128
+ )
129
+
130
+ refute_includes html, "<img"
131
+ assert_includes html, %(<turbo-frame id="machine_&quot;&gt;&lt;img src=x onerror=alert(1)&gt;_parts_list" class="list_container">)
132
+ end
133
+
134
+ # ---- pagination keeps the filters ---------------------------------------
135
+
136
+ test "page links of a filtered list carry every filter" do
137
+ 9.times { |i| Widget.create!(name: "Match #{i}") }
138
+ Widget.create!(name: "Other")
139
+
140
+ get widgets_path(search: "Match", sorteer: "name", first_name: "An", group: { id: 3 }, status: { id: 4 },
141
+ update: "widgets_list"),
142
+ headers: frame_headers("widgets_list")
143
+
144
+ assert_response :success
145
+ page_link = response.body[/href="([^"]*page=2[^"]*)"/, 1]
146
+ assert page_link, "a link to page 2"
147
+ query = Rack::Utils.parse_nested_query(URI.parse(CGI.unescapeHTML(page_link)).query)
148
+ assert_equal "Match", query["search"]
149
+ assert_equal "name", query["sorteer"]
150
+ assert_equal "An", query["first_name"]
151
+ assert_equal({ "id" => "3" }, query["group"])
152
+ assert_equal({ "id" => "4" }, query["status"])
153
+ assert_equal "widgets_list", query["update"]
154
+ assert_equal "2", query["page"]
155
+
156
+ get CGI.unescapeHTML(page_link), headers: frame_headers("widgets_list")
157
+ assert_response :success
158
+ back = response.body[/href="([^"]*page=1[^"]*)"/, 1]
159
+ assert back, "a link back to page 1"
160
+ back_query = Rack::Utils.parse_nested_query(URI.parse(CGI.unescapeHTML(back)).query)
161
+ assert_equal "Match", back_query["search"]
162
+ assert_equal({ "id" => "3" }, back_query["group"])
163
+ refute_includes response.body, "Other"
164
+ end
165
+
166
+ test "page links of a list rendered after create keep the request's filters" do
167
+ 9.times { |i| Machine.create!(name: "Match #{i}") }
168
+
169
+ post machines_path(update: "machines_list", search: "Match", sorteer: "name"),
170
+ params: { name: "Match 9" }, headers: frame_headers("machines_list")
171
+
172
+ assert_response :success
173
+ page_link = response.body[/href="([^"]*page=2[^"]*)"/, 1]
174
+ assert page_link, "a link to page 2"
175
+ query = Rack::Utils.parse_nested_query(URI.parse(CGI.unescapeHTML(page_link)).query)
176
+ assert_equal "Match", query["search"]
177
+ assert_equal "name", query["sorteer"]
178
+ end
179
+
180
+ # url_for options in the query string must not shape the page links.
181
+ # will_paginate leaves out script_name / original_script_name; merging
182
+ # request.query_parameters into the links bypassed that, so the payload
183
+ # became the start of every page link's href, and controller= raised.
184
+ test "url options in the query string never reach the page links" do
185
+ 9.times { |i| Widget.create!(name: "W #{i}") }
186
+
187
+ %w[script_name original_script_name].each do |option|
188
+ get "/widgets?update=widgets_list&#{option}=javascript:alert(1)//", headers: frame_headers("widgets_list")
189
+ assert_response :success
190
+ page_link = response.body[/href="([^"]*page=2[^"]*)"/, 1]
191
+ assert page_link, "a link to page 2"
192
+ assert page_link.start_with?("/widgets?"), "#{option} shaped the page link: #{page_link}"
193
+ refute_includes response.body, "javascript:alert"
194
+ end
195
+
196
+ get "/widgets?update=widgets_list&controller=widgets_x", headers: frame_headers("widgets_list")
197
+ assert_response :success
198
+ assert_match %r{href="/widgets\?[^"]*page=2}, response.body
199
+ end
200
+ end