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 +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/controllers/forest_liana/stripe_controller.rb +27 -1
- data/app/services/forest_liana/ability/exceptions/unsupported_conditionals.rb +17 -0
- data/app/services/forest_liana/ability/permission/smart_action_checker.rb +3 -1
- data/app/services/forest_liana/ability/permission.rb +19 -0
- data/app/services/forest_liana/schema_adapter.rb +1 -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/requests/stripe_refund_spec.rb +292 -0
- data/spec/services/forest_liana/schema_adapter_spec.rb +26 -0
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fc453faf72f6b0f7030087482ead90361fe6e3e16c4d2e7b25e2a53cbc1b5fe4
|
|
4
|
+
data.tar.gz: 6b13ab236bf05d9c5099cc8a987bd1616ecc7ba7097f077ffe3938674a428a75
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 48fac66a8b958fdf847c8861992d38f1a61efd8ff9df7da04d1254c5470246b684f0f584fd3881c80aff673f69284b115f0d44852f5eb97c62ba438c3f43dad2
|
|
7
|
+
data.tar.gz: fae68b2cf78f032cfef3a5609f9309631b69e0784b579cfc3dbe772b298c3202d5bd29a74d92796ee5610fa9b57443f80eb9bce2f8c910433a15ec67bdce2106
|
|
@@ -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]
|
|
@@ -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(
|
|
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
|
|
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,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.
|
|
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
|