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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +97 -0
- data/app/assets/stylesheets/inline_forms/_theme.scss +13 -0
- data/app/assets/stylesheets/inline_forms/devise.scss +4 -1
- data/app/assets/stylesheets/inline_forms/foundation_and_overrides.scss +1 -0
- data/app/assets/stylesheets/inline_forms/inline_forms.scss +35 -16
- data/app/controllers/inline_forms_controller.rb +50 -6
- data/app/helpers/inline_forms_file_trash_helper.rb +10 -0
- data/app/models/concerns/inline_forms/stored_files.rb +5 -1
- data/app/models/inline_forms/trashed_file.rb +16 -2
- data/app/views/devise/sessions/_form.html.erb +2 -2
- data/app/views/inline_forms/_list.html.erb +24 -7
- data/app/views/inline_forms/_versions.html.erb +1 -1
- data/app/views/inline_forms/_versions_file_entry.html.erb +1 -1
- data/app/views/inline_forms/_versions_list.html.erb +14 -12
- data/app/views/layouts/application.html.erb +10 -3
- data/app/views/layouts/devise.html.erb +1 -1
- data/app/views/layouts/inline_forms.html.erb +9 -2
- data/lib/inline_forms/form_elements/dropdown_helper.rb +1 -1
- data/lib/inline_forms/form_elements/file_field_helper.rb +3 -1
- data/lib/inline_forms/form_elements/info_list_helper.rb +3 -1
- data/lib/inline_forms/version.rb +1 -1
- data/lib/locales/inline_forms.en.yml +23 -1
- data/lib/locales/inline_forms.nl.yml +26 -1
- data/test/dummy/config/application.rb +11 -0
- data/test/integration/csp_nonce_test.rb +50 -0
- data/test/integration/dropdown_test.rb +17 -0
- data/test/integration/file_slots_test.rb +91 -0
- data/test/integration/list_params_test.rb +200 -0
- data/test/integration/locale_test.rb +131 -0
- data/test/integration/presentation_escaping_test.rb +80 -0
- metadata +11 -2
- 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
|
-
|
|
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
|
-
|
|
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>
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/inline_forms/version.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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_"><img src=x onerror=alert(1)>_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
|