forest_liana 9.20.8 → 9.20.10

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dfcdfafce741a2f8305f64a7241219a6f9b54227dd310dd7269ff83043f0f35d
4
- data.tar.gz: c3ce3c647b53d3cc7bd8c5d45ed98c887588112c6e5e7fc558ca6ca5ddf385f2
3
+ metadata.gz: 5449af5d3c0aa87c4c56f8417d3527e279690d22a34f02cb081cee74d2de06b1
4
+ data.tar.gz: '044797af4de1b516a177d859566fdbdea4e5458eb938c107077d01a19314a763'
5
5
  SHA512:
6
- metadata.gz: debd8bdb65d707795b7e895769c3ec1bd1fc0796bc971edfad2d0446a6b19e1b2b78bdd1c64cd77b2dc615eecc5384b868d1e8ab01810646aa287fd8c7c1a1f1
7
- data.tar.gz: 9534d5fd16d409d282dd806955386e730a428c3c8405b0beb0284bd317f6825a8f5cdf616ca662e4148a8df9703b23e0370772edd9a35d9c6dbca9e5fe72ff76
6
+ metadata.gz: cce3c3edaacf3cea3dfc9ea29f3a7391cf2e1a09b399784c1be06c767d6013e600d70c7ca2fa70673451c0e18431df0d521e3cff2285408c003ef4911ca18336
7
+ data.tar.gz: b56dc37e18b8f8e78e730c1d8eb844731134d22be8478a27e4ea576ff75df9523f6c6ba7b7da8b07a585b97419d9b2d3058656527acad2dcb94146533e9afabd
@@ -81,4 +81,29 @@ class ForestLiana::Model::Action
81
81
  def persisted?
82
82
  false
83
83
  end
84
+
85
+ # Normalized at the boundary so every consumer sees one canonical value: the schema sent to
86
+ # Forest (the UI appends /hooks/load to it), the permission lookup comparing request paths,
87
+ # and the routes drawn from it. Leading slash kept as declared — consumers tolerate both.
88
+ def endpoint=(value)
89
+ @endpoint = value && value.to_s.gsub(%r{/+}, '/').delete_suffix('/')
90
+ end
91
+
92
+ def endpoint_under_forest_mount?
93
+ normalized_endpoint.start_with?('forest/')
94
+ end
95
+
96
+ def engine_relative_endpoint
97
+ normalized_endpoint.delete_prefix('forest')
98
+ end
99
+
100
+ def absolute_endpoint
101
+ "/#{normalized_endpoint}"
102
+ end
103
+
104
+ private
105
+
106
+ def normalized_endpoint
107
+ endpoint.to_s.delete_prefix('/')
108
+ end
84
109
  end
@@ -85,7 +85,7 @@ module ForestLiana
85
85
  !@resource.defined_enums[column.name][@search.downcase].nil?
86
86
  conditions << "#{column_name} =
87
87
  #{@resource.defined_enums[column.name][@search.downcase]}"
88
- elsif !(column.respond_to?(:array) && column.array) && text_type?(column.type)
88
+ elsif !(column.respond_to?(:array) && column.array) && text_type?(column.type) && !malformed_uuid_search?
89
89
  conditions << "LOWER(#{column_name}) LIKE :search_value_for_string"
90
90
  end
91
91
  end
@@ -114,7 +114,7 @@ module ForestLiana
114
114
  resource = @resource.reflect_on_association(association.to_sym)
115
115
  unless (SchemaUtils.polymorphic?(resource))
116
116
  resource.klass.columns.each do |column|
117
- if !(column.respond_to?(:array) && column.array) && text_type?(column.type)
117
+ if !(column.respond_to?(:array) && column.array) && text_type?(column.type) && !malformed_uuid_search?
118
118
  if @collection.search_fields.nil? || (association_search &&
119
119
  association_search.include?(column.name))
120
120
  conditions << association_search_condition(resource.table_name,
@@ -138,7 +138,7 @@ module ForestLiana
138
138
  unless association_search.empty?
139
139
  resource = @resource.reflect_on_association(association.to_sym)
140
140
  resource.klass.columns.each do |column|
141
- if !(column.respond_to?(:array) && column.array) && text_type?(column.type)
141
+ if !(column.respond_to?(:array) && column.array) && text_type?(column.type) && !malformed_uuid_search?
142
142
  if association_search.include?(column.name)
143
143
  conditions << association_search_condition(resource.table_name,
144
144
  column.name)
@@ -150,11 +150,18 @@ module ForestLiana
150
150
  end
151
151
  end
152
152
 
153
- @records = @resource.where(
154
- conditions.join(' OR '),
155
- search_value_for_string: "%#{@search.downcase}%",
156
- search_value_for_uuid: @search.to_s
157
- ) unless conditions.empty?
153
+ if conditions.empty?
154
+ # NOTICE: a malformed-UUID search suppresses the only conditions it could
155
+ # have produced (text LIKE scans); match nothing rather than fall
156
+ # through to an unfiltered query that returns the whole table.
157
+ @records = @resource.none if malformed_uuid_search?
158
+ else
159
+ @records = @resource.where(
160
+ conditions.join(' OR '),
161
+ search_value_for_string: "%#{@search.downcase}%",
162
+ search_value_for_uuid: @search.to_s
163
+ )
164
+ end
158
165
  end
159
166
 
160
167
  @records
@@ -223,5 +230,17 @@ module ForestLiana
223
230
  def text_type?(type_sym)
224
231
  [:string, :text, :citext].include? type_sym
225
232
  end
233
+
234
+ # NOTICE: A search that is UUID-shaped but fails the strict REGEX_UUID (a
235
+ # truncated or mistyped UUID) can never match a real UUID column and,
236
+ # on text columns, only triggers `LOWER(col) LIKE '%…%'` sequential
237
+ # scans that can hit the statement timeout. Valid UUIDs are excluded
238
+ # so they keep matching UUIDs stored in varchar/text columns.
239
+ def malformed_uuid_search?
240
+ return false unless @search.is_a?(String)
241
+
242
+ @search.match?(/\A[0-9a-f]+-[0-9a-f]+-[0-9a-f]+-[0-9a-f]+-[0-9a-f]+\z/i) &&
243
+ !REGEX_UUID.match?(@search)
244
+ end
226
245
  end
227
246
  end
@@ -1,10 +1,13 @@
1
1
  ForestLiana.apimap.each do |collection|
2
2
  if !collection.actions.empty?
3
3
  collection.actions.each do |action|
4
+ # Endpoints outside the mount are routed on the host app instead — see ForestLiana::Engine.
5
+ next unless action.endpoint_under_forest_mount?
6
+
4
7
  # Unconditional: clients probe /hooks/load even when no load hook is declared.
5
- post action.endpoint.sub('forest', '') + '/hooks/load' => 'actions#load', action_name: ActiveSupport::Inflector.parameterize(action.name)
8
+ post action.engine_relative_endpoint + '/hooks/load' => 'actions#load', action_name: ActiveSupport::Inflector.parameterize(action.name)
6
9
  if action.hooks && action.hooks[:change].present?
7
- post action.endpoint.sub('forest', '') + '/hooks/change' => 'actions#change', action_name: ActiveSupport::Inflector.parameterize(action.name)
10
+ post action.engine_relative_endpoint + '/hooks/change' => 'actions#change', action_name: ActiveSupport::Inflector.parameterize(action.name)
8
11
  end
9
12
  end
10
13
  end
@@ -127,6 +127,32 @@ module ForestLiana
127
127
  end
128
128
  end
129
129
  end
130
+
131
+ ForestLiana::Engine.register_out_of_mount_hook_routes
132
+ end
133
+
134
+ # An endpoint outside the engine mount can only be served by the host router: a route drawn in
135
+ # the engine gets prefixed with the mount, where no client ever calls. Prepended so a host
136
+ # catch-all (SPA fallback) cannot shadow the hooks, and registered from after_initialize rather
137
+ # than from config/routes/actions.rb, which is re-evaluated on every reload and would stack a
138
+ # duplicate block per pass — prepended blocks persist across reloads on their own. Defined here
139
+ # so the block's closure captures this empty scope, not after_initialize's locals: the block is
140
+ # retained by the router for the process lifetime and would otherwise pin the bootstrapper.
141
+ def self.register_out_of_mount_hook_routes
142
+ Rails.application.routes.prepend do
143
+ ForestLiana.apimap.each do |collection|
144
+ collection.actions.each do |action|
145
+ next if action.endpoint_under_forest_mount?
146
+
147
+ post "#{action.absolute_endpoint}/hooks/load" => 'forest_liana/actions#load',
148
+ action_name: ActiveSupport::Inflector.parameterize(action.name)
149
+ if action.hooks && action.hooks[:change].present?
150
+ post "#{action.absolute_endpoint}/hooks/change" => 'forest_liana/actions#change',
151
+ action_name: ActiveSupport::Inflector.parameterize(action.name)
152
+ end
153
+ end
154
+ end
155
+ end
130
156
  end
131
157
  end
132
158
  end
@@ -1,3 +1,3 @@
1
1
  module ForestLiana
2
- VERSION = "9.20.8"
2
+ VERSION = "9.20.10"
3
3
  end
@@ -0,0 +1,5 @@
1
+ class HostCatchAllController < ActionController::Base
2
+ def index
3
+ render json: { caught_by: 'host catch-all' }
4
+ end
5
+ end
@@ -7,4 +7,7 @@ Rails.application.routes.draw do
7
7
  end
8
8
 
9
9
  mount ForestLiana::Engine => "/forest"
10
+
11
+ # Stands in for a host SPA fallback: hooks routes on out-of-mount endpoints must still win.
12
+ match '/api/*path', to: 'host_catch_all#index', via: :all
10
13
  end
@@ -180,6 +180,20 @@ class Forest::Island
180
180
  }
181
181
  }
182
182
 
183
+ action 'out_of_mount_action',
184
+ endpoint: '/api/custom/islands/out_of_mount_action',
185
+ fields: [foo],
186
+ hooks: {
187
+ :load => -> (context) {
188
+ context[:fields]
189
+ },
190
+ :change => {
191
+ 'on_foo_changed' => -> (context) {
192
+ context[:fields]
193
+ }
194
+ }
195
+ }
196
+
183
197
  action 'multiple_enums_action',
184
198
  fields: [foo, multiple_enum],
185
199
  hooks: {
@@ -0,0 +1,48 @@
1
+ module ForestLiana
2
+ describe Model::Action do
3
+ def action_with(endpoint)
4
+ Model::Action.new(name: 'an action', endpoint: endpoint)
5
+ end
6
+
7
+ describe 'endpoint routing' do
8
+ it 'defaults to an endpoint under the mount, with or without a leading slash' do
9
+ action = Model::Action.new(name: 'an action')
10
+
11
+ expect(action.endpoint).to eq('forest/actions/an-action')
12
+ expect(action.endpoint_under_forest_mount?).to be true
13
+ expect(action.engine_relative_endpoint).to eq('/actions/an-action')
14
+
15
+ expect(action_with('/forest/actions/foo').endpoint_under_forest_mount?).to be true
16
+ expect(action_with('/forest/actions/foo').engine_relative_endpoint).to eq('/actions/foo')
17
+ end
18
+
19
+ it 'routes an endpoint outside the mount on the host, verbatim' do
20
+ action = action_with('/domains/organizations/suspensions/suspend')
21
+
22
+ expect(action.endpoint_under_forest_mount?).to be false
23
+ expect(action.absolute_endpoint).to eq('/domains/organizations/suspensions/suspend')
24
+ end
25
+
26
+ # A trailing or doubled slash would otherwise desynchronize the three consumers of the
27
+ # declared endpoint: the drawn route, the schema the UI appends /hooks/load to, and the
28
+ # permission lookup comparing request paths.
29
+ it 'normalizes redundant slashes at assignment, so every consumer sees one value' do
30
+ expect(action_with('/domains/x/suspend/').endpoint).to eq('/domains/x/suspend')
31
+ expect(action_with('//domains/x/suspend').endpoint).to eq('/domains/x/suspend')
32
+ expect(action_with('/domains/x/suspend/').absolute_endpoint).to eq('/domains/x/suspend')
33
+ expect(action_with('domains/x/suspend').endpoint).to eq('domains/x/suspend')
34
+ expect(action_with('/forest/actions/foo/').engine_relative_endpoint).to eq('/actions/foo')
35
+ expect(action_with('//forest/actions/foo').endpoint_under_forest_mount?).to be true
36
+ end
37
+
38
+ # `sub('forest', '')` used to match anywhere, mangling these into '/ry/actions/x' and '/my//x'.
39
+ it 'does not treat a path merely containing "forest" as being under the mount' do
40
+ expect(action_with('forestry/actions/x').endpoint_under_forest_mount?).to be false
41
+ expect(action_with('forestry/actions/x').absolute_endpoint).to eq('/forestry/actions/x')
42
+
43
+ expect(action_with('my/forest/x').endpoint_under_forest_mount?).to be false
44
+ expect(action_with('my/forest/x').absolute_endpoint).to eq('/my/forest/x')
45
+ end
46
+ end
47
+ end
48
+ end
@@ -170,6 +170,53 @@ describe 'Requesting Actions routes', :type => :request do
170
170
  end
171
171
  end
172
172
 
173
+ describe 'on an endpoint declared outside the engine mount' do
174
+ params = {
175
+ data: {
176
+ attributes: { ids: [1], collection_name: 'Island' }
177
+ }
178
+ }
179
+
180
+ # The dummy app declares a `/api/*path` catch-all standing in for a host SPA fallback: these
181
+ # routes have to outrank it, or the 404 this fix removes comes straight back.
182
+ it 'should serve /load at the endpoint the client calls' do
183
+ post '/api/custom/islands/out_of_mount_action/hooks/load', params: JSON.dump(params), headers: headers
184
+ expect(response.status).to eq(200)
185
+ expect(JSON.parse(response.body)['fields'].map { |field| field['field'] }).to eq(['foo'])
186
+ end
187
+
188
+ it 'should let the host catch-all keep every other path under it' do
189
+ post '/api/custom/islands/something_else', params: JSON.dump(params), headers: headers
190
+ expect(JSON.parse(response.body)).to eq({'caught_by' => 'host catch-all'})
191
+ end
192
+
193
+ it 'should serve /change at the endpoint the client calls' do
194
+ action = island.actions.select { |action| action.name == 'out_of_mount_action' }.first
195
+ foo = action.fields.select { |field| field[:field] == 'foo' }.first
196
+ updated_foo = foo.clone.merge({:previousValue => nil, :value => 'bar'})
197
+ p = {
198
+ data: {
199
+ attributes: {
200
+ ids: [1],
201
+ fields: [updated_foo],
202
+ collection_name: 'Island',
203
+ changed_field: 'foo'
204
+ }
205
+ }
206
+ }
207
+
208
+ post '/api/custom/islands/out_of_mount_action/hooks/change', params: JSON.dump(p), headers: headers
209
+ expect(response.status).to eq(200)
210
+ expect(JSON.parse(response.body)['fields'].map { |field| field['field'] }).to eq(['foo'])
211
+ end
212
+
213
+ it 'should not expose the hooks under the engine mount' do
214
+ expect {
215
+ post '/forest/api/custom/islands/out_of_mount_action/hooks/load', params: JSON.dump(params), headers: headers
216
+ }.to raise_error(ActionController::RoutingError)
217
+ end
218
+ end
219
+
173
220
  describe 'call /change' do
174
221
  it 'should respond 200' do
175
222
  action = island.actions.select { |action| action.name == 'my_action' }.first
@@ -16,6 +16,50 @@ module ForestLiana
16
16
  end
17
17
 
18
18
  describe '#perform' do
19
+ context 'when the search is a malformed UUID' do
20
+ # UUID-shaped but fails the strict REGEX_UUID (mistyped/truncated group).
21
+ # Starts with a letter so #to_i is 0 and adds no integer-`id` condition,
22
+ # leaving the text LIKE scans as the only possible matches.
23
+ let(:params) { { search: 'abcdef12-3456-4ae-ad4f-5662757713a2', searchExtended: '0' } }
24
+
25
+ before do
26
+ Tree.create!(name: 'Oak')
27
+ Tree.create!(name: 'Elm')
28
+ end
29
+
30
+ after { Tree.destroy_all }
31
+
32
+ it 'matches nothing instead of returning the unfiltered table' do
33
+ expect(builder.perform(Tree.all).count).to eq(0)
34
+ end
35
+ end
36
+
37
+ context 'when searchExtended is on and the search is a malformed UUID' do
38
+ # HashWithIndifferentAccess: the code reads @params['searchExtended'] (string).
39
+ let(:params) do
40
+ ActiveSupport::HashWithIndifferentAccess.new(
41
+ search: 'abcdef12-3456-4ae-ad4f-5662757713a2', searchExtended: '1'
42
+ )
43
+ end
44
+ let(:builder) { described_class.new(params, [:owner], collection, user) }
45
+
46
+ before { Tree.create!(name: 'Oak') }
47
+
48
+ after { Tree.destroy_all }
49
+
50
+ it 'does not build LIKE scans on associated text columns either' do
51
+ expect(builder.perform(Tree.all).to_sql).not_to match(/LIKE/i)
52
+ end
53
+ end
54
+
55
+ context 'when the search is a valid UUID' do
56
+ let(:params) { { search: '75fbcb43-f6f8-4cd1-861f-09a61fd1ddad', searchExtended: '0' } }
57
+
58
+ it 'still builds a LIKE scan on text columns (UUIDs stored as text)' do
59
+ expect(builder.perform(Tree.all).to_sql).to match(/LIKE/i)
60
+ end
61
+ end
62
+
19
63
  context 'when a column is an array uuid type' do
20
64
  let(:array_uuid_column) do
21
65
  double('Column', name: 'attachment_ids', type: :uuid, array: true).tap do |col|
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.8
4
+ version: 9.20.10
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-05 00:00:00.000000000 Z
11
+ date: 2026-08-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -371,6 +371,7 @@ files:
371
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
+ - spec/dummy/app/controllers/host_catch_all_controller.rb
374
375
  - spec/dummy/app/helpers/application_helper.rb
375
376
  - spec/dummy/app/models/address.rb
376
377
  - spec/dummy/app/models/application_record.rb
@@ -441,6 +442,7 @@ files:
441
442
  - spec/lib/forest_liana/collection_spec.rb
442
443
  - spec/lib/forest_liana/engine_spec.rb
443
444
  - spec/lib/forest_liana/schema_file_updater_spec.rb
445
+ - spec/models/forest_liana/model/action_spec.rb
444
446
  - spec/rails_helper.rb
445
447
  - spec/requests/actions_controller_spec.rb
446
448
  - spec/requests/authentications_spec.rb
@@ -684,6 +686,7 @@ test_files:
684
686
  - spec/dummy/app/config/routes.rb
685
687
  - spec/dummy/app/controllers/application_controller.rb
686
688
  - spec/dummy/app/controllers/forest/islands_controller.rb
689
+ - spec/dummy/app/controllers/host_catch_all_controller.rb
687
690
  - spec/dummy/app/helpers/application_helper.rb
688
691
  - spec/dummy/app/models/address.rb
689
692
  - spec/dummy/app/models/application_record.rb
@@ -754,6 +757,7 @@ test_files:
754
757
  - spec/lib/forest_liana/collection_spec.rb
755
758
  - spec/lib/forest_liana/engine_spec.rb
756
759
  - spec/lib/forest_liana/schema_file_updater_spec.rb
760
+ - spec/models/forest_liana/model/action_spec.rb
757
761
  - spec/rails_helper.rb
758
762
  - spec/requests/actions_controller_spec.rb
759
763
  - spec/requests/authentications_spec.rb