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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +92 -0
  3. data/app/controllers/concerns/file_slots_concern.rb +6 -0
  4. data/app/controllers/inline_forms_controller.rb +88 -30
  5. data/app/helpers/inline_forms_file_trash_helper.rb +10 -0
  6. data/app/models/concerns/inline_forms/stored_files.rb +5 -1
  7. data/app/models/inline_forms/trashed_file.rb +16 -2
  8. data/app/views/devise/sessions/_form.html.erb +2 -2
  9. data/app/views/inline_forms/_list.html.erb +24 -7
  10. data/app/views/inline_forms/_versions.html.erb +1 -1
  11. data/app/views/inline_forms/_versions_file_entry.html.erb +1 -1
  12. data/app/views/inline_forms/_versions_list.html.erb +14 -12
  13. data/app/views/layouts/application.html.erb +10 -3
  14. data/app/views/layouts/devise.html.erb +1 -1
  15. data/app/views/layouts/inline_forms.html.erb +9 -2
  16. data/lib/inline_forms/form_elements/file_field_helper.rb +3 -1
  17. data/lib/inline_forms/form_elements/info_list_helper.rb +3 -1
  18. data/lib/inline_forms/gem_files.rb +3 -2
  19. data/lib/inline_forms/version.rb +1 -1
  20. data/lib/locales/inline_forms.en.yml +22 -1
  21. data/lib/locales/inline_forms.nl.yml +25 -1
  22. data/test/dummy/app/controllers/application_controller.rb +5 -2
  23. data/test/dummy/app/controllers/daily_reports_controller.rb +4 -0
  24. data/test/dummy/app/models/daily_report.rb +21 -0
  25. data/test/dummy/app/models/machine.rb +1 -0
  26. data/test/dummy/config/application.rb +11 -0
  27. data/test/dummy/config/routes.rb +5 -0
  28. data/test/gem_package_modes_test.rb +1 -1
  29. data/test/integration/csp_nonce_test.rb +50 -0
  30. data/test/integration/file_slots_test.rb +91 -0
  31. data/test/integration/html_refusal_test.rb +152 -0
  32. data/test/integration/list_params_test.rb +200 -0
  33. data/test/integration/locale_test.rb +113 -0
  34. data/test/integration/presentation_escaping_test.rb +80 -0
  35. data/test/integration_test_helper.rb +11 -0
  36. metadata +15 -2
  37. 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
- <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>
@@ -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
@@ -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 local Claude Code files (untracked, per checkout): .claude/
31
- # (launch.json, settings.local.json) and CLAUDE.md.
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/
@@ -1,5 +1,5 @@
1
1
  # -*- encoding : utf-8 -*-
2
2
 
3
3
  module InlineForms
4
- VERSION = "8.1.55"
4
+ VERSION = "8.1.57"
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
@@ -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
- succes: success.
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
- succes: het ding is aangemaakt.
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,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ class DailyReportsController < InlineFormsController
4
+ end
@@ -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
@@ -5,6 +5,7 @@
5
5
  # open-after-create flow (InlineFormsController#render_created_row_open_streams).
6
6
  class Machine < ApplicationRecord
7
7
  has_many :parts
8
+ has_many :daily_reports
8
9
 
9
10
  validates :name, presence: true
10
11
 
@@ -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
@@ -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
@@ -56,7 +56,7 @@ class GemPackageModesTest < Minitest::Test
56
56
  s.name = "modes_probe"
57
57
  s.version = "0.0.1"
58
58
  s.summary = "probe"
59
- s.authors = ["probe"]
59
+ s.authors = [ "probe" ]
60
60
  s.files = %w[plain.rb tool]
61
61
  end
62
62
 
@@ -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