forest_liana 9.20.13 → 9.21.0

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 (30) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/forest_liana/smart_actions_controller.rb +1 -0
  3. data/app/services/forest_liana/ability/exceptions/approval_selection_too_large.rb +17 -0
  4. data/app/services/forest_liana/ability/exceptions/require_approval.rb +3 -2
  5. data/app/services/forest_liana/ability/permission/smart_action_checker.rb +39 -2
  6. data/app/services/forest_liana/ability/permission.rb +2 -2
  7. data/app/services/forest_liana/resources_getter.rb +13 -6
  8. data/lib/forest_liana/engine.rb +4 -2
  9. data/lib/forest_liana/version.rb +1 -1
  10. data/spec/dummy/app/models/user.rb +6 -1
  11. data/spec/dummy/config/database.yml +14 -9
  12. data/spec/dummy/config/environments/test.rb +3 -2
  13. data/spec/dummy/db/migrate/20220719094127_create_cars.rb +6 -0
  14. data/spec/dummy/db/migrate/20220719094450_create_drivers.rb +3 -0
  15. data/spec/lib/forest_liana/bootstrapper_spec.rb +11 -2
  16. data/spec/rails_helper.rb +5 -1
  17. data/spec/requests/cors_spec.rb +6 -0
  18. data/spec/services/forest_liana/ability/permission/smart_action_checker_spec.rb +132 -0
  19. data/spec/services/forest_liana/has_many_getter_pagination_spec.rb +7 -0
  20. data/spec/services/forest_liana/has_many_getter_spec.rb +7 -0
  21. data/spec/services/forest_liana/resources_getter_spec.rb +26 -0
  22. data/spec/services/forest_liana/schema_adapter_default_values_spec.rb +8 -2
  23. data/test/dummy/app/models/has_one_field.rb +6 -1
  24. data/test/dummy/app/models/serialize_field.rb +6 -1
  25. data/test/dummy/config/environments/test.rb +3 -2
  26. data/test/test_helper.rb +4 -1
  27. metadata +3 -8
  28. data/spec/dummy/app/config/routes.rb +0 -3
  29. data/spec/dummy/config/initializers/assets.rb +0 -11
  30. data/test/dummy/config/initializers/assets.rb +0 -11
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3e53d6922c4422fed246ccc6d625ac21668788f962873e6b04e83689684e9a39
4
- data.tar.gz: 63d031e91c573ec7413d82bfa0c718b3b61736d6aa6583efd5a7dc848e7baf1b
3
+ metadata.gz: 9d52c5a01f048d047defb33c61e8a1eb1062e9b71f9e998717b984715b622812
4
+ data.tar.gz: 9b911cdda6df160758eaaa7cedcd9ac13b47eebf3deec4224c744713de5f2990
5
5
  SHA512:
6
- metadata.gz: 3a56ce38a01bbebe42696a893bf6e50e762135c8b0579ac488e578468469a3a381963bee3ed1a9c5efb5c73b168aaf80bcfe410ec2f90c25ac80235c29799778
7
- data.tar.gz: f93ccc8fcd6cfdbb189e623a3cd3095d79bbd8fd94a70cda8d0cf263476ea6fe3226617b8ebffc3b14a804153f975dddd6903a9791b7c75e85da5e15c72a3b91
6
+ metadata.gz: 6ea5bc520843d98179d34607094ea1f6b991a0f817d2f9a82b716babeedeeba29ce513a713c79039f75bc4d5bd844de1a64c5265af106666464ce37bc6522eff
7
+ data.tar.gz: 196c876c1b02b19c85522aa48b6f6856fd27eff601c5edb2897b4e036ebf8318d3f26a8bcc13998d4fe3efd0d2a4e8677330390e803b69809db61b3da04209cf
@@ -2,6 +2,7 @@ module ForestLiana
2
2
  class SmartActionsController < ForestLiana::ApplicationController
3
3
  rescue_from ForestLiana::Ability::Exceptions::TriggerForbidden, with: :render_error
4
4
  rescue_from ForestLiana::Ability::Exceptions::RequireApproval, with: :render_error
5
+ rescue_from ForestLiana::Ability::Exceptions::ApprovalSelectionTooLarge, with: :render_error
5
6
  rescue_from ForestLiana::Ability::Exceptions::ActionConditionError, with: :render_error
6
7
  include ForestLiana::Ability
7
8
  if Rails::VERSION::MAJOR < 4
@@ -0,0 +1,17 @@
1
+ module ForestLiana
2
+ module Ability
3
+ module Exceptions
4
+ class ApprovalSelectionTooLarge < ForestLiana::Errors::ExpectedError
5
+ def initialize(max, backtrace = nil)
6
+ super(
7
+ 422,
8
+ :unprocessable_entity,
9
+ "This action requires approval and cannot be triggered on more than #{max} records at once. Please refine your selection.",
10
+ 'ApprovalSelectionTooLargeError',
11
+ backtrace,
12
+ )
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -3,8 +3,9 @@ module ForestLiana
3
3
  module Exceptions
4
4
  class RequireApproval < ForestLiana::Errors::ExpectedError
5
5
  attr_reader :data
6
- def initialize(data, backtrace = nil)
7
- @data = data
6
+ def initialize(role_ids_allowed_to_approve, backtrace = nil, record_ids = nil)
7
+ @data = { roleIdsAllowedToApprove: role_ids_allowed_to_approve }
8
+ @data[:recordIds] = record_ids if record_ids
8
9
  super(
9
10
  403,
10
11
  :forbidden,
@@ -2,12 +2,18 @@ module ForestLiana
2
2
  module Ability
3
3
  module Permission
4
4
  class SmartActionChecker
5
+ # The Forest server's hard cap on approval record ids - keep in sync.
6
+ MAX_RECORDS_FOR_APPROVAL = 500
5
7
 
6
- def initialize(parameters, collection, smart_action, user)
8
+ # `user` is the permissions-API user (id/roleId); `forest_user` is the JWT user, the one
9
+ # ResourcesGetter needs (rendering_id for scopes).
10
+ def initialize(parameters, collection, smart_action, user, action_type = nil, forest_user = nil)
7
11
  @parameters = parameters
8
12
  @collection = collection
9
13
  @smart_action = smart_action
10
14
  @user = user
15
+ @action_type = action_type
16
+ @forest_user = forest_user
11
17
  end
12
18
 
13
19
  def can_execute?
@@ -36,7 +42,12 @@ module ForestLiana
36
42
  return true if condition_by_role_id(@smart_action['triggerConditions']).blank? || match_conditions('triggerConditions')
37
43
  elsif @smart_action['approvalRequired'].include?(@user['roleId'])
38
44
  if condition_by_role_id(@smart_action['approvalRequiredConditions']).blank? || match_conditions('approvalRequiredConditions')
39
- raise ForestLiana::Ability::Exceptions::RequireApproval.new(@smart_action['userApprovalEnabled'])
45
+ # Global actions target no specific records — never resolve.
46
+ if @parameters[:data][:attributes][:all_records] && @action_type != 'global'
47
+ record_ids = select_all_record_ids
48
+ end
49
+
50
+ raise ForestLiana::Ability::Exceptions::RequireApproval.new(@smart_action['userApprovalEnabled'], nil, record_ids)
40
51
  else
41
52
  return true if condition_by_role_id(@smart_action['triggerConditions']).blank? || match_conditions('triggerConditions')
42
53
  end
@@ -45,6 +56,32 @@ module ForestLiana
45
56
  raise ForestLiana::Ability::Exceptions::TriggerForbidden.new
46
57
  end
47
58
 
59
+ def select_all_record_ids
60
+ excluded_ids = @parameters[:data][:attributes][:all_records_ids_excluded] || []
61
+
62
+ # The exclusion list is client-controlled: it must not re-inflate the bounded fetch.
63
+ if excluded_ids.length > MAX_RECORDS_FOR_APPROVAL
64
+ raise ForestLiana::Ability::Exceptions::ApprovalSelectionTooLarge.new(MAX_RECORDS_FOR_APPROVAL)
65
+ end
66
+
67
+ # cap + excluded + 1 raw ids suffice to detect an over-cap selection after exclusions.
68
+ ids = ForestLiana::ResourcesGetter.get_ids_from_request(
69
+ @parameters, @forest_user, limit: MAX_RECORDS_FOR_APPROVAL + excluded_ids.length + 1
70
+ )
71
+
72
+ if ids.length > MAX_RECORDS_FOR_APPROVAL
73
+ raise ForestLiana::Ability::Exceptions::ApprovalSelectionTooLarge.new(MAX_RECORDS_FOR_APPROVAL)
74
+ end
75
+
76
+ # Composite pks come back as arrays: encode them like SerializerFactory#id does.
77
+ ids.map { |id| id.is_a?(Array) ? id.to_json : id.to_s }
78
+ rescue ForestLiana::Errors::ExpectedError
79
+ raise
80
+ rescue StandardError => exception
81
+ FOREST_LOGGER.error "Select all record ids resolution error: #{exception.message}"
82
+ raise ForestLiana::Errors::HTTP422Error.new('Unable to resolve the "select all" selection')
83
+ end
84
+
48
85
  def match_conditions(condition_name)
49
86
  begin
50
87
  attributes = @parameters[:data][:attributes]
@@ -39,9 +39,9 @@ module ForestLiana
39
39
  collections_data = get_collections_permissions_data
40
40
  collection_name = ForestLiana.name_for(collection)
41
41
  begin
42
- action = find_action_from_endpoint(collection_name, endpoint, http_method).name
42
+ schema_action = find_action_from_endpoint(collection_name, endpoint, http_method)
43
43
 
44
- smart_action_approval = SmartActionChecker.new(parameters, collection, collections_data[collection_name][:actions][action], user_data)
44
+ smart_action_approval = SmartActionChecker.new(parameters, collection, collections_data[collection_name][:actions][schema_action.name], user_data, schema_action.type, user)
45
45
  smart_action_approval.can_execute?
46
46
  rescue ForestLiana::Errors::ExpectedError => exception
47
47
  raise exception
@@ -19,13 +19,13 @@ module ForestLiana
19
19
  @base_records_for_batch = @records
20
20
  end
21
21
 
22
- def self.get_ids_from_request(params, user)
22
+ def self.get_ids_from_request(params, user, limit: nil)
23
23
  attributes = params.dig('data', 'attributes')
24
24
  return attributes[:ids] if attributes&.fetch(:all_records, false) == false && attributes[:ids]
25
25
 
26
26
  attributes = merge_subset_query(attributes)
27
27
  resources_getter = initialize_resources_getter(attributes, user)
28
- ids = fetch_ids(resources_getter)
28
+ ids = fetch_ids(resources_getter, limit: limit)
29
29
 
30
30
  filter_excluded_ids(ids, attributes[:all_records_ids_excluded])
31
31
  end
@@ -256,15 +256,22 @@ module ForestLiana
256
256
  ]
257
257
  end
258
258
 
259
- def self.fetch_ids(resources_getter)
259
+ def self.fetch_ids(resources_getter, limit: nil)
260
260
  ids = []
261
- resources_getter.query_for_batch.find_in_batches { |records| ids += records.map(&:id) }
261
+ resources_getter.query_for_batch.find_in_batches do |records|
262
+ ids += records.map(&:id)
263
+ break if limit && ids.length >= limit
264
+ end
262
265
 
263
- ids
266
+ limit ? ids.first(limit) : ids
264
267
  end
265
268
 
266
269
  def self.filter_excluded_ids(ids, ids_excluded)
267
- ids_excluded ? ids.reject { |id| ids_excluded.map(&:to_s).include?(id.to_s) } : ids
270
+ return ids if ids_excluded.blank?
271
+
272
+ excluded = ids_excluded.map(&:to_s)
273
+ # Composite pks are arrays; the frontend excludes them in their serialized JSON form.
274
+ ids.reject { |id| excluded.include?(id.is_a?(Array) ? id.to_json : id.to_s) }
268
275
  end
269
276
 
270
277
  def search_query
@@ -14,9 +14,11 @@ module Rack
14
14
  class Resource
15
15
  def to_preflight_headers(env)
16
16
  h = to_headers(env)
17
- h['Access-Control-Allow-Private-Network'] = 'true' if env['HTTP_ACCESS_CONTROL_REQUEST_PRIVATE_NETWORK'] == 'true'
17
+ # HTTP header names are case-insensitive, but Rack 3 stopped normalizing casing on the way
18
+ # out: an uppercase key here reaches the server as-is, and Rack::Lint rejects it.
19
+ h['access-control-allow-private-network'] = 'true' if env['HTTP_ACCESS_CONTROL_REQUEST_PRIVATE_NETWORK'] == 'true'
18
20
  if env[HTTP_ACCESS_CONTROL_REQUEST_HEADERS]
19
- h.merge!('Access-Control-Allow-Headers' => env[HTTP_ACCESS_CONTROL_REQUEST_HEADERS])
21
+ h.merge!('access-control-allow-headers' => env[HTTP_ACCESS_CONTROL_REQUEST_HEADERS])
20
22
  end
21
23
  h
22
24
  end
@@ -1,3 +1,3 @@
1
1
  module ForestLiana
2
- VERSION = "9.20.13"
2
+ VERSION = "9.21.0"
3
3
  end
@@ -3,5 +3,10 @@ class User < ActiveRecord::Base
3
3
  has_many :trees_cut, class_name: 'Tree', inverse_of: :cutter
4
4
  has_many :addresses, as: :addressable
5
5
 
6
- enum title: [ :king, :villager, :outlaw ]
6
+ # The keyword form is the only one Rails 6.1 knows; Rails 8 only keeps the positional one.
7
+ if Rails.gem_version >= Gem::Version.new('7.0')
8
+ enum :title, [ :king, :villager, :outlaw ]
9
+ else
10
+ enum title: [ :king, :villager, :outlaw ]
11
+ end
7
12
  end
@@ -16,16 +16,21 @@ development:
16
16
  # Warning: The database defined as "test" will be erased and
17
17
  # re-generated from your development database when you run "rake".
18
18
  # Do not set this db to the same as development or production.
19
+ #
20
+ # `garage`/`user` back Car/Driver (GarageRecord/UserRecord, connects_to database: { writing: ...
21
+ # }): the 3-tier form is required from Rails 8.0 on — `configs_for(env_name: 'test')` stops
22
+ # recognizing a top-level key as scoped to the environment, so `db:migrate` sees only `primary`
23
+ # and the two cross-database tables are never created.
19
24
  test:
20
- <<: *default
21
- database: db/test.sqlite3
22
-
23
- garage:
24
- <<: *default
25
- database: db/test2.sqlite3
26
- user:
27
- <<: *default
28
- database: db/test3.sqlite3
25
+ primary:
26
+ <<: *default
27
+ database: db/test.sqlite3
28
+ garage:
29
+ <<: *default
30
+ database: db/test2.sqlite3
31
+ user:
32
+ <<: *default
33
+ database: db/test3.sqlite3
29
34
 
30
35
  production:
31
36
  <<: *default
@@ -20,8 +20,9 @@ Rails.application.configure do
20
20
  config.consider_all_requests_local = true
21
21
  config.action_controller.perform_caching = false
22
22
 
23
- # Raise exceptions instead of rendering exception templates.
24
- config.action_dispatch.show_exceptions = false
23
+ # Raise exceptions instead of rendering exception templates. Rails 8 dropped the boolean form,
24
+ # and a leftover `false` there falls through to "show all" — rendered 500s instead of raises.
25
+ config.action_dispatch.show_exceptions = Rails.gem_version >= Gem::Version.new('7.1') ? :none : false
25
26
 
26
27
  # Disable request forgery protection in test environment.
27
28
  config.action_controller.allow_forgery_protection = false
@@ -1,5 +1,11 @@
1
1
  class CreateCars < ActiveRecord::Migration[6.0]
2
2
  def change
3
+ # `garage` is registered per-environment (3-tier database.yml, required from Rails 8 for
4
+ # `connects_to` to resolve it at all), so db:migrate now discovers it as its own database and
5
+ # replays this same file's migrations_paths against it too — this file already ran once by the
6
+ # time that happens.
7
+ return if Car.connection.table_exists?(:cars)
8
+
3
9
  Car.connection.create_table :cars do |t|
4
10
  t.string :model
5
11
  t.references :driver, index: true
@@ -1,5 +1,8 @@
1
1
  class CreateDrivers < ActiveRecord::Migration[6.0]
2
2
  def change
3
+ # See CreateCars: db:migrate replays this file against `user`'s own migration run too.
4
+ return if Driver.connection.table_exists?(:drivers)
5
+
3
6
  Driver.connection.create_table :drivers do |t|
4
7
  t.string :firstname
5
8
  end
@@ -19,7 +19,16 @@ module ForestLiana
19
19
  rails_models.any? { |rails_model| model <= rails_model }
20
20
  end
21
21
  end
22
- let(:rails_models) { [ActiveRecord::InternalMetadata, ActiveRecord::SchemaMigration] }
22
+ # Rails 7.1 stopped making these ActiveRecord::Base descendants (they became per-connection,
23
+ # dynamically generated), so ForestLiana.models — walked via ActiveRecord::Base.descendants —
24
+ # never sees them there in the first place.
25
+ let(:rails_models) do
26
+ if Rails.gem_version >= Gem::Version.new('7.1')
27
+ []
28
+ else
29
+ [ActiveRecord::InternalMetadata, ActiveRecord::SchemaMigration]
30
+ end
31
+ end
23
32
 
24
33
  let(:expected_application_models) do
25
34
  [
@@ -42,7 +51,7 @@ module ForestLiana
42
51
  ForestLiana::Bootstrapper.new
43
52
 
44
53
  expect(ForestLiana.models).to match_array(ForestLiana.models.uniq)
45
- expect(ForestLiana.models).to include(*rails_models)
54
+ expect(ForestLiana.models).to include(*rails_models) if rails_models.any?
46
55
  expect(application_models).to match_array(expected_application_models)
47
56
  end
48
57
 
data/spec/rails_helper.rb CHANGED
@@ -32,7 +32,11 @@ rescue ActiveRecord::PendingMigrationError => e
32
32
  end
33
33
  RSpec.configure do |config|
34
34
  # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures
35
- config.fixture_path = "#{::Rails.root}/spec/fixtures"
35
+ if config.respond_to?(:fixture_paths=)
36
+ config.fixture_paths = ["#{::Rails.root}/spec/fixtures"]
37
+ else
38
+ config.fixture_path = "#{::Rails.root}/spec/fixtures"
39
+ end
36
40
 
37
41
  # If you're not using ActiveRecord, or you'd prefer not to run each of your
38
42
  # examples within a transaction, remove the following line or assign false
@@ -50,6 +50,12 @@ describe Rack::Cors do
50
50
  assert !last_response.headers['Access-Control-Allow-Private-Network'].nil?
51
51
  assert last_response.headers['Access-Control-Allow-Private-Network'] == 'true'
52
52
  end
53
+
54
+ it 'should allow request headers' do
55
+ preflight_request('http://localhost:3000', '/', headers: 'authorization,content-type')
56
+ assert !last_response.headers['Access-Control-Allow-Headers'].nil?
57
+ assert last_response.headers['Access-Control-Allow-Headers'] == 'authorization,content-type'
58
+ end
53
59
  end
54
60
 
55
61
  protected
@@ -173,6 +173,138 @@ module ForestLiana
173
173
  expect{smart_action_checker.can_execute?}.to raise_error ForestLiana::Ability::Exceptions::RequireApproval
174
174
  end
175
175
 
176
+ it 'should expose the approver role ids and skip record ids on an explicit selection' do
177
+ parameters = ActionController::Parameters.new(params).permit!
178
+ action['approvalRequired'] = [1]
179
+ action['userApprovalEnabled'] = [7]
180
+ allow(ForestLiana::ResourcesGetter).to receive(:get_ids_from_request)
181
+ smart_action_checker = ForestLiana::Ability::Permission::SmartActionChecker.new(parameters, Island, action, user)
182
+
183
+ expect{smart_action_checker.can_execute?}.to raise_error(ForestLiana::Ability::Exceptions::RequireApproval) do |error|
184
+ expect(error.data[:roleIdsAllowedToApprove]).to eq([7])
185
+ expect(error.data).not_to have_key(:recordIds)
186
+ end
187
+ expect(ForestLiana::ResourcesGetter).not_to have_received(:get_ids_from_request)
188
+ end
189
+
190
+ let(:forest_user) { { 'id' => 1, 'rendering_id' => 13 } }
191
+
192
+ it 'should include the resolved record ids in the error on a "select all" trigger' do
193
+ select_all_params = params.deep_dup
194
+ select_all_params['data']['attributes'][:all_records] = true
195
+ select_all_params['data']['attributes'][:ids] = []
196
+ parameters = ActionController::Parameters.new(select_all_params).permit!
197
+ action['approvalRequired'] = [1]
198
+ action['userApprovalEnabled'] = [7]
199
+ allow(ForestLiana::ResourcesGetter).to receive(:get_ids_from_request).and_return([1, 2, 3])
200
+ smart_action_checker = ForestLiana::Ability::Permission::SmartActionChecker.new(parameters, Island, action, user, nil, forest_user)
201
+
202
+ expect{smart_action_checker.can_execute?}.to raise_error(ForestLiana::Ability::Exceptions::RequireApproval) do |error|
203
+ expect(error.data[:roleIdsAllowedToApprove]).to eq([7])
204
+ expect(error.data[:recordIds]).to eq(%w[1 2 3])
205
+ end
206
+ # cap + excluded ids (none here) + 1: bounds the fetch instead of materializing the table.
207
+ # The JWT user (not the permissions one) must be used: scopes need its rendering_id.
208
+ expect(ForestLiana::ResourcesGetter).to have_received(:get_ids_from_request)
209
+ .with(anything, forest_user, limit: 501)
210
+ end
211
+
212
+ it 'should widen the fetch limit by the exclusion list size' do
213
+ select_all_params = params.deep_dup
214
+ select_all_params['data']['attributes'][:all_records] = true
215
+ select_all_params['data']['attributes'][:ids] = []
216
+ select_all_params['data']['attributes'][:all_records_ids_excluded] = (1..100).map(&:to_s)
217
+ parameters = ActionController::Parameters.new(select_all_params).permit!
218
+ action['approvalRequired'] = [1]
219
+ action['userApprovalEnabled'] = [7]
220
+ allow(ForestLiana::ResourcesGetter).to receive(:get_ids_from_request).and_return((1..500).map(&:to_s))
221
+ smart_action_checker = ForestLiana::Ability::Permission::SmartActionChecker.new(parameters, Island, action, user, nil, forest_user)
222
+
223
+ expect{smart_action_checker.can_execute?}.to raise_error(ForestLiana::Ability::Exceptions::RequireApproval)
224
+ # Without the excluded term, a 600-record / 100-excluded selection silently
225
+ # resolves to 401 ids instead of the full 500.
226
+ expect(ForestLiana::ResourcesGetter).to have_received(:get_ids_from_request)
227
+ .with(anything, forest_user, limit: 601)
228
+ end
229
+
230
+ it 'should encode composite primary keys the way the serializer builds record ids' do
231
+ select_all_params = params.deep_dup
232
+ select_all_params['data']['attributes'][:all_records] = true
233
+ select_all_params['data']['attributes'][:ids] = []
234
+ parameters = ActionController::Parameters.new(select_all_params).permit!
235
+ action['approvalRequired'] = [1]
236
+ action['userApprovalEnabled'] = [7]
237
+ allow(ForestLiana::ResourcesGetter).to receive(:get_ids_from_request).and_return([[1, 'abc'], [2, 'def']])
238
+ smart_action_checker = ForestLiana::Ability::Permission::SmartActionChecker.new(parameters, Island, action, user, nil, forest_user)
239
+
240
+ expect{smart_action_checker.can_execute?}.to raise_error(ForestLiana::Ability::Exceptions::RequireApproval) do |error|
241
+ expect(error.data[:recordIds]).to eq(['[1,"abc"]', '[2,"def"]'])
242
+ end
243
+ end
244
+
245
+ it 'should not resolve record ids on a "select all" trigger of a global action' do
246
+ select_all_params = params.deep_dup
247
+ select_all_params['data']['attributes'][:all_records] = true
248
+ select_all_params['data']['attributes'][:ids] = []
249
+ parameters = ActionController::Parameters.new(select_all_params).permit!
250
+ action['approvalRequired'] = [1]
251
+ action['userApprovalEnabled'] = [7]
252
+ allow(ForestLiana::ResourcesGetter).to receive(:get_ids_from_request)
253
+ smart_action_checker = ForestLiana::Ability::Permission::SmartActionChecker.new(parameters, Island, action, user, 'global')
254
+
255
+ expect{smart_action_checker.can_execute?}.to raise_error(ForestLiana::Ability::Exceptions::RequireApproval) do |error|
256
+ expect(error.data).not_to have_key(:recordIds)
257
+ end
258
+ expect(ForestLiana::ResourcesGetter).not_to have_received(:get_ids_from_request)
259
+ end
260
+
261
+ it 'should refuse a "select all" trigger resolving over 500 record ids' do
262
+ select_all_params = params.deep_dup
263
+ select_all_params['data']['attributes'][:all_records] = true
264
+ select_all_params['data']['attributes'][:ids] = []
265
+ parameters = ActionController::Parameters.new(select_all_params).permit!
266
+ action['approvalRequired'] = [1]
267
+ allow(ForestLiana::ResourcesGetter).to receive(:get_ids_from_request).and_return((1..501).map(&:to_s))
268
+ smart_action_checker = ForestLiana::Ability::Permission::SmartActionChecker.new(parameters, Island, action, user, nil, forest_user)
269
+
270
+ expect{smart_action_checker.can_execute?}.to raise_error(
271
+ ForestLiana::Ability::Exceptions::ApprovalSelectionTooLarge,
272
+ /more than 500 records/
273
+ )
274
+ end
275
+
276
+ it 'should refuse a "select all" trigger with an oversized exclusion list without querying' do
277
+ select_all_params = params.deep_dup
278
+ select_all_params['data']['attributes'][:all_records] = true
279
+ select_all_params['data']['attributes'][:ids] = []
280
+ select_all_params['data']['attributes'][:all_records_ids_excluded] = (1..501).map(&:to_s)
281
+ parameters = ActionController::Parameters.new(select_all_params).permit!
282
+ action['approvalRequired'] = [1]
283
+ allow(ForestLiana::ResourcesGetter).to receive(:get_ids_from_request)
284
+ smart_action_checker = ForestLiana::Ability::Permission::SmartActionChecker.new(parameters, Island, action, user, nil, forest_user)
285
+
286
+ expect{smart_action_checker.can_execute?}.to raise_error(
287
+ ForestLiana::Ability::Exceptions::ApprovalSelectionTooLarge
288
+ )
289
+ expect(ForestLiana::ResourcesGetter).not_to have_received(:get_ids_from_request)
290
+ end
291
+
292
+ it 'should raise a 422 when the id resolution fails unexpectedly' do
293
+ select_all_params = params.deep_dup
294
+ select_all_params['data']['attributes'][:all_records] = true
295
+ select_all_params['data']['attributes'][:ids] = []
296
+ parameters = ActionController::Parameters.new(select_all_params).permit!
297
+ action['approvalRequired'] = [1]
298
+ allow(ForestLiana::ResourcesGetter).to receive(:get_ids_from_request)
299
+ .and_raise(StandardError.new('Unable to fetch scopes'))
300
+ smart_action_checker = ForestLiana::Ability::Permission::SmartActionChecker.new(parameters, Island, action, user, nil, forest_user)
301
+
302
+ expect{smart_action_checker.can_execute?}.to raise_error(
303
+ ForestLiana::Errors::HTTP422Error,
304
+ /Unable to resolve/
305
+ )
306
+ end
307
+
176
308
  it 'should raise RequireApproval error if match approvalRequiredConditions' do
177
309
  parameters = ActionController::Parameters.new(params).permit!
178
310
  action['approvalRequired'] = [1]
@@ -44,8 +44,15 @@ module ForestLiana
44
44
  end
45
45
 
46
46
  after(:each) do
47
+ # Rails 7.2 switched _reflections/reflections to symbol keys; deleting only the string form
48
+ # is a silent no-op there. Rails 7.2 also memoizes reflect_on_all_associations and only busts
49
+ # that cache from add_reflection, never on a direct _reflections mutation — without the
50
+ # explicit clear, the deleted association keeps leaking into every later spec.
47
51
  Tree._reflections.delete('island_neighbour')
52
+ Tree._reflections.delete(:island_neighbour)
48
53
  Tree.reflections.delete('island_neighbour')
54
+ Tree.reflections.delete(:island_neighbour)
55
+ Tree.clear_reflections_cache
49
56
  %w[island_neighbour island_neighbour= build_island_neighbour
50
57
  create_island_neighbour create_island_neighbour! reload_island_neighbour].each do |m|
51
58
  Tree.undef_method(m) rescue nil
@@ -327,10 +327,17 @@ module ForestLiana
327
327
  end
328
328
 
329
329
  after do
330
+ # Rails 7.2 switched _reflections/reflections to symbol keys; deleting only the string
331
+ # form is a silent no-op there. Rails 7.2 also memoizes reflect_on_all_associations and
332
+ # only busts that cache from add_reflection, never on a direct _reflections mutation —
333
+ # without the explicit clear, the deleted association keeps leaking into later specs.
330
334
  %w[addressable].each do |name|
331
335
  Tree._reflections.delete(name)
336
+ Tree._reflections.delete(name.to_sym)
332
337
  Tree.reflections.delete(name)
338
+ Tree.reflections.delete(name.to_sym)
333
339
  end
340
+ Tree.clear_reflections_cache
334
341
  %w[addressable addressable= addressable_id addressable_type].each do |m|
335
342
  Tree.undef_method(m) rescue nil
336
343
  end
@@ -166,6 +166,32 @@ module ForestLiana
166
166
  end
167
167
  end
168
168
 
169
+ describe '.fetch_ids' do
170
+ it 'returns all the matching ids when no limit is given' do
171
+ expect(described_class.fetch_ids(getter).length).to eq 30
172
+ end
173
+
174
+ it 'returns at most limit ids' do
175
+ expect(described_class.fetch_ids(getter, limit: 5).length).to eq 5
176
+ end
177
+ end
178
+
179
+ describe '.filter_excluded_ids' do
180
+ it 'removes excluded simple ids' do
181
+ expect(described_class.filter_excluded_ids([1, 2, 3], ['2'])).to eq [1, 3]
182
+ end
183
+
184
+ it 'removes excluded composite ids given in their serialized JSON form' do
185
+ expect(described_class.filter_excluded_ids([[1, 'abc'], [2, 'def']], ['[1,"abc"]']))
186
+ .to eq [[2, 'def']]
187
+ end
188
+
189
+ it 'returns the ids untouched without exclusions' do
190
+ expect(described_class.filter_excluded_ids([1, 2], [])).to eq [1, 2]
191
+ expect(described_class.filter_excluded_ids([1, 2], nil)).to eq [1, 2]
192
+ end
193
+ end
194
+
169
195
  describe 'when on a model having a reserved SQL word as name' do
170
196
  let(:resource) { Reference }
171
197
  let(:fields) { { resource.name => 'id' } }
@@ -58,10 +58,16 @@ module ForestLiana
58
58
  @original_apimap = ForestLiana.apimap.dup
59
59
  @original_models = ForestLiana.models.dup
60
60
 
61
+ # The keyword form is the only one Rails 6.1 knows; Rails 8 only keeps the positional one.
61
62
  Object.const_set(:EnumFieldModel, Class.new(ActiveRecord::Base) do
62
63
  self.table_name = 'enum_field_models'
63
- enum status: { inactive: 0, active: 1, archived: 2 }
64
- enum role: { user: "0", admin: "1", superadmin: "2" }
64
+ if Rails.gem_version >= Gem::Version.new('7.0')
65
+ enum :status, { inactive: 0, active: 1, archived: 2 }
66
+ enum :role, { user: "0", admin: "1", superadmin: "2" }
67
+ else
68
+ enum status: { inactive: 0, active: 1, archived: 2 }
69
+ enum role: { user: "0", admin: "1", superadmin: "2" }
70
+ end
65
71
  end)
66
72
 
67
73
  ActiveRecord::Migration.suppress_messages do
@@ -1,5 +1,10 @@
1
1
  class HasOneField < ActiveRecord::Base
2
- enum status: [:submitted, :pending, :rejected]
2
+ # The keyword form is the only one Rails 6.1 knows; Rails 8 only keeps the positional one.
3
+ if Rails.gem_version >= Gem::Version.new('7.0')
4
+ enum :status, [:submitted, :pending, :rejected]
5
+ else
6
+ enum status: [:submitted, :pending, :rejected]
7
+ end
3
8
 
4
9
  has_one :belongs_to_field
5
10
  has_one :belongs_to_class_name_field, foreign_key: :foo_id
@@ -1,3 +1,8 @@
1
1
  class SerializeField < ActiveRecord::Base
2
- serialize :field, Array
2
+ # The positional coder was removed in Rails 7.2; `type:` only exists from 7.1.
3
+ if Rails.gem_version >= Gem::Version.new('7.1')
4
+ serialize :field, type: Array
5
+ else
6
+ serialize :field, Array
7
+ end
3
8
  end
@@ -20,8 +20,9 @@ Rails.application.configure do
20
20
  config.consider_all_requests_local = true
21
21
  config.action_controller.perform_caching = false
22
22
 
23
- # Raise exceptions instead of rendering exception templates.
24
- config.action_dispatch.show_exceptions = false
23
+ # Raise exceptions instead of rendering exception templates. Rails 8 dropped the boolean form,
24
+ # and a leftover `false` there falls through to "show all" — rendered 500s instead of raises.
25
+ config.action_dispatch.show_exceptions = Rails.gem_version >= Gem::Version.new('7.1') ? :none : false
25
26
 
26
27
  # Disable request forgery protection in test environment.
27
28
  config.action_controller.allow_forgery_protection = false
data/test/test_helper.rb CHANGED
@@ -14,7 +14,10 @@ Minitest.backtrace_filter = Minitest::BacktraceFilter.new
14
14
  Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].each { |f| require f }
15
15
 
16
16
  # Load fixtures from the engine
17
- if ActiveSupport::TestCase.respond_to?(:fixture_path=)
17
+ if ActiveSupport::TestCase.respond_to?(:fixture_paths=)
18
+ ActiveSupport::TestCase.fixture_paths = [File.expand_path("../fixtures", __FILE__)]
19
+ ActiveSupport::TestCase.fixtures :all
20
+ elsif ActiveSupport::TestCase.respond_to?(:fixture_path=)
18
21
  ActiveSupport::TestCase.fixture_path = File.expand_path("../fixtures", __FILE__)
19
22
  ActiveSupport::TestCase.fixtures :all
20
23
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: forest_liana
3
3
  version: !ruby/object:Gem::Version
4
- version: 9.20.13
4
+ version: 9.21.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sandro Munda
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-25 00:00:00.000000000 Z
11
+ date: 2026-09-09 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -276,6 +276,7 @@ files:
276
276
  - app/services/forest_liana/ability.rb
277
277
  - app/services/forest_liana/ability/exceptions/access_denied.rb
278
278
  - app/services/forest_liana/ability/exceptions/action_condition_error.rb
279
+ - app/services/forest_liana/ability/exceptions/approval_selection_too_large.rb
279
280
  - app/services/forest_liana/ability/exceptions/require_approval.rb
280
281
  - app/services/forest_liana/ability/exceptions/trigger_forbidden.rb
281
282
  - app/services/forest_liana/ability/exceptions/unknown_collection.rb
@@ -368,7 +369,6 @@ files:
368
369
  - spec/dummy/app/assets/config/manifest.js
369
370
  - spec/dummy/app/assets/javascripts/application.js
370
371
  - spec/dummy/app/assets/stylesheets/application.css
371
- - spec/dummy/app/config/routes.rb
372
372
  - spec/dummy/app/controllers/application_controller.rb
373
373
  - spec/dummy/app/controllers/forest/islands_controller.rb
374
374
  - spec/dummy/app/controllers/host_catch_all_controller.rb
@@ -402,7 +402,6 @@ files:
402
402
  - spec/dummy/config/environments/development.rb
403
403
  - spec/dummy/config/environments/production.rb
404
404
  - spec/dummy/config/environments/test.rb
405
- - spec/dummy/config/initializers/assets.rb
406
405
  - spec/dummy/config/initializers/backtrace_silencers.rb
407
406
  - spec/dummy/config/initializers/cookies_serializer.rb
408
407
  - spec/dummy/config/initializers/filter_parameter_logging.rb
@@ -517,7 +516,6 @@ files:
517
516
  - test/dummy/config/environments/development.rb
518
517
  - test/dummy/config/environments/production.rb
519
518
  - test/dummy/config/environments/test.rb
520
- - test/dummy/config/initializers/assets.rb
521
519
  - test/dummy/config/initializers/backtrace_silencers.rb
522
520
  - test/dummy/config/initializers/cookies_serializer.rb
523
521
  - test/dummy/config/initializers/filter_parameter_logging.rb
@@ -627,7 +625,6 @@ test_files:
627
625
  - test/dummy/config/environments/development.rb
628
626
  - test/dummy/config/environments/production.rb
629
627
  - test/dummy/config/environments/test.rb
630
- - test/dummy/config/initializers/assets.rb
631
628
  - test/dummy/config/initializers/backtrace_silencers.rb
632
629
  - test/dummy/config/initializers/cookies_serializer.rb
633
630
  - test/dummy/config/initializers/filter_parameter_logging.rb
@@ -684,7 +681,6 @@ test_files:
684
681
  - spec/dummy/app/assets/config/manifest.js
685
682
  - spec/dummy/app/assets/javascripts/application.js
686
683
  - spec/dummy/app/assets/stylesheets/application.css
687
- - spec/dummy/app/config/routes.rb
688
684
  - spec/dummy/app/controllers/application_controller.rb
689
685
  - spec/dummy/app/controllers/forest/islands_controller.rb
690
686
  - spec/dummy/app/controllers/host_catch_all_controller.rb
@@ -717,7 +713,6 @@ test_files:
717
713
  - spec/dummy/config/environments/development.rb
718
714
  - spec/dummy/config/environments/production.rb
719
715
  - spec/dummy/config/environments/test.rb
720
- - spec/dummy/config/initializers/assets.rb
721
716
  - spec/dummy/config/initializers/backtrace_silencers.rb
722
717
  - spec/dummy/config/initializers/cookies_serializer.rb
723
718
  - spec/dummy/config/initializers/filter_parameter_logging.rb
@@ -1,3 +0,0 @@
1
- Rails.application.routes.draw do
2
- mount ForestLiana::Engine => "/forest"
3
- end
@@ -1,11 +0,0 @@
1
- # Be sure to restart your server when you modify this file.
2
-
3
- # Version of your assets, change this if you want to expire all your assets.
4
- Rails.application.config.assets.version = '1.0'
5
-
6
- # Add additional assets to the asset load path
7
- # Rails.application.config.assets.paths << Emoji.images_path
8
-
9
- # Precompile additional assets.
10
- # application.js, application.css, and all non-JS/CSS in app/assets folder are already added.
11
- # Rails.application.config.assets.precompile += %w( search.js )
@@ -1,11 +0,0 @@
1
- # Be sure to restart your server when you modify this file.
2
-
3
- # Version of your assets, change this if you want to expire all your assets.
4
- Rails.application.config.assets.version = '1.0'
5
-
6
- # Add additional assets to the asset load path
7
- # Rails.application.config.assets.paths << Emoji.images_path
8
-
9
- # Precompile additional assets.
10
- # application.js, application.css, and all non-JS/CSS in app/assets folder are already added.
11
- # Rails.application.config.assets.precompile += %w( search.js )