forest_liana 9.22.13 → 9.22.15
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/devise_controller.rb +16 -7
- data/app/controllers/forest_liana/smart_actions_controller.rb +6 -0
- data/app/services/forest_liana/query_stat_getter.rb +4 -7
- data/app/services/forest_liana/schema_adapter.rb +1 -0
- data/app/services/forest_liana/utils/context_variables_injector.rb +15 -12
- data/app/services/forest_liana/utils/live_query_injector.rb +136 -0
- data/lib/forest_liana/version.rb +1 -1
- data/spec/requests/actions_controller_spec.rb +11 -0
- data/spec/requests/devise_controller_spec.rb +271 -0
- data/spec/services/forest_liana/query_stat_getter_spec.rb +267 -0
- data/spec/services/forest_liana/schema_adapter_spec.rb +26 -0
- data/spec/services/forest_liana/utils/live_query_injector_spec.rb +273 -0
- metadata +9 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dc9e247d2d65125925e14127a256acb70326504b4c1e2fc90daaf3b792736025
|
|
4
|
+
data.tar.gz: 7039ebbb67be595efd366b94d57a35881448f9747cba39cf2e8e35b1f88a083e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3bf2d66f16eaedc067bfca8422a0274255bc9bc6d86057e251bdb65dd41191859e02344f2712356511c437bf03a525c5ff11b2a7f4ebb972104420baafcd1fbc
|
|
7
|
+
data.tar.gz: 07b0b79be5105719f3cffbb590717731031d3e1cd0fc81eb6e5d2c72a60f7413e411661f4f301a97294fc08e4cadac9e1d547ceb9c9cec1b9b0bbeadae569b3b
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
module ForestLiana
|
|
2
|
-
class DeviseController < ForestLiana::
|
|
3
|
-
|
|
4
|
-
resource = SchemaUtils.find_model_from_collection_name(
|
|
5
|
-
params['data']['attributes']['collection_name'])
|
|
2
|
+
class DeviseController < ForestLiana::SmartActionsController
|
|
3
|
+
rescue_from ActiveRecord::RecordNotFound, with: :render_target_not_found
|
|
6
4
|
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
def change_password
|
|
6
|
+
attributes = @parameters[:data][:attributes]
|
|
7
|
+
# The inherited scope check skips composite primary keys.
|
|
8
|
+
scoped_records = ForestLiana::ScopeManager.apply_scopes_on_records(
|
|
9
|
+
@resource, forest_user, ForestLiana.name_for(@resource), @parameters[:timezone]
|
|
10
|
+
)
|
|
11
|
+
user = scoped_records.find(attributes[:ids].first)
|
|
12
|
+
user.password = attributes[:values]['New password']
|
|
9
13
|
user.save
|
|
10
14
|
|
|
11
15
|
if user.valid?
|
|
@@ -16,6 +20,11 @@ module ForestLiana
|
|
|
16
20
|
}
|
|
17
21
|
end
|
|
18
22
|
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def render_target_not_found
|
|
27
|
+
render serializer: nil, json: { error: 'Smart Action: target record not found' }, status: :bad_request
|
|
28
|
+
end
|
|
19
29
|
end
|
|
20
30
|
end
|
|
21
|
-
|
|
@@ -6,11 +6,13 @@ module ForestLiana
|
|
|
6
6
|
rescue_from ForestLiana::Ability::Exceptions::ActionConditionError, with: :render_error
|
|
7
7
|
include ForestLiana::Ability
|
|
8
8
|
if Rails::VERSION::MAJOR < 4
|
|
9
|
+
before_filter :refuse_requester_id_outside_signed_request
|
|
9
10
|
before_filter :get_smart_action_request
|
|
10
11
|
before_filter :find_resource
|
|
11
12
|
before_filter :check_permission_for_smart_route
|
|
12
13
|
before_filter :ensure_record_ids_in_scope
|
|
13
14
|
else
|
|
15
|
+
before_action :refuse_requester_id_outside_signed_request
|
|
14
16
|
before_action :get_smart_action_request
|
|
15
17
|
before_action :find_resource
|
|
16
18
|
before_action :check_permission_for_smart_route
|
|
@@ -19,6 +21,10 @@ module ForestLiana
|
|
|
19
21
|
|
|
20
22
|
private
|
|
21
23
|
|
|
24
|
+
def refuse_requester_id_outside_signed_request
|
|
25
|
+
raise ForestLiana::Errors::HTTP422Error.new if params.dig(:data, :attributes, :requester_id)
|
|
26
|
+
end
|
|
27
|
+
|
|
22
28
|
def get_smart_action_request
|
|
23
29
|
begin
|
|
24
30
|
params[:data][:attributes]
|
|
@@ -13,14 +13,11 @@ module ForestLiana
|
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
def perform
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
LiveQueryChecker.new(raw_query, 'Live Query Chart').validate()
|
|
16
|
+
query = @params['query'].strip
|
|
17
|
+
LiveQueryChecker.new(query, 'Live Query Chart').validate()
|
|
20
18
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
end
|
|
19
|
+
context_variables = Utils::ContextVariables.new(nil, nil, @params['contextVariables'])
|
|
20
|
+
raw_query = Utils::LiveQueryInjector.inject(query, context_variables, @params['record_id'])
|
|
24
21
|
result = ActiveRecord::Base.connection.execute(raw_query)
|
|
25
22
|
|
|
26
23
|
case @params['type']
|
|
@@ -1,30 +1,33 @@
|
|
|
1
1
|
module ForestLiana
|
|
2
2
|
module Utils
|
|
3
3
|
class ContextVariablesInjector
|
|
4
|
+
PLACEHOLDER = /{{([^}]+)}}/
|
|
4
5
|
|
|
5
6
|
def self.inject_context_in_value(value, context_variables)
|
|
6
7
|
inject_context_in_value_custom(value) do |context_variable_key|
|
|
7
|
-
|
|
8
|
-
if value.nil?
|
|
9
|
-
available_keys = context_variables.respond_to?(:keys) ? context_variables.keys.join(', ') : 'unknown'
|
|
10
|
-
available_context = context_variables.inspect
|
|
11
|
-
error_message = "Unknown context variable: '#{context_variable_key}'. " \
|
|
12
|
-
"Please check the query for any typos. " \
|
|
13
|
-
"Available context variables: #{available_keys}. "
|
|
14
|
-
raise error_message
|
|
15
|
-
end
|
|
16
|
-
value.is_a?(Array) || value.is_a?(Hash) ? value.to_json : value.to_s
|
|
8
|
+
context_value(context_variable_key, context_variables)
|
|
17
9
|
end
|
|
18
10
|
end
|
|
19
11
|
|
|
12
|
+
def self.context_value(context_variable_key, context_variables)
|
|
13
|
+
value = context_variables.get_value(context_variable_key)
|
|
14
|
+
if value.nil?
|
|
15
|
+
available_keys = context_variables.respond_to?(:keys) ? context_variables.keys.join(', ') : 'unknown'
|
|
16
|
+
error_message = "Unknown context variable: '#{context_variable_key}'. " \
|
|
17
|
+
"Please check the query for any typos. " \
|
|
18
|
+
"Available context variables: #{available_keys}. "
|
|
19
|
+
raise error_message
|
|
20
|
+
end
|
|
21
|
+
value.is_a?(Array) || value.is_a?(Hash) ? value.to_json : value.to_s
|
|
22
|
+
end
|
|
23
|
+
|
|
20
24
|
def self.inject_context_in_value_custom(value)
|
|
21
25
|
return value unless value.is_a?(String)
|
|
22
26
|
|
|
23
27
|
value_with_context_variables_injected = value
|
|
24
|
-
regex = /{{([^}]+)}}/
|
|
25
28
|
encountered_variables = []
|
|
26
29
|
|
|
27
|
-
while (match =
|
|
30
|
+
while (match = PLACEHOLDER.match(value_with_context_variables_injected))
|
|
28
31
|
context_variable_key = match[1]
|
|
29
32
|
|
|
30
33
|
value_with_context_variables_injected = value_with_context_variables_injected.gsub(
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
require 'strscan'
|
|
2
|
+
|
|
3
|
+
module ForestLiana
|
|
4
|
+
module Utils
|
|
5
|
+
class LiveQueryInjector
|
|
6
|
+
PLACEHOLDER = Regexp.union(ContextVariablesInjector::PLACEHOLDER, '?')
|
|
7
|
+
VERBATIM_VALUE = /\A(?:\d+(?:\.\d+)?(?:,\d+(?:\.\d+)?)*|true|false)\z/i
|
|
8
|
+
CHARACTERS_CLOSING_QUOTED_TEXT = /["`'\\$\]\r\n\0]/
|
|
9
|
+
LINE_COMMENT = /--[^\n]*/
|
|
10
|
+
MYSQL_LINE_COMMENT = /--(?=[[:space:][:cntrl:]]|\z)[^\n]*/
|
|
11
|
+
BLOCK_COMMENT = %r{/\*.*?(?:\*/|\z)}m
|
|
12
|
+
NESTED_BLOCK_COMMENT = %r{(?<comment>/\*(?:[^/*]|/(?!\*)|\*(?!/)|\g<comment>)*(?:\*/|\z))}m
|
|
13
|
+
BRACKET_QUOTED = /\[[^\]]*\]?/
|
|
14
|
+
WORD = /[[:alpha:]_][[:alnum:]_$]*/
|
|
15
|
+
STRING_CONTINUATION = /[ \t\f\r]*\n[[:space:]]*(?=')/
|
|
16
|
+
DOLLAR_QUOTED = /\$([[:alpha:]_][[:alnum:]_]*|)\$.*?(?:\$\1\$|\z)/m
|
|
17
|
+
|
|
18
|
+
def self.inject(query, context_variables, record_id, connection = ActiveRecord::Base.connection)
|
|
19
|
+
new(context_variables, record_id, connection).inject(query)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def initialize(context_variables, record_id, connection)
|
|
23
|
+
@context_variables = context_variables
|
|
24
|
+
@record_id = record_id
|
|
25
|
+
@connection = connection
|
|
26
|
+
@mysql = connection.adapter_name.match?(/mysql|trilogy/i)
|
|
27
|
+
@postgresql = connection.adapter_name.match?(/postg/i)
|
|
28
|
+
@bracket_quotes = connection.adapter_name.match?(/sqlite|sqlserver/i)
|
|
29
|
+
@nested_comments = @postgresql || connection.adapter_name.match?(/sqlserver/i)
|
|
30
|
+
@backslash_escapes = @mysql && mysql_backslash_escapes?
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def inject(query)
|
|
34
|
+
scanner = StringScanner.new(query)
|
|
35
|
+
injected = +''
|
|
36
|
+
injected << next_token(scanner) until scanner.eos?
|
|
37
|
+
injected
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def next_token(scanner)
|
|
43
|
+
if (comment = scan_comment(scanner))
|
|
44
|
+
comment
|
|
45
|
+
elsif (word = scanner.scan(WORD))
|
|
46
|
+
return word unless @postgresql && word.casecmp?('e') && scanner.peek(1) == "'"
|
|
47
|
+
|
|
48
|
+
word + inject_in_string(scan_escape_string(scanner), backslash_escapes: true, escape_backslashes: true)
|
|
49
|
+
elsif (string = scanner.scan(quoted_pattern("'", backslash_escapes: @backslash_escapes)))
|
|
50
|
+
inject_in_string(string, backslash_escapes: @backslash_escapes, escape_backslashes: false)
|
|
51
|
+
elsif (quoted = scan_quoted_text(scanner))
|
|
52
|
+
inject_in_quoted_text(quoted)
|
|
53
|
+
elsif (placeholder = scanner.scan(PLACEHOLDER))
|
|
54
|
+
inject_in_code(placeholder)
|
|
55
|
+
else
|
|
56
|
+
scanner.getch
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def mysql_backslash_escapes?
|
|
61
|
+
!@connection.select_value('SELECT @@SESSION.sql_mode').to_s.include?('NO_BACKSLASH_ESCAPES')
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def scan_comment(scanner)
|
|
65
|
+
scanner.scan(@mysql ? MYSQL_LINE_COMMENT : LINE_COMMENT) ||
|
|
66
|
+
scanner.scan(@nested_comments ? NESTED_BLOCK_COMMENT : BLOCK_COMMENT) ||
|
|
67
|
+
(@mysql && scanner.scan(/#[^\n]*/))
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def scan_escape_string(scanner)
|
|
71
|
+
escape_string = scanner.scan(quoted_pattern("'", backslash_escapes: true))
|
|
72
|
+
while (continuation = scanner.scan(STRING_CONTINUATION))
|
|
73
|
+
escape_string << continuation << scanner.scan(quoted_pattern("'", backslash_escapes: true))
|
|
74
|
+
end
|
|
75
|
+
escape_string
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def scan_quoted_text(scanner)
|
|
79
|
+
scanner.scan(quoted_pattern('"', backslash_escapes: @backslash_escapes)) ||
|
|
80
|
+
scanner.scan(quoted_pattern('`')) ||
|
|
81
|
+
(@postgresql && scanner.scan(DOLLAR_QUOTED)) ||
|
|
82
|
+
(@bracket_quotes && scanner.scan(BRACKET_QUOTED))
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def quoted_pattern(quote, backslash_escapes: false)
|
|
86
|
+
escaped = Regexp.escape(quote)
|
|
87
|
+
content = backslash_escapes ? "[^#{escaped}\\\\]|\\\\.|#{escaped}#{escaped}" : "[^#{escaped}]|#{escaped}#{escaped}"
|
|
88
|
+
/#{escaped}(?:#{content})*#{escaped}?/m
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def inject_in_code(placeholder)
|
|
92
|
+
replace_placeholders(placeholder) do |text|
|
|
93
|
+
text.match?(VERBATIM_VALUE) ? text : @connection.quote(text)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def inject_in_string(string, backslash_escapes:, escape_backslashes:)
|
|
98
|
+
replace_placeholders(string) do |text, placeholder, preceding_text|
|
|
99
|
+
if backslash_escapes && preceding_text[/\\*\z/].length.odd?
|
|
100
|
+
raise ForestLiana::Errors::LiveQueryError.new(
|
|
101
|
+
"Live Query Chart: #{placeholder} cannot follow a backslash inside a string."
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
escape_backslashes ? text.gsub(/[\\']/) { |character| character * 2 } : @connection.quote_string(text)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def inject_in_quoted_text(quoted)
|
|
109
|
+
replace_placeholders(quoted) do |text, placeholder|
|
|
110
|
+
if text.match?(CHARACTERS_CLOSING_QUOTED_TEXT)
|
|
111
|
+
raise ForestLiana::Errors::LiveQueryError.new(
|
|
112
|
+
"Live Query Chart: The value of #{placeholder} cannot contain quotes, brackets, backslashes, " \
|
|
113
|
+
'dollar signs or line breaks when it is written inside a quoted identifier or a dollar-quoted string.'
|
|
114
|
+
)
|
|
115
|
+
end
|
|
116
|
+
text
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def replace_placeholders(text)
|
|
121
|
+
text.gsub(PLACEHOLDER) do |placeholder|
|
|
122
|
+
context_variable_key = Regexp.last_match(1)
|
|
123
|
+
preceding_text = Regexp.last_match.pre_match
|
|
124
|
+
if context_variable_key
|
|
125
|
+
value = ContextVariablesInjector.context_value(context_variable_key, @context_variables)
|
|
126
|
+
yield(value, placeholder, preceding_text)
|
|
127
|
+
elsif @record_id.nil?
|
|
128
|
+
placeholder
|
|
129
|
+
else
|
|
130
|
+
yield(@record_id.to_s, placeholder, preceding_text)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
data/lib/forest_liana/version.rb
CHANGED
|
@@ -563,6 +563,17 @@ describe 'Requesting Actions routes', :type => :request do
|
|
|
563
563
|
end
|
|
564
564
|
end
|
|
565
565
|
|
|
566
|
+
describe 'with a requester named outside a signed approval request' do
|
|
567
|
+
it 'should respond 422 and NOT perform the action' do
|
|
568
|
+
params[:data][:attributes][:requester_id] = 99
|
|
569
|
+
expect_any_instance_of(Forest::IslandsController).not_to receive(:test)
|
|
570
|
+
|
|
571
|
+
post '/forest/actions/test', params: JSON.dump(params), headers: headers
|
|
572
|
+
|
|
573
|
+
expect(response.status).to eq(422)
|
|
574
|
+
end
|
|
575
|
+
end
|
|
576
|
+
|
|
566
577
|
describe 'with scopes' do
|
|
567
578
|
before(:each) do
|
|
568
579
|
allow_any_instance_of(ForestLiana::Ability).to receive(:forest_authorize!) { true }
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
require 'rails_helper'
|
|
2
|
+
|
|
3
|
+
describe 'Change password action', type: :request do
|
|
4
|
+
let(:rendering_id) { 13 }
|
|
5
|
+
let(:role_id) { 1 }
|
|
6
|
+
let(:trigger_roles) { [role_id] }
|
|
7
|
+
let(:approval_roles) { [] }
|
|
8
|
+
let(:approver_roles) { [] }
|
|
9
|
+
let(:scope_filters) { { 'scopes' => {}, 'team' => { 'id' => '1', 'name' => 'Operations' } } }
|
|
10
|
+
let(:cache) { ActiveSupport::Cache::MemoryStore.new }
|
|
11
|
+
let(:island_collection) { ForestLiana.apimap.find { |collection| collection.name.to_s == 'Island' } }
|
|
12
|
+
let(:change_password_action) do
|
|
13
|
+
ForestLiana::Model::Action.new(
|
|
14
|
+
id: 'Island.Change password',
|
|
15
|
+
name: 'Change password',
|
|
16
|
+
type: 'single',
|
|
17
|
+
fields: [{ field: 'New password', type: 'String' }]
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
let(:token) do
|
|
22
|
+
JWT.encode({
|
|
23
|
+
id: 38,
|
|
24
|
+
email: 'michael.kelso@that70.show',
|
|
25
|
+
first_name: 'Michael',
|
|
26
|
+
last_name: 'Kelso',
|
|
27
|
+
team: 'Operations',
|
|
28
|
+
rendering_id: rendering_id,
|
|
29
|
+
exp: Time.now.to_i + 2.weeks.to_i,
|
|
30
|
+
permission_level: 'user'
|
|
31
|
+
}, ForestLiana.auth_secret, 'HS256')
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
let(:headers) do
|
|
35
|
+
{
|
|
36
|
+
'Accept' => 'application/json',
|
|
37
|
+
'Content-Type' => 'application/json',
|
|
38
|
+
'Authorization' => "Bearer #{token}"
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
let(:attributes) do
|
|
43
|
+
{
|
|
44
|
+
collection_name: 'Island',
|
|
45
|
+
ids: ['1'],
|
|
46
|
+
all_records: false,
|
|
47
|
+
values: { 'New password' => 'n3w-p4ssw0rd' },
|
|
48
|
+
smart_action_id: 'Island-Change password'
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
let(:params) { { data: { attributes: attributes, type: 'custom-action-requests' }, timezone: 'Europe/Paris' } }
|
|
53
|
+
|
|
54
|
+
def environment_permissions
|
|
55
|
+
enabled = { 'roles' => [role_id] }
|
|
56
|
+
crud = %w[browseEnabled readEnabled editEnabled addEnabled deleteEnabled exportEnabled].to_h { |key| [key, enabled] }
|
|
57
|
+
change_password = {
|
|
58
|
+
'triggerEnabled' => { 'roles' => trigger_roles },
|
|
59
|
+
'triggerConditions' => [],
|
|
60
|
+
'approvalRequired' => { 'roles' => approval_roles },
|
|
61
|
+
'approvalRequiredConditions' => [],
|
|
62
|
+
'userApprovalEnabled' => { 'roles' => approver_roles },
|
|
63
|
+
'userApprovalConditions' => [],
|
|
64
|
+
'selfApprovalEnabled' => { 'roles' => [] }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
{ 'collections' => { 'Island' => { 'collection' => crud, 'actions' => { 'Change password' => change_password } } } }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
before(:all) { Island.send(:attr_accessor, :password) }
|
|
71
|
+
after(:all) { Island.send(:remove_method, :password, :password=) }
|
|
72
|
+
|
|
73
|
+
before do
|
|
74
|
+
island_collection.actions << change_password_action
|
|
75
|
+
Island.create!(id: 1, name: 'Corsica')
|
|
76
|
+
|
|
77
|
+
allow(Rails).to receive(:cache).and_return(cache)
|
|
78
|
+
allow_any_instance_of(ForestLiana::Ability::Fetch).to receive(:get_permissions) do |_, path|
|
|
79
|
+
case path
|
|
80
|
+
when '/liana/v4/permissions/environment' then environment_permissions
|
|
81
|
+
when '/liana/v4/permissions/users' then [{ 'id' => 38, 'roleId' => role_id }]
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
ForestLiana::ScopeManager.invalidate_scope_cache(rendering_id)
|
|
86
|
+
allow(ForestLiana::ScopeManager).to receive(:fetch_scopes).and_return(scope_filters)
|
|
87
|
+
allow(ForestLiana::IpWhitelist).to receive(:is_ip_whitelist_retrieved) { true }
|
|
88
|
+
allow(ForestLiana::IpWhitelist).to receive(:is_ip_valid) { true }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
after do
|
|
92
|
+
island_collection.actions.delete(change_password_action)
|
|
93
|
+
Island.destroy_all
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def change_password
|
|
97
|
+
post '/forest/actions/change-password', params: JSON.dump(params), headers: headers
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
context 'when the user is allowed to trigger the action on the collection' do
|
|
101
|
+
it 'responds 204 and sets the new password on the targeted record' do
|
|
102
|
+
expect_any_instance_of(Island).to receive(:password=).with('n3w-p4ssw0rd').and_call_original
|
|
103
|
+
|
|
104
|
+
change_password
|
|
105
|
+
|
|
106
|
+
expect(response.status).to eq(204)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
context 'when the user scope excludes the targeted record at lookup' do
|
|
111
|
+
it 'resolves the record within the user scope and leaves the password unchanged' do
|
|
112
|
+
expect(ForestLiana::ScopeManager).to receive(:apply_scopes_on_records)
|
|
113
|
+
.with(Island, hash_including('id' => 38, 'rendering_id' => rendering_id), 'Island', 'Europe/Paris')
|
|
114
|
+
.and_return(Island.none)
|
|
115
|
+
expect_any_instance_of(Island).not_to receive(:password=)
|
|
116
|
+
|
|
117
|
+
change_password
|
|
118
|
+
|
|
119
|
+
expect(response.status).to eq(400)
|
|
120
|
+
expect(JSON.parse(response.body)).to eq('error' => 'Smart Action: target record not found')
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
context 'when the user is not allowed to trigger the action on the collection' do
|
|
125
|
+
let(:trigger_roles) { [] }
|
|
126
|
+
|
|
127
|
+
it 'responds 403 and leaves the password unchanged' do
|
|
128
|
+
expect_any_instance_of(Island).not_to receive(:password=)
|
|
129
|
+
|
|
130
|
+
change_password
|
|
131
|
+
|
|
132
|
+
expect(response.status).to eq(403)
|
|
133
|
+
expect(JSON.parse(response.body)['errors'].first).to include(
|
|
134
|
+
'name' => 'CustomActionTriggerForbiddenError',
|
|
135
|
+
'detail' => "You don't have the permission to trigger this action"
|
|
136
|
+
)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
context 'when the user role must have the action approved' do
|
|
141
|
+
let(:approval_roles) { [role_id] }
|
|
142
|
+
|
|
143
|
+
it 'responds 403 asking for approval and leaves the password unchanged' do
|
|
144
|
+
expect_any_instance_of(Island).not_to receive(:password=)
|
|
145
|
+
|
|
146
|
+
change_password
|
|
147
|
+
|
|
148
|
+
expect(response.status).to eq(403)
|
|
149
|
+
expect(JSON.parse(response.body)['errors'].first).to include(
|
|
150
|
+
'name' => 'CustomActionRequiresApprovalError',
|
|
151
|
+
'detail' => 'This action requires to be approved.'
|
|
152
|
+
)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
context 'when the request carries no smart action id' do
|
|
157
|
+
let(:attributes) { super().except(:smart_action_id) }
|
|
158
|
+
|
|
159
|
+
it 'responds 400 and leaves the password unchanged' do
|
|
160
|
+
expect_any_instance_of(Island).not_to receive(:password=)
|
|
161
|
+
|
|
162
|
+
change_password
|
|
163
|
+
|
|
164
|
+
expect(response.status).to eq(400)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
context 'when the targeted record is outside the user scope' do
|
|
169
|
+
let(:scope_filters) do
|
|
170
|
+
{
|
|
171
|
+
'scopes' => {
|
|
172
|
+
'Island' => {
|
|
173
|
+
'aggregator' => 'and',
|
|
174
|
+
'conditions' => [{ 'field' => 'name', 'operator' => 'equal', 'value' => 'Ré' }]
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
'team' => { 'id' => 43, 'name' => 'Operations' }
|
|
178
|
+
}
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
it 'responds 400 and leaves the password unchanged' do
|
|
182
|
+
expect_any_instance_of(Island).not_to receive(:password=)
|
|
183
|
+
|
|
184
|
+
change_password
|
|
185
|
+
|
|
186
|
+
expect(response.status).to eq(400)
|
|
187
|
+
expect(JSON.parse(response.body)).to eq('error' => 'Smart Action: target record not found')
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
context 'when the request selects all records' do
|
|
192
|
+
let(:attributes) { super().merge(all_records: true, all_records_ids_excluded: []) }
|
|
193
|
+
|
|
194
|
+
it 'responds 422 and leaves the password unchanged' do
|
|
195
|
+
expect_any_instance_of(Island).not_to receive(:password=)
|
|
196
|
+
|
|
197
|
+
change_password
|
|
198
|
+
|
|
199
|
+
expect(response.status).to eq(422)
|
|
200
|
+
expect(JSON.parse(response.body)['errors'].first).to include('detail' => ForestLiana::SingleRecordSelection::MESSAGE)
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
context 'when the request targets several records' do
|
|
205
|
+
let(:attributes) { super().merge(ids: %w[1 2]) }
|
|
206
|
+
|
|
207
|
+
it 'responds 422 and leaves the password unchanged' do
|
|
208
|
+
expect_any_instance_of(Island).not_to receive(:password=)
|
|
209
|
+
|
|
210
|
+
change_password
|
|
211
|
+
|
|
212
|
+
expect(response.status).to eq(422)
|
|
213
|
+
expect(JSON.parse(response.body)['errors'].first).to include('detail' => ForestLiana::SingleRecordSelection::MESSAGE)
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
context 'when the request names a requester outside a signed approval request' do
|
|
218
|
+
let(:attributes) { super().merge(requester_id: 99) }
|
|
219
|
+
|
|
220
|
+
it 'responds 422 and leaves the password unchanged' do
|
|
221
|
+
expect_any_instance_of(Island).not_to receive(:password=)
|
|
222
|
+
|
|
223
|
+
change_password
|
|
224
|
+
|
|
225
|
+
expect(response.status).to eq(422)
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
context 'when an approver executes a signed approval request' do
|
|
230
|
+
let(:approval_roles) { [role_id] }
|
|
231
|
+
let(:approver_roles) { [role_id] }
|
|
232
|
+
let(:approved_ids) { ['1'] }
|
|
233
|
+
let(:signed_approval_request) do
|
|
234
|
+
JWT.encode(
|
|
235
|
+
{ data: { attributes: attributes.merge(ids: approved_ids, requester_id: 99) } },
|
|
236
|
+
ForestLiana.env_secret,
|
|
237
|
+
'HS256'
|
|
238
|
+
)
|
|
239
|
+
end
|
|
240
|
+
let(:params) do
|
|
241
|
+
{
|
|
242
|
+
data: {
|
|
243
|
+
attributes: attributes.except(:values).merge(signed_approval_request: signed_approval_request),
|
|
244
|
+
type: 'custom-action-requests'
|
|
245
|
+
},
|
|
246
|
+
timezone: 'Europe/Paris'
|
|
247
|
+
}
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
it 'responds 204 and sets the approved password on the targeted record' do
|
|
251
|
+
expect_any_instance_of(Island).to receive(:password=).with('n3w-p4ssw0rd').and_call_original
|
|
252
|
+
|
|
253
|
+
change_password
|
|
254
|
+
|
|
255
|
+
expect(response.status).to eq(204)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
context 'and the approved request targets several records' do
|
|
259
|
+
let(:approved_ids) { %w[1 2] }
|
|
260
|
+
|
|
261
|
+
it 'responds 422 and leaves the password unchanged' do
|
|
262
|
+
expect_any_instance_of(Island).not_to receive(:password=)
|
|
263
|
+
|
|
264
|
+
change_password
|
|
265
|
+
|
|
266
|
+
expect(response.status).to eq(422)
|
|
267
|
+
expect(JSON.parse(response.body)['errors'].first).to include('detail' => ForestLiana::SingleRecordSelection::MESSAGE)
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
module ForestLiana
|
|
2
|
+
describe QueryStatGetter do
|
|
3
|
+
let!(:aragorn) { User.create!(name: 'Aragorn') }
|
|
4
|
+
let!(:obrien) { User.create!(name: "O'Brien") }
|
|
5
|
+
let!(:sam) { User.create!(name: 'Sam') }
|
|
6
|
+
|
|
7
|
+
def value_of(query, record_id: nil, context_variables: nil)
|
|
8
|
+
params = { 'type' => 'Value', 'query' => query }
|
|
9
|
+
params['record_id'] = record_id unless record_id.nil?
|
|
10
|
+
params['contextVariables'] = context_variables unless context_variables.nil?
|
|
11
|
+
|
|
12
|
+
getter = QueryStatGetter.new(params)
|
|
13
|
+
getter.perform
|
|
14
|
+
getter.record.value[:countCurrent]
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
describe 'record id' do
|
|
18
|
+
it 'matches the record whose integer id replaces ?' do
|
|
19
|
+
expect(value_of('SELECT COUNT(*) AS value FROM users WHERE id = ?', record_id: aragorn.id)).to eq(1)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
it 'matches the record whose integer id is sent as a string' do
|
|
23
|
+
expect(value_of('SELECT COUNT(*) AS value FROM users WHERE id = ?', record_id: aragorn.id.to_s)).to eq(1)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
it 'matches a string id written inside quotes in the query' do
|
|
27
|
+
expect(value_of("SELECT COUNT(*) AS value FROM users WHERE name = '?'", record_id: 'Aragorn')).to eq(1)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it 'matches a string id that contains a quote' do
|
|
31
|
+
expect(value_of("SELECT COUNT(*) AS value FROM users WHERE name = '?'", record_id: "O'Brien")).to eq(1)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
it 'matches a string id written without quotes in the query' do
|
|
35
|
+
expect(value_of('SELECT COUNT(*) AS value FROM users WHERE name = ?', record_id: 'Aragorn')).to eq(1)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it 'matches a UUID id written without quotes in the query' do
|
|
39
|
+
uuid = '3f2504e0-4f89-11d3-9a0c-0305e82c3301'
|
|
40
|
+
User.create!(name: uuid)
|
|
41
|
+
|
|
42
|
+
expect(value_of('SELECT COUNT(*) AS value FROM users WHERE name = ?', record_id: uuid)).to eq(1)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
it 'compares a non-numeric record id as a single value' do
|
|
46
|
+
expect(value_of('SELECT COUNT(*) AS value FROM users WHERE id = ?', record_id: '0 OR 1=1')).to eq(0)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
it 'keeps a record id containing quotes inside its string literal' do
|
|
50
|
+
expect(
|
|
51
|
+
value_of("SELECT COUNT(*) AS value FROM users WHERE name = '?'", record_id: "x' OR '1'='1")
|
|
52
|
+
).to eq(0)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it 'compares a record id holding a semicolon as a single value' do
|
|
56
|
+
expect(value_of('SELECT COUNT(*) AS value FROM users WHERE id = ?', record_id: '1; DELETE FROM users')).to eq(0)
|
|
57
|
+
expect(User.count).to eq(3)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it 'matches a string id holding a semicolon' do
|
|
61
|
+
User.create!(name: 'a;b')
|
|
62
|
+
|
|
63
|
+
expect(value_of("SELECT COUNT(*) AS value FROM users WHERE name = '?'", record_id: 'a;b')).to eq(1)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
it 'refuses a query that chains statements before any value is injected' do
|
|
67
|
+
expect {
|
|
68
|
+
value_of('SELECT COUNT(*) AS value FROM users WHERE id = ?; DELETE FROM users', record_id: 1)
|
|
69
|
+
}.to raise_error(ForestLiana::Errors::LiveQueryError, /You cannot chain SQL/)
|
|
70
|
+
expect(User.count).to eq(3)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
it 'leaves ? untouched when no record id is sent' do
|
|
74
|
+
expect(value_of("SELECT COUNT(*) AS value FROM users WHERE name <> '?'")).to eq(3)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
describe 'context variables' do
|
|
79
|
+
it 'matches the record whose integer id is injected' do
|
|
80
|
+
expect(
|
|
81
|
+
value_of(
|
|
82
|
+
'SELECT COUNT(*) AS value FROM users WHERE id = {{users.selectedRecord.id}}',
|
|
83
|
+
context_variables: { 'users.selectedRecord.id' => aragorn.id.to_s }
|
|
84
|
+
)
|
|
85
|
+
).to eq(1)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
it 'matches every id of a comma-separated list of integers' do
|
|
89
|
+
expect(
|
|
90
|
+
value_of(
|
|
91
|
+
'SELECT COUNT(*) AS value FROM users WHERE id IN ({{users.selectedRecords.id}})',
|
|
92
|
+
context_variables: { 'users.selectedRecords.id' => "#{aragorn.id},#{sam.id}" }
|
|
93
|
+
)
|
|
94
|
+
).to eq(2)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
it 'matches a string written inside quotes in the query' do
|
|
98
|
+
expect(
|
|
99
|
+
value_of(
|
|
100
|
+
"SELECT COUNT(*) AS value FROM users WHERE name = '{{dropdown.selectedValue}}'",
|
|
101
|
+
context_variables: { 'dropdown.selectedValue' => 'Aragorn' }
|
|
102
|
+
)
|
|
103
|
+
).to eq(1)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
it 'matches a string that contains a quote' do
|
|
107
|
+
expect(
|
|
108
|
+
value_of(
|
|
109
|
+
"SELECT COUNT(*) AS value FROM users WHERE name = '{{dropdown.selectedValue}}'",
|
|
110
|
+
context_variables: { 'dropdown.selectedValue' => "O'Brien" }
|
|
111
|
+
)
|
|
112
|
+
).to eq(1)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
it 'matches a string written without quotes in the query' do
|
|
116
|
+
expect(
|
|
117
|
+
value_of(
|
|
118
|
+
'SELECT COUNT(*) AS value FROM users WHERE name = {{dropdown.selectedValue}}',
|
|
119
|
+
context_variables: { 'dropdown.selectedValue' => 'Aragorn' }
|
|
120
|
+
)
|
|
121
|
+
).to eq(1)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
it 'matches a string injected in the middle of a string literal' do
|
|
125
|
+
expect(
|
|
126
|
+
value_of(
|
|
127
|
+
"SELECT COUNT(*) AS value FROM users WHERE name LIKE '%{{search.value}}%'",
|
|
128
|
+
context_variables: { 'search.value' => 'rag' }
|
|
129
|
+
)
|
|
130
|
+
).to eq(1)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
it 'injects a boolean as a boolean' do
|
|
134
|
+
expect(
|
|
135
|
+
value_of(
|
|
136
|
+
'SELECT COUNT(*) AS value FROM users WHERE {{toggle.value}}',
|
|
137
|
+
context_variables: { 'toggle.value' => 'true' }
|
|
138
|
+
)
|
|
139
|
+
).to eq(3)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
it 'injects the same variable everywhere it appears' do
|
|
143
|
+
expect(
|
|
144
|
+
value_of(
|
|
145
|
+
"SELECT COUNT(*) AS value FROM users WHERE id = {{users.selectedRecord.id}} OR name = '{{users.selectedRecord.id}}'",
|
|
146
|
+
context_variables: { 'users.selectedRecord.id' => sam.id.to_s }
|
|
147
|
+
)
|
|
148
|
+
).to eq(1)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
it 'compares a non-numeric value as a single value' do
|
|
152
|
+
expect(
|
|
153
|
+
value_of(
|
|
154
|
+
'SELECT COUNT(*) AS value FROM users WHERE id = {{users.selectedRecord.id}}',
|
|
155
|
+
context_variables: { 'users.selectedRecord.id' => '0 OR 1=1' }
|
|
156
|
+
)
|
|
157
|
+
).to eq(0)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
it 'compares a value holding a second SELECT as a single value' do
|
|
161
|
+
expect(
|
|
162
|
+
value_of(
|
|
163
|
+
'SELECT COUNT(*) AS value FROM users WHERE id = {{users.selectedRecord.id}}',
|
|
164
|
+
context_variables: { 'users.selectedRecord.id' => '0 UNION SELECT 99 ORDER BY 1 DESC' }
|
|
165
|
+
)
|
|
166
|
+
).to eq(0)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
it 'keeps a value containing quotes inside its string literal' do
|
|
170
|
+
expect(
|
|
171
|
+
value_of(
|
|
172
|
+
"SELECT COUNT(*) AS value FROM users WHERE name = '{{dropdown.selectedValue}}'",
|
|
173
|
+
context_variables: { 'dropdown.selectedValue' => "x' OR '1'='1" }
|
|
174
|
+
)
|
|
175
|
+
).to eq(0)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
it 'keeps a value containing quotes inside the string literal it is injected into' do
|
|
179
|
+
expect(
|
|
180
|
+
value_of(
|
|
181
|
+
"SELECT COUNT(*) AS value FROM users WHERE name LIKE '%{{search.value}}%'",
|
|
182
|
+
context_variables: { 'search.value' => "%' OR '1'='1" }
|
|
183
|
+
)
|
|
184
|
+
).to eq(0)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
it 'leaves variables written in a comment untouched' do
|
|
188
|
+
expect(
|
|
189
|
+
value_of(
|
|
190
|
+
"SELECT COUNT(*) AS value FROM users -- {{note.value}}\nWHERE id = {{users.selectedRecord.id}}",
|
|
191
|
+
context_variables: { 'note.value' => "\nOR 1=1", 'users.selectedRecord.id' => aragorn.id.to_s }
|
|
192
|
+
)
|
|
193
|
+
).to eq(1)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
it 'injects a plain value inside a quoted identifier' do
|
|
197
|
+
expect(
|
|
198
|
+
value_of(
|
|
199
|
+
'SELECT COUNT(*) AS value FROM "{{dropdown.selectedValue}}"',
|
|
200
|
+
context_variables: { 'dropdown.selectedValue' => 'users' }
|
|
201
|
+
)
|
|
202
|
+
).to eq(3)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
it 'refuses a value that would end the quoted identifier it is injected into' do
|
|
206
|
+
expect {
|
|
207
|
+
value_of(
|
|
208
|
+
'SELECT COUNT(*) AS value FROM "{{dropdown.selectedValue}}"',
|
|
209
|
+
context_variables: { 'dropdown.selectedValue' => 'users" WHERE 1=0 OR "1' }
|
|
210
|
+
)
|
|
211
|
+
}.to raise_error(ForestLiana::Errors::LiveQueryError)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
it 'does not inject a variable or the record id found inside an injected value' do
|
|
215
|
+
expect(
|
|
216
|
+
value_of(
|
|
217
|
+
"SELECT COUNT(*) AS value FROM users WHERE name = '{{dropdown.selectedValue}}' OR id = ?",
|
|
218
|
+
record_id: aragorn.id,
|
|
219
|
+
context_variables: { 'dropdown.selectedValue' => '{{other.value}}?' }
|
|
220
|
+
)
|
|
221
|
+
).to eq(1)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
it 'matches a string holding a semicolon' do
|
|
225
|
+
User.create!(name: 'a;b')
|
|
226
|
+
|
|
227
|
+
expect(
|
|
228
|
+
value_of(
|
|
229
|
+
"SELECT COUNT(*) AS value FROM users WHERE name = '{{dropdown.selectedValue}}'",
|
|
230
|
+
context_variables: { 'dropdown.selectedValue' => 'a;b' }
|
|
231
|
+
)
|
|
232
|
+
).to eq(1)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
it 'compares a value holding a semicolon and a second statement as a single value' do
|
|
236
|
+
expect(
|
|
237
|
+
value_of(
|
|
238
|
+
'SELECT COUNT(*) AS value FROM users WHERE name = {{dropdown.selectedValue}}',
|
|
239
|
+
context_variables: { 'dropdown.selectedValue' => 'Sam; DELETE FROM users' }
|
|
240
|
+
)
|
|
241
|
+
).to eq(0)
|
|
242
|
+
expect(User.count).to eq(3)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
it 'raises on a variable the request does not provide' do
|
|
246
|
+
expect {
|
|
247
|
+
value_of(
|
|
248
|
+
'SELECT COUNT(*) AS value FROM users WHERE id = {{users.selectedRecord.id}}',
|
|
249
|
+
context_variables: {}
|
|
250
|
+
)
|
|
251
|
+
}.to raise_error(/Unknown context variable: 'users\.selectedRecord\.id'/)
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
describe 'with both a record id and context variables' do
|
|
256
|
+
it 'injects both' do
|
|
257
|
+
expect(
|
|
258
|
+
value_of(
|
|
259
|
+
"SELECT COUNT(*) AS value FROM users WHERE id = ? OR name = '{{dropdown.selectedValue}}'",
|
|
260
|
+
record_id: aragorn.id,
|
|
261
|
+
context_variables: { 'dropdown.selectedValue' => 'Sam' }
|
|
262
|
+
)
|
|
263
|
+
).to eq(2)
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|
|
@@ -122,6 +122,32 @@ module ForestLiana
|
|
|
122
122
|
expect(uri_regex_validation[:value].match('\n')).to eq(nil)
|
|
123
123
|
end
|
|
124
124
|
end
|
|
125
|
+
|
|
126
|
+
context 'with a Devise model' do
|
|
127
|
+
let(:devise_model) do
|
|
128
|
+
stub_const('DeviseAccount', Class.new(ActiveRecord::Base) do
|
|
129
|
+
self.table_name = 'isle'
|
|
130
|
+
|
|
131
|
+
def self.devise_modules?
|
|
132
|
+
true
|
|
133
|
+
end
|
|
134
|
+
end)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
after { ForestLiana.apimap.delete_if { |collection| collection.name.to_s == 'DeviseAccount' } }
|
|
138
|
+
|
|
139
|
+
it 'declares Change password as a single-record action' do
|
|
140
|
+
collection = described_class.new(devise_model).perform
|
|
141
|
+
action = collection.actions.find { |candidate| candidate.name == 'Change password' }
|
|
142
|
+
|
|
143
|
+
expect(action).to have_attributes(
|
|
144
|
+
id: 'DeviseAccount.Change password',
|
|
145
|
+
type: 'single',
|
|
146
|
+
endpoint: 'forest/actions/change-password',
|
|
147
|
+
http_method: 'POST'
|
|
148
|
+
)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
125
151
|
end
|
|
126
152
|
end
|
|
127
153
|
end
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
module ForestLiana
|
|
2
|
+
module Utils
|
|
3
|
+
describe LiveQueryInjector do
|
|
4
|
+
let(:context_variables) { ContextVariables.new(nil, nil, request_context_variables) }
|
|
5
|
+
let(:request_context_variables) { {} }
|
|
6
|
+
|
|
7
|
+
def mysql_connection(sql_mode: 'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION')
|
|
8
|
+
connection = double('mysql connection', adapter_name: 'Mysql2')
|
|
9
|
+
allow(connection).to receive(:select_value).with('SELECT @@SESSION.sql_mode').and_return(sql_mode)
|
|
10
|
+
if sql_mode.include?('NO_BACKSLASH_ESCAPES')
|
|
11
|
+
allow(connection).to receive(:quote_string) { |value| value.gsub("'", "''") }
|
|
12
|
+
else
|
|
13
|
+
allow(connection).to receive(:quote_string) { |value| value.gsub(/[\\'"]/) { |char| "\\#{char}" } }
|
|
14
|
+
end
|
|
15
|
+
allow(connection).to receive(:quote) { |value| "'#{connection.quote_string(value)}'" }
|
|
16
|
+
connection
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def sqlserver_connection
|
|
20
|
+
connection = double('sqlserver connection', adapter_name: 'SQLServer')
|
|
21
|
+
allow(connection).to receive(:quote_string) { |value| value.gsub("'", "''") }
|
|
22
|
+
allow(connection).to receive(:quote) { |value| "'#{connection.quote_string(value)}'" }
|
|
23
|
+
connection
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def postgresql_connection
|
|
27
|
+
connection = double('postgresql connection', adapter_name: 'PostgreSQL')
|
|
28
|
+
allow(connection).to receive(:quote_string) { |value| value.gsub("'", "''") }
|
|
29
|
+
allow(connection).to receive(:quote) { |value| "'#{connection.quote_string(value)}'" }
|
|
30
|
+
connection
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def inject(query, connection, record_id: nil)
|
|
34
|
+
LiveQueryInjector.inject(query, context_variables, record_id, connection)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
describe 'on MySQL' do
|
|
38
|
+
let(:request_context_variables) { { 'x.value' => '0 OR 1=1' } }
|
|
39
|
+
|
|
40
|
+
it 'reads a backslash-escaped quote as part of the string literal' do
|
|
41
|
+
expect(inject("SELECT 1 FROM t WHERE a = 'it\\'s' AND id = {{x.value}}", mysql_connection))
|
|
42
|
+
.to eq("SELECT 1 FROM t WHERE a = 'it\\'s' AND id = '0 OR 1=1'")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
it 'escapes a value injected inside a string literal with the connection' do
|
|
46
|
+
request_context_variables['x.value'] = "a\\' OR 1=1 -- "
|
|
47
|
+
|
|
48
|
+
expect(inject("SELECT 1 FROM t WHERE a LIKE '%{{x.value}}%'", mysql_connection))
|
|
49
|
+
.to eq("SELECT 1 FROM t WHERE a LIKE '%a\\\\\\' OR 1=1 -- %'")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
it 'injects a plain value inside a double-quoted string' do
|
|
53
|
+
request_context_variables['x.value'] = 'Aragorn'
|
|
54
|
+
|
|
55
|
+
expect(inject('SELECT 1 FROM t WHERE a = "{{x.value}}"', mysql_connection))
|
|
56
|
+
.to eq('SELECT 1 FROM t WHERE a = "Aragorn"')
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
it 'refuses a value holding a quote inside a double-quoted string' do
|
|
60
|
+
request_context_variables['x.value'] = 'a" OR "1'
|
|
61
|
+
|
|
62
|
+
expect { inject('SELECT 1 FROM t WHERE a = "{{x.value}}"', mysql_connection) }
|
|
63
|
+
.to raise_error(ForestLiana::Errors::LiveQueryError)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
it 'refuses a value placed right after a backslash inside a string literal' do
|
|
67
|
+
request_context_variables['x.value'] = "' OR true -- "
|
|
68
|
+
|
|
69
|
+
expect { inject("SELECT 1 FROM t WHERE a = '\\{{x.value}}'", mysql_connection) }
|
|
70
|
+
.to raise_error(ForestLiana::Errors::LiveQueryError)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
it 'refuses a record id placed right after a backslash inside a string literal' do
|
|
74
|
+
expect { inject("SELECT 1 FROM t WHERE a = '\\?'", mysql_connection, record_id: "' OR true -- ") }
|
|
75
|
+
.to raise_error(ForestLiana::Errors::LiveQueryError)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
it 'reads two dashes not followed by a space as code' do
|
|
79
|
+
request_context_variables['x.value'] = '5'
|
|
80
|
+
|
|
81
|
+
expect(inject('SELECT 1--{{x.value}} AS value FROM t', mysql_connection))
|
|
82
|
+
.to eq('SELECT 1--5 AS value FROM t')
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
it 'leaves a dash-dash-space comment untouched' do
|
|
86
|
+
expect(inject("SELECT 1 FROM t -- {{x.value}} ?\nWHERE id = ?", mysql_connection, record_id: 7))
|
|
87
|
+
.to eq("SELECT 1 FROM t -- {{x.value}} ?\nWHERE id = 7")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
it 'ends a dash-dash comment at the line break right after it' do
|
|
91
|
+
expect(inject("SELECT 1 FROM t --\nWHERE id = ?", mysql_connection, record_id: 7))
|
|
92
|
+
.to eq("SELECT 1 FROM t --\nWHERE id = 7")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
it 'injects a plain value inside a backtick-quoted identifier' do
|
|
96
|
+
request_context_variables['x.value'] = 'name'
|
|
97
|
+
|
|
98
|
+
expect(inject('SELECT `{{x.value}}` AS value FROM t', mysql_connection))
|
|
99
|
+
.to eq('SELECT `name` AS value FROM t')
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
it 'refuses a value holding a backtick inside a backtick-quoted identifier' do
|
|
103
|
+
request_context_variables['x.value'] = 'name` FROM t WHERE 1=0 OR `1'
|
|
104
|
+
|
|
105
|
+
expect { inject('SELECT `{{x.value}}` AS value FROM t', mysql_connection) }
|
|
106
|
+
.to raise_error(ForestLiana::Errors::LiveQueryError)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
describe 'when the session does not treat backslashes as escapes' do
|
|
110
|
+
let(:connection) { mysql_connection(sql_mode: 'STRICT_TRANS_TABLES,NO_BACKSLASH_ESCAPES') }
|
|
111
|
+
|
|
112
|
+
it 'reads a backslash as a plain character of a string literal' do
|
|
113
|
+
expect(inject("SELECT 1 FROM t WHERE a = 'x\\' AND id = {{x.value}}", connection))
|
|
114
|
+
.to eq("SELECT 1 FROM t WHERE a = 'x\\' AND id = '0 OR 1=1'")
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
it 'injects a value placed right after a backslash inside a string literal' do
|
|
118
|
+
request_context_variables['x.value'] = "a'b"
|
|
119
|
+
|
|
120
|
+
expect(inject("SELECT 1 FROM t WHERE a = '\\{{x.value}}'", connection))
|
|
121
|
+
.to eq("SELECT 1 FROM t WHERE a = '\\a''b'")
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
it 'leaves a hash comment untouched' do
|
|
126
|
+
expect(inject("SELECT 1 FROM t # {{x.value}} ?\nWHERE id = ?", mysql_connection, record_id: 7))
|
|
127
|
+
.to eq("SELECT 1 FROM t # {{x.value}} ?\nWHERE id = 7")
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
describe 'on PostgreSQL' do
|
|
132
|
+
let(:request_context_variables) { { 'x.value' => '0 OR 1=1' } }
|
|
133
|
+
|
|
134
|
+
it 'reads a backslash as a plain character of a standard string literal' do
|
|
135
|
+
expect(inject("SELECT 1 FROM t WHERE a = 'a\\' AND id = {{x.value}}", postgresql_connection))
|
|
136
|
+
.to eq("SELECT 1 FROM t WHERE a = 'a\\' AND id = '0 OR 1=1'")
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
it 'escapes quotes and backslashes of a value injected inside an escape string' do
|
|
140
|
+
request_context_variables['x.value'] = "\\' OR 1=1 --"
|
|
141
|
+
|
|
142
|
+
expect(inject("SELECT 1 FROM t WHERE a = E'{{x.value}}'", postgresql_connection))
|
|
143
|
+
.to eq("SELECT 1 FROM t WHERE a = E'\\\\'' OR 1=1 --'")
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
it 'leaves the hash operator outside comments' do
|
|
147
|
+
expect(inject('SELECT 1 # {{x.value}} AS value FROM t', postgresql_connection))
|
|
148
|
+
.to eq("SELECT 1 # '0 OR 1=1' AS value FROM t")
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
it 'refuses a value placed right after a backslash inside an escape string' do
|
|
152
|
+
request_context_variables['x.value'] = "' OR true -- "
|
|
153
|
+
|
|
154
|
+
expect { inject("SELECT 1 FROM t WHERE a = E'\\{{x.value}}'", postgresql_connection) }
|
|
155
|
+
.to raise_error(ForestLiana::Errors::LiveQueryError)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
it 'refuses a value right after a backslash in the continuation of an escape string' do
|
|
159
|
+
request_context_variables['x.value'] = "' OR true -- "
|
|
160
|
+
|
|
161
|
+
expect { inject("SELECT 1 FROM t WHERE a = E'a'\n '\\{{x.value}}'", postgresql_connection) }
|
|
162
|
+
.to raise_error(ForestLiana::Errors::LiveQueryError)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
it 'escapes backslashes of a value injected in the continuation of an escape string' do
|
|
166
|
+
request_context_variables['x.value'] = "a\\b"
|
|
167
|
+
|
|
168
|
+
expect(inject("SELECT 1 FROM t WHERE a = E'a'\n'{{x.value}}'", postgresql_connection))
|
|
169
|
+
.to eq("SELECT 1 FROM t WHERE a = E'a'\n'a\\\\b'")
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
it 'injects a value placed right after an escaped backslash inside an escape string' do
|
|
173
|
+
request_context_variables['x.value'] = "a'b"
|
|
174
|
+
|
|
175
|
+
expect(inject("SELECT 1 FROM t WHERE a = E'\\\\{{x.value}}'", postgresql_connection))
|
|
176
|
+
.to eq("SELECT 1 FROM t WHERE a = E'\\\\a''b'")
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
it 'quotes a value placed after an untagged dollar-quoted string' do
|
|
180
|
+
expect(inject('SELECT $$a$$ AS value FROM t WHERE id = {{x.value}}', postgresql_connection))
|
|
181
|
+
.to eq("SELECT $$a$$ AS value FROM t WHERE id = '0 OR 1=1'")
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
it 'injects a plain value inside a dollar-quoted string' do
|
|
185
|
+
request_context_variables['x.value'] = 'Aragorn'
|
|
186
|
+
|
|
187
|
+
expect(inject('SELECT 1 FROM t WHERE a = $tag${{x.value}}$tag$', postgresql_connection))
|
|
188
|
+
.to eq('SELECT 1 FROM t WHERE a = $tag$Aragorn$tag$')
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
it 'refuses a value holding a dollar sign inside a dollar-quoted string' do
|
|
192
|
+
request_context_variables['x.value'] = '$tag$ OR 1=1 --'
|
|
193
|
+
|
|
194
|
+
expect { inject('SELECT 1 FROM t WHERE a = $tag${{x.value}}$tag$', postgresql_connection) }
|
|
195
|
+
.to raise_error(ForestLiana::Errors::LiveQueryError)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
it 'leaves a nested block comment untouched' do
|
|
199
|
+
expect(inject('SELECT 1 /* a /* b */ {{x.value}} */ FROM t WHERE id = {{x.value}}', postgresql_connection))
|
|
200
|
+
.to eq("SELECT 1 /* a /* b */ {{x.value}} */ FROM t WHERE id = '0 OR 1=1'")
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
it 'leaves a block comment untouched' do
|
|
204
|
+
expect(inject('SELECT 1 /* {{x.value}} ? */ FROM t WHERE id = ?', postgresql_connection, record_id: 'abc'))
|
|
205
|
+
.to eq("SELECT 1 /* {{x.value}} ? */ FROM t WHERE id = 'abc'")
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
describe 'on SQLite' do
|
|
210
|
+
let(:request_context_variables) { { 'x.value' => 'users' } }
|
|
211
|
+
|
|
212
|
+
def sqlite_connection
|
|
213
|
+
connection = double('sqlite connection', adapter_name: 'SQLite')
|
|
214
|
+
allow(connection).to receive(:quote_string) { |value| value.gsub("'", "''") }
|
|
215
|
+
allow(connection).to receive(:quote) { |value| "'#{connection.quote_string(value)}'" }
|
|
216
|
+
connection
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
it 'injects a plain value inside a bracket-quoted identifier' do
|
|
220
|
+
expect(inject('SELECT COUNT(*) AS value FROM [{{x.value}}]', sqlite_connection))
|
|
221
|
+
.to eq('SELECT COUNT(*) AS value FROM [users]')
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
it 'refuses a value holding a closing bracket inside a bracket-quoted identifier' do
|
|
225
|
+
request_context_variables['x.value'] = 'users] WHERE 1=0 OR [1'
|
|
226
|
+
|
|
227
|
+
expect { inject('SELECT COUNT(*) AS value FROM [{{x.value}}]', sqlite_connection) }
|
|
228
|
+
.to raise_error(ForestLiana::Errors::LiveQueryError)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
it 'reads a nested block comment marker as plain comment text' do
|
|
232
|
+
request_context_variables['x.value'] = '0 OR 1=1'
|
|
233
|
+
|
|
234
|
+
expect(inject('SELECT 1 /* a /* b */ FROM t WHERE id = {{x.value}}', sqlite_connection))
|
|
235
|
+
.to eq("SELECT 1 /* a /* b */ FROM t WHERE id = '0 OR 1=1'")
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
describe 'on SQL Server' do
|
|
240
|
+
let(:request_context_variables) { { 'x.value' => '0 OR 1=1' } }
|
|
241
|
+
|
|
242
|
+
it 'leaves a nested block comment untouched' do
|
|
243
|
+
expect(inject('SELECT 1 /* a /* b */ {{x.value}} */ FROM t WHERE id = {{x.value}}', sqlserver_connection))
|
|
244
|
+
.to eq("SELECT 1 /* a /* b */ {{x.value}} */ FROM t WHERE id = '0 OR 1=1'")
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
it 'reads {{...}} with the grammar of the filter injector' do
|
|
249
|
+
expect(LiveQueryInjector::PLACEHOLDER.source).to include(ContextVariablesInjector::PLACEHOLDER.source)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
describe 'value formats outside literals' do
|
|
253
|
+
let(:request_context_variables) do
|
|
254
|
+
{
|
|
255
|
+
'n.value' => '42',
|
|
256
|
+
'd.value' => '4.5',
|
|
257
|
+
'l.value' => '1,2,3',
|
|
258
|
+
'b.value' => 'FALSE',
|
|
259
|
+
'neg.value' => '-1',
|
|
260
|
+
's.value' => 'DESC'
|
|
261
|
+
}
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
it 'injects unsigned numbers, lists of numbers and booleans as they are, everything else as a string' do
|
|
265
|
+
query = 'SELECT {{n.value}}, {{d.value}}, ({{l.value}}), {{b.value}}, {{neg.value}}, {{s.value}}'
|
|
266
|
+
|
|
267
|
+
expect(inject(query, postgresql_connection))
|
|
268
|
+
.to eq("SELECT 42, 4.5, (1,2,3), FALSE, '-1', 'DESC'")
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
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.22.
|
|
4
|
+
version: 9.22.15
|
|
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-10-
|
|
11
|
+
date: 2026-10-30 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|
|
@@ -351,6 +351,7 @@ files:
|
|
|
351
351
|
- app/services/forest_liana/utils/beta_schema_utils.rb
|
|
352
352
|
- app/services/forest_liana/utils/context_variables.rb
|
|
353
353
|
- app/services/forest_liana/utils/context_variables_injector.rb
|
|
354
|
+
- app/services/forest_liana/utils/live_query_injector.rb
|
|
354
355
|
- app/services/forest_liana/value_stat_getter.rb
|
|
355
356
|
- app/views/layouts/forest_liana/application.html.erb
|
|
356
357
|
- config/initializers/error-messages.rb
|
|
@@ -485,6 +486,7 @@ files:
|
|
|
485
486
|
- spec/requests/capabilities_spec.rb
|
|
486
487
|
- spec/requests/cors_spec.rb
|
|
487
488
|
- spec/requests/count_spec.rb
|
|
489
|
+
- spec/requests/devise_controller_spec.rb
|
|
488
490
|
- spec/requests/missing_attribute_valve_spec.rb
|
|
489
491
|
- spec/requests/permissions_cache_footprint_spec.rb
|
|
490
492
|
- spec/requests/projection_header_spec.rb
|
|
@@ -522,6 +524,7 @@ files:
|
|
|
522
524
|
- spec/services/forest_liana/line_stat_getter_spec.rb
|
|
523
525
|
- spec/services/forest_liana/pie_stat_getter_spec.rb
|
|
524
526
|
- spec/services/forest_liana/projection_parser_spec.rb
|
|
527
|
+
- spec/services/forest_liana/query_stat_getter_spec.rb
|
|
525
528
|
- spec/services/forest_liana/request_cache_spec.rb
|
|
526
529
|
- spec/services/forest_liana/resource_updater_spec.rb
|
|
527
530
|
- spec/services/forest_liana/resources_getter_composite_keys_spec.rb
|
|
@@ -537,6 +540,7 @@ files:
|
|
|
537
540
|
- spec/services/forest_liana/smart_field_dependencies_spec.rb
|
|
538
541
|
- spec/services/forest_liana/utils/context_variables_injector_spec.rb
|
|
539
542
|
- spec/services/forest_liana/utils/context_variables_spec.rb
|
|
543
|
+
- spec/services/forest_liana/utils/live_query_injector_spec.rb
|
|
540
544
|
- spec/services/forest_liana/value_stat_getter_spec.rb
|
|
541
545
|
- spec/spec_helper.rb
|
|
542
546
|
- spec/support/forest_liana/query_capture.rb
|
|
@@ -847,6 +851,7 @@ test_files:
|
|
|
847
851
|
- spec/requests/capabilities_spec.rb
|
|
848
852
|
- spec/requests/cors_spec.rb
|
|
849
853
|
- spec/requests/count_spec.rb
|
|
854
|
+
- spec/requests/devise_controller_spec.rb
|
|
850
855
|
- spec/requests/missing_attribute_valve_spec.rb
|
|
851
856
|
- spec/requests/permissions_cache_footprint_spec.rb
|
|
852
857
|
- spec/requests/projection_header_spec.rb
|
|
@@ -884,6 +889,7 @@ test_files:
|
|
|
884
889
|
- spec/services/forest_liana/line_stat_getter_spec.rb
|
|
885
890
|
- spec/services/forest_liana/pie_stat_getter_spec.rb
|
|
886
891
|
- spec/services/forest_liana/projection_parser_spec.rb
|
|
892
|
+
- spec/services/forest_liana/query_stat_getter_spec.rb
|
|
887
893
|
- spec/services/forest_liana/request_cache_spec.rb
|
|
888
894
|
- spec/services/forest_liana/resource_updater_spec.rb
|
|
889
895
|
- spec/services/forest_liana/resources_getter_composite_keys_spec.rb
|
|
@@ -899,6 +905,7 @@ test_files:
|
|
|
899
905
|
- spec/services/forest_liana/smart_field_dependencies_spec.rb
|
|
900
906
|
- spec/services/forest_liana/utils/context_variables_injector_spec.rb
|
|
901
907
|
- spec/services/forest_liana/utils/context_variables_spec.rb
|
|
908
|
+
- spec/services/forest_liana/utils/live_query_injector_spec.rb
|
|
902
909
|
- spec/services/forest_liana/value_stat_getter_spec.rb
|
|
903
910
|
- spec/spec_helper.rb
|
|
904
911
|
- spec/support/forest_liana/query_capture.rb
|