inline_forms 8.1.47 → 8.1.55

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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +3 -0
  3. data/CHANGELOG.md +178 -0
  4. data/Gemfile +3 -0
  5. data/README.rdoc +47 -0
  6. data/app/assets/stylesheets/inline_forms/inline_forms.scss +34 -0
  7. data/app/controllers/concerns/file_slots_concern.rb +211 -0
  8. data/app/controllers/inline_forms/file_trash_controller.rb +115 -0
  9. data/app/controllers/inline_forms_controller.rb +82 -16
  10. data/app/helpers/inline_forms_file_trash_helper.rb +131 -0
  11. data/app/helpers/inline_forms_helper.rb +5 -1
  12. data/app/models/concerns/inline_forms/stored_files.rb +350 -0
  13. data/app/models/inline_forms/current.rb +8 -0
  14. data/app/models/inline_forms/file_trash_sweep.rb +37 -0
  15. data/app/models/inline_forms/trashed_file.rb +205 -0
  16. data/app/views/devise/passwords/edit.html.erb +1 -1
  17. data/app/views/devise/passwords/new.html.erb +1 -1
  18. data/app/views/devise/shared/_header_and_errors.html.erb +9 -2
  19. data/app/views/inline_forms/_file_slot.html.erb +39 -0
  20. data/app/views/inline_forms/_header.html.erb +3 -0
  21. data/app/views/inline_forms/_model_top_bar.html.erb +3 -4
  22. data/app/views/inline_forms/_show.html.erb +6 -0
  23. data/app/views/inline_forms/_versions.html.erb +4 -4
  24. data/app/views/inline_forms/_versions_file_entry.html.erb +36 -0
  25. data/app/views/inline_forms/_versions_list.html.erb +11 -2
  26. data/app/views/inline_forms/file_field_frame.html.erb +22 -0
  27. data/app/views/inline_forms/file_trash/index.html.erb +112 -0
  28. data/app/views/inline_forms/file_trash_panel.html.erb +90 -0
  29. data/app/views/layouts/inline_forms.html.erb +5 -1
  30. data/lib/generators/inline_forms/file_trash_generator.rb +36 -0
  31. data/lib/generators/inline_forms/templates/create_inline_forms_file_trash.rb.erb +41 -0
  32. data/lib/generators/inline_forms_addto_generator.rb +47 -0
  33. data/lib/generators/inline_forms_generator.rb +19 -2
  34. data/lib/generators/templates/model.erb +4 -0
  35. data/lib/inline_forms/form_element_from_callee.rb +25 -0
  36. data/lib/inline_forms/form_elements/simple_file_field_helper.rb +30 -2
  37. data/lib/inline_forms/gem_files.rb +37 -0
  38. data/lib/inline_forms/version.rb +1 -1
  39. data/lib/inline_forms.rb +55 -0
  40. data/lib/locales/inline_forms.en.yml +87 -0
  41. data/lib/locales/inline_forms.nl.yml +87 -3
  42. data/lib/tasks/inline_forms_files.rake +61 -0
  43. data/test/dummy/app/controllers/application_controller.rb +5 -0
  44. data/test/dummy/app/controllers/documents_controller.rb +4 -0
  45. data/test/dummy/app/controllers/dossiers_controller.rb +4 -0
  46. data/test/dummy/app/models/ability.rb +15 -0
  47. data/test/dummy/app/models/document.rb +33 -0
  48. data/test/dummy/app/models/dossier.rb +38 -0
  49. data/test/dummy/config/application.rb +4 -3
  50. data/test/dummy/config/routes.rb +13 -0
  51. data/test/gem_package_modes_test.rb +86 -0
  52. data/test/inline_forms_addto_generator_test.rb +47 -0
  53. data/test/inline_forms_generator_test.rb +24 -0
  54. data/test/integration/field_authorization_test.rb +131 -0
  55. data/test/integration/file_slots_test.rb +574 -0
  56. data/test/integration/layout_title_test.rb +56 -0
  57. data/test/integration/model_top_bar_test.rb +9 -0
  58. data/test/integration/revert_skipped_columns_test.rb +59 -0
  59. data/test/integration_test_helper.rb +62 -0
  60. metadata +36 -3
  61. data/.claude/settings.local.json +0 -9
  62. data/CLAUDE.md +0 -68
@@ -28,6 +28,89 @@ nl:
28
28
  add_new: Maak een nieuw %{model}
29
29
  undo: terughalen
30
30
  trash: weggooien
31
+ files:
32
+ download: downloaden
33
+ replace: bestand vervangen
34
+ remove: bestand verwijderen
35
+ restore: terugzetten
36
+ restore_file: bestand terugzetten
37
+ purge: definitief verwijderen
38
+ undo: ongedaan maken
39
+ restore_previous: "verwijderd bestand %{filename} terugzetten"
40
+ replace_hint: "Het huidige bestand %{filename} gaat naar de prullenbak."
41
+ confirm_remove:
42
+ one: "%{filename} verwijderen? Het gaat naar de prullenbak en kan 1 dag worden teruggezet."
43
+ other: "%{filename} verwijderen? Het gaat naar de prullenbak en kan %{count} dagen worden teruggezet."
44
+ confirm_remove_no_expiry: "%{filename} verwijderen? Het gaat naar de prullenbak en kan worden teruggezet."
45
+ confirm_restore_swap: "Het huidige bestand %{current} gaat naar de prullenbak en %{filename} wordt teruggezet. Doorgaan?"
46
+ confirm_purge: "%{filename} definitief verwijderen? Dit kan niet ongedaan worden gemaakt."
47
+ notice_removed: "%{filename} verwijderd"
48
+ notice_restored: Bestand teruggezet.
49
+ notice_purged: Bestand definitief verwijderd.
50
+ notice_already_empty: Er was geen bestand om te verwijderen.
51
+ notice_not_restorable: Dit bestand kan niet meer worden teruggezet.
52
+ notice_not_purgeable: Dit bestand is al teruggezet of verwijderd.
53
+ missing: Dit bestand is verwijderd.
54
+ trash_header:
55
+ zero: Verwijderde bestanden
56
+ one: Verwijderde bestanden (1)
57
+ other: Verwijderde bestanden (%{count})
58
+ open_trash: Verwijderde bestanden tonen
59
+ close_trash: Verwijderde bestanden verbergen
60
+ by_on: "door %{user} op %{at}"
61
+ by_system: het systeem
62
+ redacted_name: "[verwijderd bestand]"
63
+ state_restored: "teruggezet door %{user} op %{at}"
64
+ state_purged: "definitief verwijderd door %{user} op %{at}"
65
+ state_expired: verlopen, wordt verwijderd
66
+ expired_pending:
67
+ one: 1 verlopen bestand wacht op de nachtelijke opruiming.
68
+ other: "%{count} verlopen bestanden wachten op de nachtelijke opruiming."
69
+ expires_in:
70
+ one: "terug te zetten tot %{date} (1 dag)"
71
+ other: "terug te zetten tot %{date} (%{count} dagen)"
72
+ reason:
73
+ removed: verwijderd
74
+ replaced: vervangen
75
+ record_destroyed: record verwijderd
76
+ orphaned: verweesde bytes
77
+ uploaded: geüpload
78
+ event:
79
+ uploaded: bestand geüpload
80
+ removed: bestand verwijderd
81
+ replaced: bestand vervangen
82
+ record_destroyed: bestand in prullenbak (record verwijderd)
83
+ orphaned: verweesd bestand in prullenbak
84
+ restored: bestand teruggezet
85
+ purged: bestand definitief verwijderd
86
+ global:
87
+ title: Verwijderde bestanden
88
+ model: Model
89
+ state: Status
90
+ all: alle
91
+ filter: filteren
92
+ record: Record
93
+ record_gone: "%{model} #%{id} (verwijderd)"
94
+ slot: Veld
95
+ file: Bestand
96
+ trashed: Verwijderd
97
+ actions: Acties
98
+ purge_selected: Selectie definitief verwijderen
99
+ confirm_purge_selected: De geselecteerde bestanden definitief verwijderen? Dit kan niet ongedaan worden gemaakt.
100
+ purged_count:
101
+ one: 1 bestand definitief verwijderd.
102
+ other: "%{count} bestanden definitief verwijderd."
103
+ nothing_selected: Geen bestanden geselecteerd.
104
+ empty: Geen verwijderde bestanden.
105
+ sweep_last: "Laatste opruiming: %{at} (%{count} verwijderd)"
106
+ sweep_never: De nachtelijke opruiming heeft nog nooit gedraaid.
107
+ sweep_overdue: De nachtelijke opruiming heeft al meer dan 48 uur niet gedraaid. Controleer de cron-taak.
108
+ sweep_failed: "Laatste opruiming mislukt: %{error}"
109
+ states:
110
+ trashed: in prullenbak
111
+ restored: teruggezet
112
+ purged: verwijderd
113
+ expired: verlopen
31
114
  pending_migration:
32
115
  notice: "migratie in afwachting \u2014 draai rails db:migrate"
33
116
  succes: het ding is aangemaakt.
@@ -38,6 +121,7 @@ nl:
38
121
  client:
39
122
  first_name: voornaam
40
123
  last_name: achternaam
41
- application_name: naam van de applicatie
42
-
43
-
124
+ errors:
125
+ messages:
126
+ inline_forms_file_too_large: "is te groot (maximaal %{count} MB)"
127
+ inline_forms_file_missing: "geen bestand gekozen"
@@ -0,0 +1,61 @@
1
+ # -*- encoding : utf-8 -*-
2
+
3
+ # Declared file slots (InlineForms::StoredFiles) and the file trash.
4
+ namespace :inline_forms do
5
+ namespace :file_trash do
6
+ desc "Purge trashed files whose retention has passed (run daily from cron; exits non-zero on failure)"
7
+ task purge_expired: :environment do
8
+ purged = InlineForms::FileTrashSweep.run!
9
+ puts "inline_forms: file trash sweep purged #{purged} file(s)"
10
+ Rails.logger.info("inline_forms: file trash sweep purged #{purged} file(s)")
11
+ end
12
+ end
13
+
14
+ namespace :files do
15
+ # Models that declare file slots. Eager-loads the app so every model is known.
16
+ def inline_forms_file_slot_models
17
+ Rails.application.eager_load!
18
+ ActiveRecord::Base.descendants.select do |klass|
19
+ !klass.abstract_class? && klass.respond_to?(:inline_forms_file_slots) &&
20
+ klass.inline_forms_file_slots.any? && klass.table_exists?
21
+ end
22
+ end
23
+
24
+ desc "Report broken file slots (read-only): bytes without a filename, a filename without bytes"
25
+ task audit: :environment do
26
+ problems = 0
27
+ inline_forms_file_slot_models.each do |klass|
28
+ klass.inline_forms_file_slots.each do |attribute, slot|
29
+ scope = klass.unscoped
30
+ orphaned = scope.where.not(slot[:data] => nil).where(attribute => [ nil, "" ]).count
31
+ missing = scope.where(slot[:data] => nil).where.not(attribute => [ nil, "" ]).count
32
+ next if orphaned.zero? && missing.zero?
33
+
34
+ problems += orphaned + missing
35
+ puts "#{klass.name}##{attribute}: #{orphaned} with bytes but no filename (orphaned), " \
36
+ "#{missing} with a filename but no bytes (empty downloads)"
37
+ end
38
+ end
39
+ puts(problems.zero? ? "inline_forms: all file slots consistent" :
40
+ "inline_forms: #{problems} inconsistent slot(s). Orphaned bytes: `rake inline_forms:files:trash_orphans CONFIRM=yes` " \
41
+ "moves them to the trash (purged by the retention sweep). Missing bytes cannot be repaired here; restore from a backup.")
42
+ end
43
+
44
+ desc "Move orphaned bytes (bytes in a slot without a filename) into the file trash. Needs CONFIRM=yes"
45
+ task trash_orphans: :environment do
46
+ abort "inline_forms: refusing without CONFIRM=yes (run inline_forms:files:audit first)" unless ENV["CONFIRM"] == "yes"
47
+
48
+ moved = 0
49
+ inline_forms_file_slot_models.each do |klass|
50
+ klass.inline_forms_file_slots.each do |attribute, slot|
51
+ ids = klass.unscoped.where.not(slot[:data] => nil).where(attribute => [ nil, "" ]).pluck(klass.primary_key)
52
+ ids.each do |id|
53
+ record = klass.unscoped.find(id)
54
+ moved += 1 if record.inline_forms_remove_file!(attribute, by: nil, reason: :orphaned)
55
+ end
56
+ end
57
+ end
58
+ puts "inline_forms: moved #{moved} orphaned file(s) to the trash"
59
+ end
60
+ end
61
+ end
@@ -14,6 +14,11 @@ class ApplicationController < ActionController::Base
14
14
 
15
15
  helper_method :current_user
16
16
 
17
+ # Generated apps redirect HTML with a notice; a bare 403 is easier to assert.
18
+ rescue_from CanCan::AccessDenied do
19
+ head :forbidden
20
+ end
21
+
17
22
  def current_user
18
23
  @current_user ||= DummyUser.new(1, "Dummy User")
19
24
  end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ class DocumentsController < InlineFormsController
4
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ class DossiersController < InlineFormsController
4
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Grants everything (the generated superadmin branch). A test narrows it by
4
+ # setting Ability.restrictions to a block of `cannot` rules, evaluated after
5
+ # the grant so they win; InlineFormsIntegrationTestCase resets it.
6
+ class Ability
7
+ include CanCan::Ability
8
+
9
+ cattr_accessor :restrictions
10
+
11
+ def initialize(_user)
12
+ can :manage, :all
13
+ instance_exec(&restrictions) if restrictions
14
+ end
15
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Declared file slots (InlineForms::StoredFiles), shaped like StProject's
4
+ # Client: two slots with the default column naming (`filename` -> data /
5
+ # content_type, `plan_filename` -> plan_data / plan_content_type). An
6
+ # ApplicationRecord subclass on purpose: has_paper_trail is inherited, so the
7
+ # slot columns' skip must be set on Document without leaking to Widget.
8
+ # See test/integration/file_slots_test.rb.
9
+ class Document < ApplicationRecord
10
+ include InlineForms::StoredFiles
11
+
12
+ inline_forms_file :filename
13
+ inline_forms_file :plan_filename
14
+
15
+ # StProject's Attachment/Incident/Zorgformulier validate the filename; a
16
+ # test switches this on to prove undo-of-destroy puts files back BEFORE save.
17
+ cattr_accessor :require_filename, default: false
18
+ validates :filename, presence: true, if: -> { self.class.require_filename }
19
+
20
+ validates :title, presence: true
21
+
22
+ def _presentation
23
+ "#{title}"
24
+ end
25
+
26
+ def inline_forms_attribute_list
27
+ @inline_forms_attribute_list ||= [
28
+ [ :title, :text_field ],
29
+ [ :filename, :simple_file_field ],
30
+ [ :plan_filename, :simple_file_field ]
31
+ ]
32
+ end
33
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Models StProject's Client/Attachment: file bytes stored in a binary column
4
+ # that is excluded from PaperTrail (`skip:`), filename kept versioned. Reverting
5
+ # an unrelated edit must not touch `data`. See
6
+ # test/integration/revert_skipped_columns_test.rb.
7
+ #
8
+ # Not an ApplicationRecord: that already calls has_paper_trail, which PaperTrail
9
+ # allows only once per hierarchy. StProject likewise declares it per model, so
10
+ # this repeats the rest of the ApplicationRecord host contract inline.
11
+ class Dossier < ActiveRecord::Base
12
+ include InlineForms::Searchable
13
+
14
+ has_paper_trail on: [ :create, :update, :destroy ], skip: [ :data ]
15
+
16
+ attr_writer :inline_forms_attribute_list
17
+
18
+ self.per_page = 7
19
+
20
+ scope :inline_forms_list, -> { order(:name, :id) }
21
+
22
+ validates :name, presence: true
23
+
24
+ def self.not_accessible_through_html?
25
+ false
26
+ end
27
+
28
+ def _presentation
29
+ "#{name}"
30
+ end
31
+
32
+ def inline_forms_attribute_list
33
+ @inline_forms_attribute_list ||= [
34
+ [ :name, :text_field ],
35
+ [ :data_filename, :info ]
36
+ ]
37
+ end
38
+ end
@@ -2,9 +2,10 @@
2
2
 
3
3
  # Minimal host app for the engine's integration tests (test/integration/).
4
4
  # Boots the inline_forms engine against an in-memory SQLite database with the
5
- # host-side gems the engine expects (will_paginate, paper_trail, turbo-rails)
6
- # but without Devise, CanCanCan, Sprockets or ActionText: authorization is
7
- # optional in the engine (cancan_enabled? rescues NameError), tests drive the
5
+ # host-side gems the engine expects (will_paginate, paper_trail, turbo-rails,
6
+ # cancancan) but without Devise, Sprockets or ActionText. CanCanCan is on so
7
+ # load_and_authorize_resource and the attribute-level checks run; the dummy
8
+ # Ability grants everything unless a test narrows it. Tests drive the
8
9
  # UI through Turbo-Frame requests (the frame layout never renders the
9
10
  # current_user header), and the asset-precompile initializer skips hosts
10
11
  # without a pipeline.
@@ -25,6 +25,19 @@ Rails.application.routes.draw do
25
25
  get "list_versions", on: :member
26
26
  end
27
27
 
28
+ resources :dossiers do
29
+ post "revert", on: :member
30
+ get "list_versions", on: :member
31
+ end
32
+
33
+ resources :documents do
34
+ post "revert", on: :member
35
+ get "list_versions", on: :member
36
+ InlineForms.file_routes(self)
37
+ end
38
+
39
+ InlineForms.draw_file_trash_routes(self)
40
+
28
41
  get "stats", to: "stats#show"
29
42
 
30
43
  # Schema-change GUI (opt-in in real installs; routed here for the engine's
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
4
+
5
+ require "minitest/autorun"
6
+ require "tmpdir"
7
+ require "zlib"
8
+ require "inline_forms/gem_files"
9
+
10
+ # 8.1.53 shipped every file as 0660 (the checkout's group-only modes), so an
11
+ # installed gem was unreadable for anyone outside the installing user and
12
+ # group. Gem builds now package 0644 / 0755 whatever the on-disk modes.
13
+ class GemPackageModesTest < Minitest::Test
14
+ REPO_ROOT = File.expand_path("..", __dir__)
15
+
16
+ def packaged_modes(gem_path)
17
+ modes = {}
18
+ File.open(gem_path, "rb") do |io|
19
+ Gem::Package::TarReader.new(io).each do |entry|
20
+ next unless entry.full_name == "data.tar.gz"
21
+
22
+ Gem::Package::TarReader.new(Zlib::GzipReader.new(entry)).each do |file|
23
+ modes[file.full_name] = file.header.mode & 0o777
24
+ end
25
+ end
26
+ end
27
+ modes
28
+ end
29
+
30
+ def build(spec, dir, gem_dir)
31
+ gem_path = File.join(gem_dir, "#{spec.full_name}.gem")
32
+ Dir.chdir(dir) do
33
+ package = Gem::Package.new(gem_path)
34
+ package.spec = spec
35
+ Gem::DefaultUserInteraction.use_ui(Gem::SilentUI.new) { package.build(true) }
36
+ end
37
+ gem_path
38
+ end
39
+
40
+ def test_packaged_mode_keeps_only_the_owner_execute_bit
41
+ assert_equal 0o644, InlineFormsGemFiles.packaged_mode(0o100660)
42
+ assert_equal 0o644, InlineFormsGemFiles.packaged_mode(0o100600)
43
+ assert_equal 0o755, InlineFormsGemFiles.packaged_mode(0o100770)
44
+ assert_equal 0o755, InlineFormsGemFiles.packaged_mode(0o100700)
45
+ end
46
+
47
+ def test_group_only_files_are_packaged_world_readable
48
+ Dir.mktmpdir do |src|
49
+ Dir.mktmpdir do |out|
50
+ File.write(File.join(src, "plain.rb"), "# plain\n")
51
+ File.write(File.join(src, "tool"), "#!/bin/sh\n")
52
+ File.chmod(0o660, File.join(src, "plain.rb"))
53
+ File.chmod(0o700, File.join(src, "tool"))
54
+
55
+ spec = Gem::Specification.new do |s|
56
+ s.name = "modes_probe"
57
+ s.version = "0.0.1"
58
+ s.summary = "probe"
59
+ s.authors = ["probe"]
60
+ s.files = %w[plain.rb tool]
61
+ end
62
+
63
+ modes = packaged_modes(build(spec, src, out))
64
+
65
+ assert_equal({ "plain.rb" => 0o644, "tool" => 0o755 }, modes)
66
+ end
67
+ end
68
+ end
69
+
70
+ def test_every_gemspec_in_the_repo_packages_world_readable_files
71
+ %w[inline_forms.gemspec inline_forms_installer.gemspec
72
+ inline_forms_schema_edit/inline_forms_schema_edit.gemspec].each do |gemspec|
73
+ path = File.join(REPO_ROOT, gemspec)
74
+ spec = Gem::Specification.load(path)
75
+ refute_nil spec, gemspec
76
+
77
+ Dir.mktmpdir do |out|
78
+ modes = packaged_modes(build(spec, File.dirname(path), out))
79
+
80
+ refute_empty modes, gemspec
81
+ unreadable = modes.reject { |_, mode| (mode & 0o444) == 0o444 }
82
+ assert_empty unreadable, "#{gemspec}: not world-readable in the .gem"
83
+ end
84
+ end
85
+ end
86
+ end
@@ -386,6 +386,53 @@ class InlineFormsAddtoGeneratorTest < Minitest::Test
386
386
  teardown_sqlite
387
387
  end
388
388
 
389
+ def test_simple_file_field_adds_a_declared_slot_columns_and_routes
390
+ write_model("widget.rb", GENERATOR_SHAPED_MODEL)
391
+ mkdir_p("config")
392
+ File.write(File.join(@destination_root, "config/routes.rb"), <<~RUBY)
393
+ Rails.application.routes.draw do
394
+ resources :widgets do
395
+ post 'revert', :on => :member
396
+ get 'list_versions', :on => :member
397
+ end
398
+ end
399
+ RUBY
400
+
401
+ capture_io { run_generator("Widget", "manual:simple_file_field") }
402
+ capture_io { run_generator("Widget", "plan_filename:simple_file_field") }
403
+
404
+ model = read("app/models/widget.rb")
405
+ assert_equal(1, model.scan("include InlineForms::StoredFiles").size)
406
+ assert_includes(model, "inline_forms_file :manual")
407
+ assert_includes(model, "inline_forms_file :plan_filename")
408
+ assert_operator(model.index("include InlineForms::StoredFiles"), :<, model.index("inline_forms_file :manual"))
409
+ assert_operator(model.index("include InlineForms::StoredFiles"), :<, model.index("inline_forms_file :plan_filename"))
410
+ assert_includes(model, "[ :manual, :simple_file_field ]")
411
+ routes = read("config/routes.rb")
412
+ assert_equal(1, routes.scan("InlineForms.file_routes(self)").size)
413
+ assert_match(/resources :widgets do\n\s+InlineForms\.file_routes\(self\)/, routes)
414
+
415
+ migration_file = Dir.glob(File.join(@destination_root, "db/migrate/*_inline_forms_add_to_widgets_*.rb")).min
416
+ conn = apply_migration_to_memory_sqlite(migration_file, tables: %i[widgets])
417
+ %i[manual manual_data manual_content_type].each do |column|
418
+ assert(conn.column_exists?(:widgets, column), "#{column} should exist after migrate")
419
+ end
420
+ ensure
421
+ teardown_sqlite
422
+ end
423
+
424
+ def test_simple_file_field_without_a_resource_block_reminds_about_routes
425
+ write_model("widget.rb", GENERATOR_SHAPED_MODEL)
426
+ mkdir_p("config")
427
+ File.write(File.join(@destination_root, "config/routes.rb"), "Rails.application.routes.draw do\n MODELS.each { |m| resources m }\nend\n")
428
+
429
+ stdout, = capture_io { run_generator("Widget", "manual:simple_file_field") }
430
+
431
+ assert_includes(read("app/models/widget.rb"), "inline_forms_file :manual")
432
+ refute_includes(read("config/routes.rb"), "file_routes")
433
+ assert_includes(stdout, "InlineForms.file_routes(self)")
434
+ end
435
+
389
436
  private
390
437
 
391
438
  def run_generator(*args)
@@ -171,6 +171,30 @@ class InlineFormsGeneratorTest < Minitest::Test
171
171
  assert_includes(migration, "t.text :description")
172
172
  end
173
173
 
174
+ def test_simple_file_field_declares_a_file_slot_with_its_columns_and_routes
175
+ run_generator("Folder", "title:string", "plan_filename:simple_file_field", "_enabled:yes")
176
+
177
+ model = read("app/models/folder.rb")
178
+ routes = read("config/routes.rb")
179
+ migration = read_single_migration_for("folders")
180
+
181
+ assert_includes(model, "include InlineForms::StoredFiles")
182
+ assert_includes(model, "inline_forms_file :plan_filename")
183
+ assert_operator(model.index("include InlineForms::StoredFiles"), :<, model.index("inline_forms_file :plan_filename"))
184
+ assert_includes(model, "[ :plan_filename, :simple_file_field ]")
185
+ assert_includes(migration, "t.string :plan_filename")
186
+ assert_includes(migration, "t.binary :plan_data")
187
+ assert_includes(migration, "t.string :plan_content_type")
188
+ assert_includes(routes, "InlineForms.file_routes(self)")
189
+ end
190
+
191
+ def test_no_file_routes_without_a_file_field
192
+ run_generator("Thing", "name:string", "_enabled:yes")
193
+
194
+ refute_includes(read("config/routes.rb"), "file_routes")
195
+ refute_includes(read("app/models/thing.rb"), "StoredFiles")
196
+ end
197
+
174
198
  private
175
199
 
176
200
  def build_destination_skeleton!
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../integration_test_helper"
4
+
5
+ # Server-side guard on the single-field actions (edit / update / show with an
6
+ # attribute). The UI only hides the edit link for an attribute the user may
7
+ # not update; before 8.1.48 a hand-made request still went through, because
8
+ # load_and_authorize_resource authorizes the record only (CanCanCan passes
9
+ # attribute-scoped rules when no attribute is given) and `form_element` was
10
+ # dispatched as `send("#{form_element}_update")` unchecked.
11
+ class FieldAuthorizationTest < InlineFormsIntegrationTestCase
12
+ setup do
13
+ @widget = Widget.create!(name: "Alpha", accent: "#112233")
14
+ @frame = "widget_#{@widget.id}_accent"
15
+ end
16
+
17
+ def patch_field(attribute, form_element, params = {})
18
+ patch widget_path(@widget, attribute: attribute, form_element: form_element,
19
+ update: "widget_#{@widget.id}_#{attribute}"),
20
+ params: params, headers: frame_headers("widget_#{@widget.id}_#{attribute}")
21
+ end
22
+
23
+ test "the dummy runs with CanCanCan enabled" do
24
+ assert InlineFormsController.cancan_enabled?
25
+ end
26
+
27
+ test "crafted PATCH on an attribute-scoped `cannot :update` is refused" do
28
+ Ability.restrictions = -> { cannot :update, Widget, [ :accent ] }
29
+
30
+ patch_field "accent", "color_field", accent: "#ff0000"
31
+
32
+ assert_response :forbidden
33
+ assert_equal "#112233", @widget.reload.accent
34
+ end
35
+
36
+ test "other attributes of the same record stay updatable" do
37
+ Ability.restrictions = -> { cannot :update, Widget, [ :accent ] }
38
+
39
+ patch_field "name", "text_field", name: "Renamed"
40
+
41
+ assert_response :success
42
+ assert_equal "Renamed", @widget.reload.name
43
+ end
44
+
45
+ test "an attribute-scoped `can :update` only covers the listed attributes" do
46
+ Ability.restrictions = lambda do
47
+ cannot :update, Widget
48
+ can :update, Widget, [ :name ]
49
+ end
50
+
51
+ patch_field "accent", "color_field", accent: "#ff0000"
52
+ assert_response :forbidden
53
+ assert_equal "#112233", @widget.reload.accent
54
+
55
+ patch_field "name", "text_field", name: "Renamed"
56
+ assert_response :success
57
+ assert_equal "Renamed", @widget.reload.name
58
+ end
59
+
60
+ test "edit of a `cannot :update` attribute is refused" do
61
+ Ability.restrictions = -> { cannot :update, Widget, [ :accent ] }
62
+
63
+ get edit_widget_path(@widget, attribute: "accent", form_element: "color_field", update: @frame),
64
+ headers: frame_headers(@frame)
65
+
66
+ assert_response :forbidden
67
+ end
68
+
69
+ test "single-field show of a `cannot :read` attribute is refused" do
70
+ Ability.restrictions = -> { cannot :read, Widget, [ :accent ] }
71
+
72
+ get widget_path(@widget, attribute: "accent", form_element: "color_field", update: @frame),
73
+ headers: frame_headers(@frame)
74
+
75
+ assert_response :forbidden
76
+ end
77
+
78
+ test "a form_element that does not match the attribute's row is rejected" do
79
+ patch_field "accent", "text_field", accent: "not a color"
80
+
81
+ assert_response :bad_request
82
+ assert_equal "#112233", @widget.reload.accent
83
+ end
84
+
85
+ test "an unknown form_element is rejected before it is dispatched" do
86
+ patch_field "name", "nonexistent", name: "Renamed"
87
+
88
+ assert_response :bad_request
89
+ assert_equal "Alpha", @widget.reload.name
90
+ end
91
+
92
+ test "an attribute outside the attribute list is rejected" do
93
+ patch_field "kind_id", "text_field", kind_id: "42"
94
+
95
+ assert_response :bad_request
96
+ assert_nil @widget.reload.kind_id
97
+ end
98
+
99
+ test "missing attribute or form_element is rejected" do
100
+ patch widget_path(@widget, update: @frame), params: { name: "x" }, headers: frame_headers(@frame)
101
+ assert_response :bad_request
102
+
103
+ get edit_widget_path(@widget, attribute: "name", update: @frame), headers: frame_headers(@frame)
104
+ assert_response :bad_request
105
+ end
106
+
107
+ test "show cannot be used to send an arbitrary method to the record" do
108
+ get widget_path(@widget, attribute: "destroy", form_element: "has_one", update: @frame),
109
+ headers: frame_headers(@frame)
110
+
111
+ assert_response :bad_request
112
+ assert Widget.exists?(@widget.id)
113
+ end
114
+
115
+ test "a delegating element's link name (plain_text_area -> plain_text) is accepted" do
116
+ patch_field "notes", "plain_text", notes: "Long enough notes"
117
+
118
+ assert_response :success
119
+ assert_equal "Long enough notes", @widget.reload.notes
120
+ end
121
+
122
+ test "attribute_list_row_for honours the declared element and its delegate only" do
123
+ list = Widget.new.inline_forms_attribute_list
124
+
125
+ assert_equal [ :notes, :plain_text_area ], InlineForms.attribute_list_row_for(list, "notes", "plain_text_area")
126
+ assert_equal [ :notes, :plain_text_area ], InlineForms.attribute_list_row_for(list, "notes", "plain_text")
127
+ assert_nil InlineForms.attribute_list_row_for(list, "notes", "rich_text")
128
+ assert_nil InlineForms.attribute_list_row_for(list, "name", nil)
129
+ assert_nil InlineForms.attribute_list_row_for(list, nil, "text_field")
130
+ end
131
+ end