inline_forms 8.1.55 → 8.1.57
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 +92 -0
- data/app/controllers/concerns/file_slots_concern.rb +6 -0
- data/app/controllers/inline_forms_controller.rb +88 -30
- 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/file_field_helper.rb +3 -1
- data/lib/inline_forms/form_elements/info_list_helper.rb +3 -1
- data/lib/inline_forms/gem_files.rb +3 -2
- data/lib/inline_forms/version.rb +1 -1
- data/lib/locales/inline_forms.en.yml +22 -1
- data/lib/locales/inline_forms.nl.yml +25 -1
- data/test/dummy/app/controllers/application_controller.rb +5 -2
- data/test/dummy/app/controllers/daily_reports_controller.rb +4 -0
- data/test/dummy/app/models/daily_report.rb +21 -0
- data/test/dummy/app/models/machine.rb +1 -0
- data/test/dummy/config/application.rb +11 -0
- data/test/dummy/config/routes.rb +5 -0
- data/test/gem_package_modes_test.rb +1 -1
- data/test/integration/csp_nonce_test.rb +50 -0
- data/test/integration/file_slots_test.rb +91 -0
- data/test/integration/html_refusal_test.rb +152 -0
- data/test/integration/list_params_test.rb +200 -0
- data/test/integration/locale_test.rb +113 -0
- data/test/integration/presentation_escaping_test.rb +80 -0
- data/test/integration_test_helper.rb +11 -0
- metadata +15 -2
- data/app/views/inline_forms/_flash.html.erb +0 -13
|
@@ -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>
|
|
@@ -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
|
|
@@ -27,8 +27,9 @@ module InlineFormsGemFiles
|
|
|
27
27
|
# the token leaked into `gem build` on the release machine. Exclude it hard,
|
|
28
28
|
# independent of any ignore configuration.
|
|
29
29
|
#
|
|
30
|
-
# Same for
|
|
31
|
-
#
|
|
30
|
+
# Same for Claude Code files: .claude/ (untracked, per checkout:
|
|
31
|
+
# launch.json, settings.local.json, worktrees) and CLAUDE.md (committed so
|
|
32
|
+
# worktrees see it, but repo instructions, not gem content).
|
|
32
33
|
EXCLUDED_FILE_PREFIXES = %w[
|
|
33
34
|
stuff/
|
|
34
35
|
.claude/
|
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
|
|
@@ -31,6 +32,20 @@ en:
|
|
|
31
32
|
trash: trash
|
|
32
33
|
list_versions: List versions
|
|
33
34
|
close_versions_list: Close versions list
|
|
35
|
+
undelete: undelete
|
|
36
|
+
versions: Versions (%{count})
|
|
37
|
+
versions_event: Event
|
|
38
|
+
versions_when: When
|
|
39
|
+
versions_done_by: Done by
|
|
40
|
+
versions_changeset: Changeset
|
|
41
|
+
versions_old_value: old value
|
|
42
|
+
versions_new_value: new value
|
|
43
|
+
versions_empty: empty
|
|
44
|
+
versions_rich_text: rich text
|
|
45
|
+
version_events:
|
|
46
|
+
create: create
|
|
47
|
+
update: update
|
|
48
|
+
destroy: destroy
|
|
34
49
|
files:
|
|
35
50
|
download: download
|
|
36
51
|
replace: replace file
|
|
@@ -116,7 +131,13 @@ en:
|
|
|
116
131
|
expired: expired
|
|
117
132
|
pending_migration:
|
|
118
133
|
notice: "pending migration \u2014 run rails db:migrate"
|
|
119
|
-
|
|
134
|
+
# The create flash (InlineFormsController#create). Was misspelled
|
|
135
|
+
# `succes`, so t("success") was missing in every app.
|
|
136
|
+
success: "%{message} created."
|
|
137
|
+
# The engine header (inline_forms/_header): the More menu and logout.
|
|
138
|
+
common:
|
|
139
|
+
more: more...
|
|
140
|
+
logout: logout
|
|
120
141
|
activerecord:
|
|
121
142
|
models:
|
|
122
143
|
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
|
|
@@ -27,7 +28,24 @@ nl:
|
|
|
27
28
|
close: sluiten
|
|
28
29
|
add_new: Maak een nieuw %{model}
|
|
29
30
|
undo: terughalen
|
|
31
|
+
restore: terugzetten
|
|
30
32
|
trash: weggooien
|
|
33
|
+
undelete: terughalen
|
|
34
|
+
list_versions: versies tonen
|
|
35
|
+
close_versions_list: versies verbergen
|
|
36
|
+
versions: Versies (%{count})
|
|
37
|
+
versions_event: Gebeurtenis
|
|
38
|
+
versions_when: Wanneer
|
|
39
|
+
versions_done_by: Door
|
|
40
|
+
versions_changeset: Wijzigingen
|
|
41
|
+
versions_old_value: oude waarde
|
|
42
|
+
versions_new_value: nieuwe waarde
|
|
43
|
+
versions_empty: leeg
|
|
44
|
+
versions_rich_text: opgemaakte tekst
|
|
45
|
+
version_events:
|
|
46
|
+
create: aangemaakt
|
|
47
|
+
update: gewijzigd
|
|
48
|
+
destroy: verwijderd
|
|
31
49
|
files:
|
|
32
50
|
download: downloaden
|
|
33
51
|
replace: bestand vervangen
|
|
@@ -113,7 +131,13 @@ nl:
|
|
|
113
131
|
expired: verlopen
|
|
114
132
|
pending_migration:
|
|
115
133
|
notice: "migratie in afwachting \u2014 draai rails db:migrate"
|
|
116
|
-
|
|
134
|
+
# The create flash (InlineFormsController#create). Was misspelled
|
|
135
|
+
# `succes`, so t("success") was missing in every app.
|
|
136
|
+
success: "%{message} is aangemaakt."
|
|
137
|
+
# The engine header (inline_forms/_header): the More menu and logout.
|
|
138
|
+
common:
|
|
139
|
+
more: meer...
|
|
140
|
+
logout: uitloggen
|
|
117
141
|
activerecord:
|
|
118
142
|
models:
|
|
119
143
|
language: taal
|
|
@@ -5,10 +5,13 @@ class ApplicationController < ActionController::Base
|
|
|
5
5
|
# create/new HTML responses) calls `current_user.name` and
|
|
6
6
|
# `current_user.role?`, so a nil current_user cannot render it. The stub is
|
|
7
7
|
# superadmin so `destroy_permitted?` allows hard destroy, matching the
|
|
8
|
-
# generated apps' test user.
|
|
8
|
+
# generated apps' test user. A test can switch that off with
|
|
9
|
+
# dummy_user_superadmin (reset in InlineFormsIntegrationTestCase#setup).
|
|
10
|
+
mattr_accessor :dummy_user_superadmin, default: true
|
|
11
|
+
|
|
9
12
|
DummyUser = Struct.new(:id, :name) do
|
|
10
13
|
def role?(role)
|
|
11
|
-
role.to_sym == :superadmin
|
|
14
|
+
role.to_sym == :superadmin && ApplicationController.dummy_user_superadmin
|
|
12
15
|
end
|
|
13
16
|
end
|
|
14
17
|
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Child of Machine that has no top-level HTML surface: it is only shown
|
|
4
|
+
# nested in its machine (like StProject's Dagrapportage inside a Client tab).
|
|
5
|
+
class DailyReport < ApplicationRecord
|
|
6
|
+
belongs_to :machine, optional: true
|
|
7
|
+
|
|
8
|
+
def self.not_accessible_through_html?
|
|
9
|
+
true
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def _presentation
|
|
13
|
+
"#{name}"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def inline_forms_attribute_list
|
|
17
|
+
@inline_forms_attribute_list ||= [
|
|
18
|
+
[ :name, :text_field ]
|
|
19
|
+
]
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -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
|
data/test/dummy/config/routes.rb
CHANGED
|
@@ -20,6 +20,11 @@ Rails.application.routes.draw do
|
|
|
20
20
|
get "list_versions", on: :member
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
+
resources :daily_reports do
|
|
24
|
+
post "revert", on: :member
|
|
25
|
+
get "list_versions", on: :member
|
|
26
|
+
end
|
|
27
|
+
|
|
23
28
|
resources :gizmos do
|
|
24
29
|
post "revert", on: :member
|
|
25
30
|
get "list_versions", on: :member
|
|
@@ -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
|
|
@@ -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,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../integration_test_helper"
|
|
4
|
+
|
|
5
|
+
# Every HTML request the engine decides not to serve gets an explicit answer:
|
|
6
|
+
# a top-level list of a not_accessible_through_html? model is a 404, a
|
|
7
|
+
# new/create/show/destroy/revert without the frame context the gem needs is a
|
|
8
|
+
# 400. Before, the format.html block rendered nothing (or was not registered),
|
|
9
|
+
# so Rails fell through to implicit rendering: ActionController::
|
|
10
|
+
# MissingExactTemplate (500; StProject's GET /dagrapportages) or UnknownFormat
|
|
11
|
+
# (406). create, destroy and revert also refuse before writing anything.
|
|
12
|
+
#
|
|
13
|
+
# DailyReport is the not_accessible_through_html? child of Machine; Machine is
|
|
14
|
+
# a normal (accessible) model.
|
|
15
|
+
class HtmlRefusalTest < InlineFormsIntegrationTestCase
|
|
16
|
+
setup do
|
|
17
|
+
@machine = Machine.create!(name: "Press")
|
|
18
|
+
@report = DailyReport.create!(name: "Monday", machine: @machine)
|
|
19
|
+
@list_frame = "machine_#{@machine.id}_daily_reports"
|
|
20
|
+
@row_frame = "machine_#{@machine.id}_daily_report_#{@report.id}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def nested_params(update = @list_frame)
|
|
24
|
+
{ update: update, parent_class: "Machine", parent_id: @machine.id }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def stream_headers(frame_id)
|
|
28
|
+
frame_headers(frame_id).merge("Accept" => "text/vnd.turbo-stream.html")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The refusal ran after authorization, so CanCan's check_authorization (an
|
|
32
|
+
# after_action in host apps) is satisfied.
|
|
33
|
+
def assert_refused(status)
|
|
34
|
+
assert_response status
|
|
35
|
+
assert controller.instance_variable_defined?(:@_authorized),
|
|
36
|
+
"the refused request must still count as authorized (check_authorization)"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# ---- not_accessible_through_html? model, top level -----------------------
|
|
40
|
+
|
|
41
|
+
test "the top-level list of a not_accessible_through_html? model is a 404" do
|
|
42
|
+
get daily_reports_path
|
|
43
|
+
assert_refused :not_found
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
test "show without a nested row frame is a 400" do
|
|
47
|
+
get daily_report_path(@report)
|
|
48
|
+
assert_refused :bad_request
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
test "new without update and parent_class is a 400" do
|
|
52
|
+
get new_daily_report_path
|
|
53
|
+
assert_refused :bad_request
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
test "create without update and parent_class is a 400 and creates nothing" do
|
|
57
|
+
assert_no_difference -> { DailyReport.count } do
|
|
58
|
+
post daily_reports_path, params: { name: "Tuesday" }
|
|
59
|
+
end
|
|
60
|
+
assert_refused :bad_request
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
test "destroy without a nested row frame is a 400 and keeps the record" do
|
|
64
|
+
delete daily_report_path(@report)
|
|
65
|
+
assert_refused :bad_request
|
|
66
|
+
assert DailyReport.exists?(@report.id)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
test "revert without a nested row frame is a 400 and changes nothing" do
|
|
70
|
+
@report.update!(name: "Changed")
|
|
71
|
+
version = @report.versions.where(event: "update").last
|
|
72
|
+
|
|
73
|
+
post revert_daily_report_path(version.id, update: "daily_report_#{@report.id}"),
|
|
74
|
+
headers: stream_headers("daily_report_#{@report.id}")
|
|
75
|
+
assert_refused :bad_request
|
|
76
|
+
assert_equal "Changed", @report.reload.name
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# ---- the nested forms still work (regression guard) ----------------------
|
|
80
|
+
|
|
81
|
+
test "the nested list, new form, create, row and destroy still render" do
|
|
82
|
+
get daily_reports_path(nested_params), headers: frame_headers(@list_frame)
|
|
83
|
+
assert_response :success
|
|
84
|
+
assert_includes response.body, "Monday"
|
|
85
|
+
|
|
86
|
+
get new_daily_report_path(nested_params), headers: frame_headers(@list_frame)
|
|
87
|
+
assert_response :success
|
|
88
|
+
|
|
89
|
+
assert_difference -> { DailyReport.count }, +1 do
|
|
90
|
+
post daily_reports_path(nested_params), params: { name: "Tuesday" }, headers: frame_headers(@list_frame)
|
|
91
|
+
end
|
|
92
|
+
assert_response :success
|
|
93
|
+
assert_equal @machine.id, DailyReport.find_by!(name: "Tuesday").machine_id
|
|
94
|
+
|
|
95
|
+
get daily_report_path(@report, update: @row_frame), headers: frame_headers(@row_frame)
|
|
96
|
+
assert_response :success
|
|
97
|
+
assert_includes response.body, %(<turbo-frame id="#{@row_frame}">)
|
|
98
|
+
|
|
99
|
+
delete daily_report_path(@report, update: @row_frame), headers: frame_headers(@row_frame)
|
|
100
|
+
assert_response :success
|
|
101
|
+
refute DailyReport.exists?(@report.id)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
test "a nested revert still restores the record" do
|
|
105
|
+
@report.update!(name: "Changed")
|
|
106
|
+
version = @report.versions.where(event: "update").last
|
|
107
|
+
|
|
108
|
+
post revert_daily_report_path(version.id, update: @row_frame), headers: stream_headers(@row_frame)
|
|
109
|
+
assert_response :success
|
|
110
|
+
assert_equal "Monday", @report.reload.name
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# ---- accessible model ------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
test "an accessible model's list and row still render" do
|
|
116
|
+
get machines_path
|
|
117
|
+
assert_response :success
|
|
118
|
+
assert_includes response.body, "Press"
|
|
119
|
+
|
|
120
|
+
get machine_path(@machine)
|
|
121
|
+
assert_response :success
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
test "an accessible model's new and create without update are a 400; nothing is created" do
|
|
125
|
+
get new_machine_path
|
|
126
|
+
assert_refused :bad_request
|
|
127
|
+
|
|
128
|
+
assert_no_difference -> { Machine.count } do
|
|
129
|
+
post machines_path, params: { name: "Lathe" }
|
|
130
|
+
end
|
|
131
|
+
assert_refused :bad_request
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
test "destroy by a user who may not hard-destroy is a 403 and keeps the record" do
|
|
135
|
+
ApplicationController.dummy_user_superadmin = false
|
|
136
|
+
frame = "machine_#{@machine.id}"
|
|
137
|
+
|
|
138
|
+
delete machine_path(@machine, update: frame), headers: frame_headers(frame)
|
|
139
|
+
assert_refused :forbidden
|
|
140
|
+
assert Machine.exists?(@machine.id)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# ---- file slots ------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
test "a refused file-slot request still counts as authorized" do
|
|
146
|
+
document = Document.create!(title: "Plan")
|
|
147
|
+
frame = "document_#{document.id}_filename"
|
|
148
|
+
|
|
149
|
+
post remove_file_document_path(document, attribute: "title", update: frame), headers: stream_headers(frame)
|
|
150
|
+
assert_refused :bad_request
|
|
151
|
+
end
|
|
152
|
+
end
|