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 +4 -4
- data/app/models/forest_liana/model/action.rb +25 -0
- data/app/services/forest_liana/search_query_builder.rb +27 -8
- data/config/routes/actions.rb +5 -2
- data/lib/forest_liana/engine.rb +26 -0
- data/lib/forest_liana/version.rb +1 -1
- data/spec/dummy/app/controllers/host_catch_all_controller.rb +5 -0
- data/spec/dummy/config/routes.rb +3 -0
- data/spec/dummy/lib/forest_liana/collections/island.rb +14 -0
- data/spec/models/forest_liana/model/action_spec.rb +48 -0
- data/spec/requests/actions_controller_spec.rb +47 -0
- data/spec/services/forest_liana/search_query_builder_spec.rb +44 -0
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5449af5d3c0aa87c4c56f8417d3527e279690d22a34f02cb081cee74d2de06b1
|
|
4
|
+
data.tar.gz: '044797af4de1b516a177d859566fdbdea4e5458eb938c107077d01a19314a763'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
154
|
-
conditions
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
data/config/routes/actions.rb
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
data/lib/forest_liana/engine.rb
CHANGED
|
@@ -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
|
data/lib/forest_liana/version.rb
CHANGED
data/spec/dummy/config/routes.rb
CHANGED
|
@@ -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.
|
|
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-
|
|
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
|