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.
- checksums.yaml +4 -4
- data/app/controllers/forest_liana/smart_actions_controller.rb +1 -0
- data/app/services/forest_liana/ability/exceptions/approval_selection_too_large.rb +17 -0
- data/app/services/forest_liana/ability/exceptions/require_approval.rb +3 -2
- data/app/services/forest_liana/ability/permission/smart_action_checker.rb +39 -2
- data/app/services/forest_liana/ability/permission.rb +2 -2
- data/app/services/forest_liana/resources_getter.rb +13 -6
- data/lib/forest_liana/engine.rb +4 -2
- data/lib/forest_liana/version.rb +1 -1
- data/spec/dummy/app/models/user.rb +6 -1
- data/spec/dummy/config/database.yml +14 -9
- data/spec/dummy/config/environments/test.rb +3 -2
- data/spec/dummy/db/migrate/20220719094127_create_cars.rb +6 -0
- data/spec/dummy/db/migrate/20220719094450_create_drivers.rb +3 -0
- data/spec/lib/forest_liana/bootstrapper_spec.rb +11 -2
- data/spec/rails_helper.rb +5 -1
- data/spec/requests/cors_spec.rb +6 -0
- data/spec/services/forest_liana/ability/permission/smart_action_checker_spec.rb +132 -0
- data/spec/services/forest_liana/has_many_getter_pagination_spec.rb +7 -0
- data/spec/services/forest_liana/has_many_getter_spec.rb +7 -0
- data/spec/services/forest_liana/resources_getter_spec.rb +26 -0
- data/spec/services/forest_liana/schema_adapter_default_values_spec.rb +8 -2
- data/test/dummy/app/models/has_one_field.rb +6 -1
- data/test/dummy/app/models/serialize_field.rb +6 -1
- data/test/dummy/config/environments/test.rb +3 -2
- data/test/test_helper.rb +4 -1
- metadata +3 -8
- data/spec/dummy/app/config/routes.rb +0 -3
- data/spec/dummy/config/initializers/assets.rb +0 -11
- data/test/dummy/config/initializers/assets.rb +0 -11
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9d52c5a01f048d047defb33c61e8a1eb1062e9b71f9e998717b984715b622812
|
|
4
|
+
data.tar.gz: 9b911cdda6df160758eaaa7cedcd9ac13b47eebf3deec4224c744713de5f2990
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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(
|
|
7
|
-
@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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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][
|
|
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
|
|
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
|
-
|
|
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
|
data/lib/forest_liana/engine.rb
CHANGED
|
@@ -14,9 +14,11 @@ module Rack
|
|
|
14
14
|
class Resource
|
|
15
15
|
def to_preflight_headers(env)
|
|
16
16
|
h = to_headers(env)
|
|
17
|
-
|
|
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!('
|
|
21
|
+
h.merge!('access-control-allow-headers' => env[HTTP_ACCESS_CONTROL_REQUEST_HEADERS])
|
|
20
22
|
end
|
|
21
23
|
h
|
|
22
24
|
end
|
data/lib/forest_liana/version.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
garage:
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
user:
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
data/spec/requests/cors_spec.rb
CHANGED
|
@@ -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
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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?(:
|
|
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.
|
|
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-
|
|
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,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 )
|