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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fcbf97d59c4b454c53e5f068f969e8e503e2a722c1a011e2d03b7f9e73a0c152
4
- data.tar.gz: cd6608d3c224d6daaedd240928a34cec77d49c35bfdf52fdb0d5b5187a23cf02
3
+ metadata.gz: dc9e247d2d65125925e14127a256acb70326504b4c1e2fc90daaf3b792736025
4
+ data.tar.gz: 7039ebbb67be595efd366b94d57a35881448f9747cba39cf2e8e35b1f88a083e
5
5
  SHA512:
6
- metadata.gz: 78dd9047c6f8647076342d0a99f223ad66c7c33590b6b8be4726901d4aa6b057eb97eb4137cd6de3852fcf06d505621b77e6d6b653bb699443e753b57955a782
7
- data.tar.gz: c0dc2372c158fb2f86929279b3594ae6aec5b2270ae6fd74a19871c7dc26af89b0b20d8d5f8a822ddd6147ab11d89413f80df582168ea31e41e7fac847c2e430
6
+ metadata.gz: 3bf2d66f16eaedc067bfca8422a0274255bc9bc6d86057e251bdb65dd41191859e02344f2712356511c437bf03a525c5ff11b2a7f4ebb972104420baafcd1fbc
7
+ data.tar.gz: 07b0b79be5105719f3cffbb590717731031d3e1cd0fc81eb6e5d2c72a60f7413e411661f4f301a97294fc08e4cadac9e1d547ceb9c9cec1b9b0bbeadae569b3b
@@ -1,11 +1,15 @@
1
1
  module ForestLiana
2
- class DeviseController < ForestLiana::ApplicationController
3
- def change_password
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
- user = resource.find(params['data']['attributes']['ids'].first)
8
- user.password = params['data']['attributes']['values']['New password']
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
- context_variables = Utils::ContextVariables.new(nil, nil, @params['contextVariables'])
17
- raw_query = Utils::ContextVariablesInjector.inject_context_in_value(@params['query'].strip, context_variables)
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
- if @params['record_id']
22
- raw_query.gsub!('?', @params['record_id'].to_s)
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']
@@ -32,6 +32,7 @@ module ForestLiana
32
32
  collection.actions << ForestLiana::Model::Action.new({
33
33
  id: "#{collection.name}.Change password",
34
34
  name: "Change password",
35
+ type: 'single',
35
36
  fields: [{
36
37
  field: 'New password',
37
38
  type: 'String'
@@ -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
- value = context_variables.get_value(context_variable_key)
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 = regex.match(value_with_context_variables_injected))
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
@@ -1,3 +1,3 @@
1
1
  module ForestLiana
2
- VERSION = "9.22.13"
2
+ VERSION = "9.22.15"
3
3
  end
@@ -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.13
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-23 00:00:00.000000000 Z
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