forest_liana 9.22.14 → 9.22.16

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: 0ea33256d7cec4221e9c94bbe46f8bc4f7caaef29854b237cff1961aed4809dd
4
- data.tar.gz: c80925513502ae243fabd0d0c6840483634940882226ccb1e6c686c0cbc52553
3
+ metadata.gz: fc453faf72f6b0f7030087482ead90361fe6e3e16c4d2e7b25e2a53cbc1b5fe4
4
+ data.tar.gz: 6b13ab236bf05d9c5099cc8a987bd1616ecc7ba7097f077ffe3938674a428a75
5
5
  SHA512:
6
- metadata.gz: f21a026ae94a6061ba804462b9e681dc4e43fa57ace54b627ca06fcec199d1ddbeebdbc701c94e4820d3e00de8bab105dc6acd3614db4f855318592b6957ab81
7
- data.tar.gz: 0f8579fab129cd9ba90747c9c37a22de38bf4aa0bf89a210e4cee5a806129a9aee1b1adb5da22c8ede9925047478cf18fbceb79b394f5d5323b27f13a2e1ce08
6
+ metadata.gz: 48fac66a8b958fdf847c8861992d38f1a61efd8ff9df7da04d1254c5470246b684f0f584fd3881c80aff673f69284b115f0d44852f5eb97c62ba438c3f43dad2
7
+ data.tar.gz: fae68b2cf78f032cfef3a5609f9309631b69e0784b579cfc3dbe772b298c3202d5bd29a74d92796ee5610fa9b57443f80eb9bce2f8c910433a15ec67bdce2106
@@ -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]
@@ -1,5 +1,12 @@
1
1
  module ForestLiana
2
2
  class StripeController < ForestLiana::ApplicationController
3
+ include ForestLiana::Ability
4
+
5
+ rescue_from ForestLiana::Ability::Exceptions::TriggerForbidden, with: :render_error
6
+ rescue_from ForestLiana::Ability::Exceptions::RequireApproval, with: :render_error
7
+ rescue_from ForestLiana::Ability::Exceptions::UnsupportedConditionals, with: :render_error
8
+
9
+ before_action :authorize_refund, only: :refund
3
10
 
4
11
  def payments
5
12
  getter = StripePaymentsGetter.new(params, request.headers['Stripe-Secret-Key'],
@@ -27,7 +34,7 @@ module ForestLiana
27
34
 
28
35
  def refund
29
36
  begin
30
- refunder = StripePaymentRefunder.new(params)
37
+ refunder = StripePaymentRefunder.new(@refund_parameters)
31
38
  refunder.perform
32
39
 
33
40
  render serializer: nil, json: {}
@@ -137,5 +144,24 @@ module ForestLiana
137
144
  def get_serializer_type(suffix)
138
145
  "#{params[:collection]}_#{suffix}"
139
146
  end
147
+
148
+ private
149
+
150
+ def authorize_refund
151
+ raise ForestLiana::Errors::HTTP422Error.new if params.dig(:data, :attributes, :requester_id).present?
152
+
153
+ parameters = refund_request_parameters
154
+ collection_name = parameters.dig(:data, :attributes, :collection_name).to_s
155
+ is_integration_action_authorized?(forest_user, collection_name, 'Refund', params)
156
+ @refund_parameters = parameters
157
+ end
158
+
159
+ def refund_request_parameters
160
+ return params.permit! unless params.dig(:data, :attributes, :signed_approval_request)
161
+
162
+ ForestLiana::Ability::Permission::RequestPermission.decodeSignedApprovalRequest(params.permit!)
163
+ rescue JWT::DecodeError
164
+ raise ForestLiana::Errors::HTTP400Error.new('Invalid signed action parameters')
165
+ end
140
166
  end
141
167
  end
@@ -0,0 +1,17 @@
1
+ module ForestLiana
2
+ module Ability
3
+ module Exceptions
4
+ class UnsupportedConditionals < ForestLiana::Errors::ExpectedError
5
+ def initialize(backtrace = nil)
6
+ super(
7
+ 422,
8
+ :unprocessable_entity,
9
+ 'The roles conditions (conditional smart actions) are not supported with Smart Collection. Please contact an administrator.',
10
+ 'UnsupportedConditionalsFeatureError',
11
+ backtrace
12
+ )
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -43,7 +43,7 @@ module ForestLiana
43
43
  elsif @smart_action['approvalRequired'].include?(@user['roleId'])
44
44
  if condition_by_role_id(@smart_action['approvalRequiredConditions']).blank? || match_conditions('approvalRequiredConditions')
45
45
  # Global actions target no specific records — never resolve.
46
- if @parameters[:data][:attributes][:all_records] && @action_type != 'global'
46
+ if @parameters[:data][:attributes][:all_records] && @action_type != 'global' && @collection
47
47
  record_ids = select_all_record_ids
48
48
  end
49
49
 
@@ -83,6 +83,8 @@ module ForestLiana
83
83
  end
84
84
 
85
85
  def match_conditions(condition_name)
86
+ raise ForestLiana::Ability::Exceptions::UnsupportedConditionals.new unless @collection
87
+
86
88
  begin
87
89
  attributes = @parameters[:data][:attributes]
88
90
  condition = condition_by_role_id(@smart_action[condition_name])
@@ -53,6 +53,19 @@ module ForestLiana
53
53
  end
54
54
  end
55
55
 
56
+ # An integration collection (`<Model>_stripe_payments`, …) has no model: a select-all is not
57
+ # resolved for its approval, and a role condition on its action is refused as unsupported.
58
+ def is_integration_action_authorized?(user, collection_name, action_name, parameters)
59
+ return true unless has_permission_system?
60
+
61
+ schema_action = find_integration_action(collection_name, action_name)
62
+ user_data = get_user_data(user['id'])
63
+ smart_action = schema_action && get_collections_permissions_data.dig(collection_name, :actions, action_name)
64
+ raise ForestLiana::Ability::Exceptions::TriggerForbidden.new unless user_data && smart_action
65
+
66
+ SmartActionChecker.new(parameters, nil, smart_action, user_data, schema_action.type, user).can_execute?
67
+ end
68
+
56
69
  # +refetch+ drops the cached permissions first. Only a caller about to refuse on a denial asks
57
70
  # for it (a stale, up to TTL-old cache may sit behind a permission granted moments ago): the
58
71
  # refetch rewrites the cluster-wide `forest.collections` entry and this request's copy, so a
@@ -367,6 +380,12 @@ module ForestLiana
367
380
  nil
368
381
  end
369
382
 
383
+ def find_integration_action(collection_name, action_name)
384
+ collection = ForestLiana.apimap.find { |candidate| candidate.name.to_s == collection_name && candidate.integration.present? }
385
+
386
+ collection&.actions&.find { |action| action.name == action_name }
387
+ end
388
+
370
389
  def find_action_from_endpoint(collection_name, endpoint, http_method)
371
390
  collection = ForestLiana.apimap.find { |collection| collection.name.to_s == collection_name }
372
391
 
@@ -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,3 +1,3 @@
1
1
  module ForestLiana
2
- VERSION = "9.22.14"
2
+ VERSION = "9.22.16"
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,292 @@
1
+ require 'rails_helper'
2
+
3
+ describe 'Stripe payments refund', type: :request do
4
+ let(:rendering_id) { 13 }
5
+ let(:user_id) { 38 }
6
+ let(:role_id) { 1 }
7
+ let(:approver_role_id) { 3 }
8
+ let(:trigger_roles) { [role_id] }
9
+ let(:approval_roles) { [] }
10
+ let(:user_approval_roles) { [] }
11
+ let(:trigger_conditions) { [] }
12
+ let(:permissions_users) { [{ 'id' => user_id, 'roleId' => role_id }] }
13
+ let(:has_permission_system) { true }
14
+ let(:cache) { ActiveSupport::Cache::MemoryStore.new }
15
+ let(:refunder) { instance_double(ForestLiana::StripePaymentRefunder, perform: nil) }
16
+ let(:refund_requests) { [] }
17
+ let(:payments_collection) do
18
+ ForestLiana::Model::Collection.new(
19
+ name: 'Island_stripe_payments',
20
+ integration: 'stripe',
21
+ is_virtual: true,
22
+ actions: [ForestLiana::Model::Action.new(name: 'Refund', endpoint: '/forest/stripe_payments/refunds')]
23
+ )
24
+ end
25
+
26
+ let(:token) do
27
+ JWT.encode({
28
+ id: user_id,
29
+ email: 'michael.kelso@that70.show',
30
+ first_name: 'Michael',
31
+ last_name: 'Kelso',
32
+ team: 'Operations',
33
+ rendering_id: rendering_id,
34
+ exp: Time.now.to_i + 2.weeks.to_i,
35
+ permission_level: 'user'
36
+ }, ForestLiana.auth_secret, 'HS256')
37
+ end
38
+
39
+ let(:headers) do
40
+ {
41
+ 'Accept' => 'application/json',
42
+ 'Content-Type' => 'application/json',
43
+ 'Authorization' => "Bearer #{token}"
44
+ }
45
+ end
46
+
47
+ let(:attributes) do
48
+ {
49
+ collection_name: 'Island_stripe_payments',
50
+ ids: ['ch_1', 'ch_2'],
51
+ values: {},
52
+ all_records: false,
53
+ smart_action_id: 'Island_stripe_payments-Refund'
54
+ }
55
+ end
56
+
57
+ let(:params) { { data: { attributes: attributes, type: 'custom-action-requests' }, timezone: 'Europe/Paris' } }
58
+
59
+ def environment_permissions
60
+ enabled = { 'roles' => [role_id, approver_role_id] }
61
+ crud = %w[browseEnabled readEnabled editEnabled addEnabled deleteEnabled exportEnabled].to_h { |key| [key, enabled] }
62
+ refund = {
63
+ 'triggerEnabled' => { 'roles' => trigger_roles },
64
+ 'triggerConditions' => trigger_conditions,
65
+ 'approvalRequired' => { 'roles' => approval_roles },
66
+ 'approvalRequiredConditions' => [],
67
+ 'userApprovalEnabled' => { 'roles' => user_approval_roles },
68
+ 'userApprovalConditions' => [],
69
+ 'selfApprovalEnabled' => { 'roles' => [] }
70
+ }
71
+
72
+ { 'collections' => { 'Island_stripe_payments' => { 'collection' => crud, 'actions' => { 'Refund' => refund } } } }
73
+ end
74
+
75
+ def signed_approval_request(ids:, requester_id:)
76
+ JWT.encode({
77
+ data: {
78
+ attributes: attributes.merge(ids: ids, requester_id: requester_id),
79
+ type: 'custom-action-requests'
80
+ }
81
+ }, ForestLiana.env_secret, 'HS256')
82
+ end
83
+
84
+ def refund(request_headers = headers)
85
+ post '/forest/stripe_payments/refunds', params: JSON.dump(params), headers: request_headers
86
+ end
87
+
88
+ def refunded_ids
89
+ refund_requests.map { |refund_params| refund_params[:data][:attributes][:ids] }
90
+ end
91
+
92
+ before do
93
+ ForestLiana.apimap << payments_collection
94
+
95
+ allow(Rails).to receive(:cache).and_return(cache)
96
+ allow_any_instance_of(ForestLiana::Ability::Fetch).to receive(:get_permissions) do |_, path|
97
+ case path
98
+ when '/liana/v4/permissions/environment' then has_permission_system ? environment_permissions : true
99
+ when '/liana/v4/permissions/users' then permissions_users
100
+ end
101
+ end
102
+
103
+ allow(ForestLiana::StripePaymentRefunder).to receive(:new) do |refund_params|
104
+ refund_requests << refund_params
105
+ refunder
106
+ end
107
+ allow(ForestLiana::IpWhitelist).to receive(:is_ip_whitelist_retrieved) { true }
108
+ allow(ForestLiana::IpWhitelist).to receive(:is_ip_valid) { true }
109
+ end
110
+
111
+ after { ForestLiana.apimap.delete(payments_collection) }
112
+
113
+ context 'when the role is allowed to trigger the Refund action' do
114
+ it 'responds 200 and refunds each requested charge' do
115
+ refund
116
+
117
+ expect(response.status).to eq(200)
118
+ expect(refunded_ids).to eq([['ch_1', 'ch_2']])
119
+ expect(refunder).to have_received(:perform).with(no_args)
120
+ end
121
+ end
122
+
123
+ context 'when the project has no permission system' do
124
+ let(:has_permission_system) { false }
125
+ let(:trigger_roles) { [] }
126
+
127
+ it 'responds 200 and refunds each requested charge' do
128
+ refund
129
+
130
+ expect(response.status).to eq(200)
131
+ expect(refunded_ids).to eq([['ch_1', 'ch_2']])
132
+ end
133
+ end
134
+
135
+ context 'when the role is not allowed to trigger the Refund action' do
136
+ let(:trigger_roles) { [approver_role_id] }
137
+
138
+ it 'responds 403 and refunds nothing' do
139
+ refund
140
+
141
+ expect(response.status).to eq(403)
142
+ expect(JSON.parse(response.body)['errors'].first['name']).to eq('CustomActionTriggerForbiddenError')
143
+ expect(ForestLiana::StripePaymentRefunder).not_to have_received(:new)
144
+ end
145
+ end
146
+
147
+ context 'when the user is no longer in the permissions system' do
148
+ let(:permissions_users) { [] }
149
+
150
+ it 'responds 403 and refunds nothing' do
151
+ refund
152
+
153
+ expect(response.status).to eq(403)
154
+ expect(ForestLiana::StripePaymentRefunder).not_to have_received(:new)
155
+ end
156
+ end
157
+
158
+ context 'when the Refund action requires approval for the role' do
159
+ let(:approval_roles) { [role_id] }
160
+ let(:user_approval_roles) { [approver_role_id] }
161
+
162
+ it 'responds 403 with the roles allowed to approve and refunds nothing' do
163
+ refund
164
+
165
+ expect(response.status).to eq(403)
166
+ error = JSON.parse(response.body)['errors'].first
167
+ expect(error['name']).to eq('CustomActionRequiresApprovalError')
168
+ expect(error['data']).to eq('roleIdsAllowedToApprove' => [approver_role_id])
169
+ expect(ForestLiana::StripePaymentRefunder).not_to have_received(:new)
170
+ end
171
+ end
172
+
173
+ context 'when an approver sends a signed approval request' do
174
+ let(:role_id) { approver_role_id }
175
+ let(:trigger_roles) { [] }
176
+ let(:user_approval_roles) { [approver_role_id] }
177
+ let(:attributes) do
178
+ super().merge(ids: ['ch_99'], signed_approval_request: signed_approval_request(ids: ['ch_1'], requester_id: 12))
179
+ end
180
+
181
+ it 'responds 200 and refunds the charges of the approved request' do
182
+ refund
183
+
184
+ expect(response.status).to eq(200)
185
+ expect(refunded_ids).to eq([['ch_1']])
186
+ end
187
+ end
188
+
189
+ context 'when the approval request is not signed with the environment secret' do
190
+ let(:role_id) { approver_role_id }
191
+ let(:trigger_roles) { [] }
192
+ let(:user_approval_roles) { [approver_role_id] }
193
+ let(:attributes) do
194
+ forged = JWT.encode({ data: { attributes: { collection_name: 'Island_stripe_payments', ids: ['ch_1'], requester_id: 12 } } }, 'another-secret', 'HS256')
195
+ super().merge(signed_approval_request: forged)
196
+ end
197
+
198
+ it 'responds 400 and refunds nothing' do
199
+ refund
200
+
201
+ expect(response.status).to eq(400)
202
+ expect(JSON.parse(response.body)['errors'].first['detail']).to eq('Invalid signed action parameters')
203
+ expect(ForestLiana::StripePaymentRefunder).not_to have_received(:new)
204
+ end
205
+ end
206
+
207
+ context 'when the body carries a requester id outside a signed approval request' do
208
+ let(:attributes) { super().merge(requester_id: 12) }
209
+
210
+ it 'responds 422 and refunds nothing, even for a role allowed to trigger the Refund action' do
211
+ refund
212
+
213
+ expect(response.status).to eq(422)
214
+ expect(ForestLiana::StripePaymentRefunder).not_to have_received(:new)
215
+ end
216
+ end
217
+
218
+ context 'when the role can trigger the Refund action only under a condition' do
219
+ let(:trigger_conditions) do
220
+ [{ 'roleId' => role_id, 'filter' => { 'field' => 'amount', 'operator' => 'greater_than', 'value' => 0, 'source' => 'data' } }]
221
+ end
222
+
223
+ it 'responds 422 and refunds nothing, since role conditions are not supported on Stripe payments' do
224
+ refund
225
+
226
+ expect(response.status).to eq(422)
227
+ expect(JSON.parse(response.body)['errors'].first['name']).to eq('UnsupportedConditionalsFeatureError')
228
+ expect(ForestLiana::StripePaymentRefunder).not_to have_received(:new)
229
+ end
230
+ end
231
+
232
+ context 'when a role needing approval selects every payment' do
233
+ let(:approval_roles) { [role_id] }
234
+ let(:user_approval_roles) { [approver_role_id] }
235
+ let(:attributes) { super().merge(all_records: true, all_records_ids_excluded: []) }
236
+
237
+ it 'responds 403 with the roles allowed to approve and refunds nothing' do
238
+ refund
239
+
240
+ expect(response.status).to eq(403)
241
+ error = JSON.parse(response.body)['errors'].first
242
+ expect(error['name']).to eq('CustomActionRequiresApprovalError')
243
+ expect(error['data']).to eq('roleIdsAllowedToApprove' => [approver_role_id])
244
+ expect(ForestLiana::StripePaymentRefunder).not_to have_received(:new)
245
+ end
246
+ end
247
+
248
+ context 'when the request names a collection without the Stripe Refund action' do
249
+ let(:attributes) { super().merge(collection_name: 'Island') }
250
+
251
+ it 'responds 403 and refunds nothing' do
252
+ refund
253
+
254
+ expect(response.status).to eq(403)
255
+ expect(JSON.parse(response.body)['errors'].first['name']).to eq('CustomActionTriggerForbiddenError')
256
+ expect(ForestLiana::StripePaymentRefunder).not_to have_received(:new)
257
+ end
258
+ end
259
+
260
+ context 'when the request names no collection' do
261
+ let(:attributes) { super().except(:collection_name) }
262
+
263
+ it 'responds 403 and refunds nothing' do
264
+ refund
265
+
266
+ expect(response.status).to eq(403)
267
+ expect(JSON.parse(response.body)['errors'].first['name']).to eq('CustomActionTriggerForbiddenError')
268
+ expect(ForestLiana::StripePaymentRefunder).not_to have_received(:new)
269
+ end
270
+ end
271
+
272
+ context 'when the project has no permission system and the request names no collection' do
273
+ let(:has_permission_system) { false }
274
+ let(:attributes) { super().except(:collection_name) }
275
+
276
+ it 'responds 200 and refunds each requested charge' do
277
+ refund
278
+
279
+ expect(response.status).to eq(200)
280
+ expect(refunded_ids).to eq([['ch_1', 'ch_2']])
281
+ end
282
+ end
283
+
284
+ context 'when the request is not authenticated' do
285
+ it 'responds 401 and refunds nothing' do
286
+ refund(headers.except('Authorization'))
287
+
288
+ expect(response.status).to eq(401)
289
+ expect(ForestLiana::StripePaymentRefunder).not_to have_received(:new)
290
+ end
291
+ end
292
+ 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
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: forest_liana
3
3
  version: !ruby/object:Gem::Version
4
- version: 9.22.14
4
+ version: 9.22.16
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sandro Munda
@@ -286,6 +286,7 @@ files:
286
286
  - app/services/forest_liana/ability/exceptions/unauthorized_query_field_error.rb
287
287
  - app/services/forest_liana/ability/exceptions/unexposed_query_collection_error.rb
288
288
  - app/services/forest_liana/ability/exceptions/unknown_collection.rb
289
+ - app/services/forest_liana/ability/exceptions/unsupported_conditionals.rb
289
290
  - app/services/forest_liana/ability/fetch.rb
290
291
  - app/services/forest_liana/ability/permission.rb
291
292
  - app/services/forest_liana/ability/permission/request_permission.rb
@@ -486,6 +487,7 @@ files:
486
487
  - spec/requests/capabilities_spec.rb
487
488
  - spec/requests/cors_spec.rb
488
489
  - spec/requests/count_spec.rb
490
+ - spec/requests/devise_controller_spec.rb
489
491
  - spec/requests/missing_attribute_valve_spec.rb
490
492
  - spec/requests/permissions_cache_footprint_spec.rb
491
493
  - spec/requests/projection_header_spec.rb
@@ -497,6 +499,7 @@ files:
497
499
  - spec/requests/smart_field_named_like_unexposed_relation_spec.rb
498
500
  - spec/requests/stats_spec.rb
499
501
  - spec/requests/sti_record_conversion_spec.rb
502
+ - spec/requests/stripe_refund_spec.rb
500
503
  - spec/requests/test.ru
501
504
  - spec/requests/unresolved_polymorphic_relation_spec.rb
502
505
  - spec/requests/workflow_executions_spec.rb
@@ -850,6 +853,7 @@ test_files:
850
853
  - spec/requests/capabilities_spec.rb
851
854
  - spec/requests/cors_spec.rb
852
855
  - spec/requests/count_spec.rb
856
+ - spec/requests/devise_controller_spec.rb
853
857
  - spec/requests/missing_attribute_valve_spec.rb
854
858
  - spec/requests/permissions_cache_footprint_spec.rb
855
859
  - spec/requests/projection_header_spec.rb
@@ -861,6 +865,7 @@ test_files:
861
865
  - spec/requests/smart_field_named_like_unexposed_relation_spec.rb
862
866
  - spec/requests/stats_spec.rb
863
867
  - spec/requests/sti_record_conversion_spec.rb
868
+ - spec/requests/stripe_refund_spec.rb
864
869
  - spec/requests/test.ru
865
870
  - spec/requests/unresolved_polymorphic_relation_spec.rb
866
871
  - spec/requests/workflow_executions_spec.rb