forest_liana 9.19.0 → 9.20.1
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/actions_controller.rb +9 -4
- data/app/controllers/forest_liana/workflow_executions_controller.rb +89 -34
- data/config/routes/actions.rb +2 -3
- data/config/routes.rb +3 -3
- data/lib/forest_liana/version.rb +1 -1
- data/spec/dummy/lib/forest_liana/collections/island.rb +13 -0
- data/spec/requests/actions_controller_spec.rb +19 -0
- data/spec/requests/workflow_executions_spec.rb +163 -38
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7e6590ae4597f44757af9a98c3f4d8ba9e83652eedd857f45aeb1fe365e8a8ae
|
|
4
|
+
data.tar.gz: 5aa5f3150171dd65dac411007c311b9cb5409c9eb97d1a415d31e908001b0d34
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6d848b970f6f68edd2a162f40e546740878c6fc7e2b298a3c494f6641fce908b41e2412a9e3457d13edef7d6433ce9f5704b77604851b2fd7aeca5eb60cfe691
|
|
7
|
+
data.tar.gz: 2828ff99f57028970cc2ec4e1662b70203d0d077ca0bbb45350dd4d29d09bffaffd1ef907b923e666cfc28704f97db62358bdf6709cf4b7c16bac822cd1afa15
|
|
@@ -58,7 +58,7 @@ module ForestLiana
|
|
|
58
58
|
begin
|
|
59
59
|
# action.hooks[:change] is a hashmap here
|
|
60
60
|
# to do the validation, only the hook names are require
|
|
61
|
-
change_hooks_name = action.hooks[:change]
|
|
61
|
+
change_hooks_name = action.hooks && action.hooks[:change] ? action.hooks[:change].keys : nil
|
|
62
62
|
ForestLiana::SmartActionFieldValidator.validate_smart_action_fields(result[:fields], action.name, change_hooks_name)
|
|
63
63
|
rescue ForestLiana::Errors::SmartActionInvalidFieldError => invalid_field_error
|
|
64
64
|
FOREST_LOGGER.warn invalid_field_error.message
|
|
@@ -107,10 +107,15 @@ module ForestLiana
|
|
|
107
107
|
# Get the smart action hook load context
|
|
108
108
|
context = get_smart_action_load_ctx(action.fields)
|
|
109
109
|
|
|
110
|
-
|
|
111
|
-
|
|
110
|
+
if action.hooks && action.hooks[:load].present?
|
|
111
|
+
# Call the user-defined load hook.
|
|
112
|
+
result = action.hooks[:load].(context)
|
|
112
113
|
|
|
113
|
-
|
|
114
|
+
handle_result(result, action)
|
|
115
|
+
else
|
|
116
|
+
# No load hook declared: answer the static form (Node agent parity) instead of a 404.
|
|
117
|
+
handle_result(context[:fields], action)
|
|
118
|
+
end
|
|
114
119
|
end
|
|
115
120
|
end
|
|
116
121
|
|
|
@@ -1,71 +1,126 @@
|
|
|
1
1
|
require 'httparty'
|
|
2
|
+
require 'uri'
|
|
2
3
|
|
|
3
4
|
module ForestLiana
|
|
4
5
|
class WorkflowExecutionsController < ApplicationController
|
|
5
|
-
|
|
6
|
-
|
|
6
|
+
# Never forwarded (request or response): hop-by-hop, Host, and body-framing headers.
|
|
7
|
+
# render json: re-serializes the body, so the upstream length/encoding no longer match — and
|
|
8
|
+
# forwarding accept-encoding would defeat Net::HTTP's transparent gzip decompression.
|
|
9
|
+
SKIPPED_HEADERS = %w[
|
|
10
|
+
connection keep-alive transfer-encoding upgrade te trailer
|
|
11
|
+
proxy-authenticate proxy-authorization host
|
|
12
|
+
content-length content-encoding accept-encoding
|
|
13
|
+
].freeze
|
|
14
|
+
UNSAFE_PATH_FRAGMENTS = ['..', '%2e', '%2E', '\\', "\0"].freeze
|
|
15
|
+
OPEN_TIMEOUT_IN_SECONDS = 2
|
|
16
|
+
REQUEST_TIMEOUT_IN_SECONDS = 120
|
|
7
17
|
UPSTREAM_ERRORS = [
|
|
8
18
|
HTTParty::Error,
|
|
9
19
|
SocketError,
|
|
10
20
|
Errno::ECONNREFUSED,
|
|
11
21
|
Net::OpenTimeout,
|
|
12
22
|
Net::ReadTimeout,
|
|
13
|
-
Timeout::Error
|
|
23
|
+
Timeout::Error,
|
|
24
|
+
OpenSSL::SSL::SSLError
|
|
14
25
|
].freeze
|
|
15
26
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def trigger
|
|
21
|
-
forward_to_executor(method: :post, suffix: '/trigger')
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
private
|
|
25
|
-
|
|
26
|
-
def forward_to_executor(method:, suffix:)
|
|
27
|
+
# Catch-all: forward any verb/sub-path verbatim to the executor, so a new executor route needs
|
|
28
|
+
# no change here.
|
|
29
|
+
def proxy
|
|
27
30
|
base = ForestLiana.workflow_executor_url
|
|
28
|
-
if base.blank?
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
return head(:not_found) if base.blank?
|
|
32
|
+
|
|
33
|
+
normalized_base = base.sub(%r{/+\z}, '')
|
|
34
|
+
path = safe_executor_path
|
|
35
|
+
return head(:not_found) if path.nil?
|
|
36
|
+
return head(:not_found) unless same_origin?(normalized_base, path)
|
|
32
37
|
|
|
33
|
-
url = "#{base.sub(%r{/+\z}, '')}/runs/#{params[:run_id]}#{suffix}"
|
|
34
38
|
response = HTTParty.send(
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
headers:
|
|
39
|
+
request.request_method_symbol,
|
|
40
|
+
"#{normalized_base}#{path}",
|
|
41
|
+
headers: forwarded_request_headers,
|
|
38
42
|
query: forwarded_query,
|
|
39
|
-
body: forwarded_body
|
|
43
|
+
body: forwarded_body,
|
|
40
44
|
verify: Rails.env.production?,
|
|
41
|
-
|
|
45
|
+
# Don't follow redirects: a 3xx Location to an off-origin host would bypass the origin guard.
|
|
46
|
+
follow_redirects: false,
|
|
47
|
+
open_timeout: OPEN_TIMEOUT_IN_SECONDS,
|
|
48
|
+
timeout: REQUEST_TIMEOUT_IN_SECONDS
|
|
42
49
|
)
|
|
43
50
|
|
|
51
|
+
forward_response_headers(response)
|
|
44
52
|
render json: response.parsed_response, status: response.code
|
|
45
53
|
rescue *UPSTREAM_ERRORS => e
|
|
46
54
|
Rails.logger.error("[ForestLiana] workflow executor proxy error: #{e.class}: #{e.message}")
|
|
47
55
|
render json: { error: 'workflow_executor_unreachable' }, status: :service_unavailable
|
|
48
56
|
end
|
|
49
57
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
# First-pass rejection of escape attempts; the authoritative origin check is same_origin?.
|
|
61
|
+
def safe_executor_path
|
|
62
|
+
wildcard = params[:path].to_s
|
|
63
|
+
return nil if wildcard.empty? || wildcard.start_with?('/')
|
|
64
|
+
return nil if UNSAFE_PATH_FRAGMENTS.any? { |fragment| wildcard.include?(fragment) }
|
|
65
|
+
|
|
66
|
+
"/#{wildcard}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Authoritative SSRF check: forwarding must never leave the executor origin.
|
|
70
|
+
def same_origin?(base, path)
|
|
71
|
+
base_uri = URI.parse(base)
|
|
72
|
+
target = URI.parse("#{base}#{path}")
|
|
73
|
+
target.scheme == base_uri.scheme && target.host == base_uri.host && target.port == base_uri.port
|
|
74
|
+
rescue URI::InvalidURIError
|
|
75
|
+
false
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def forwarded_request_headers
|
|
79
|
+
request.headers.env.each_with_object({}) do |(key, value), acc|
|
|
80
|
+
name = http_header_name(key.to_s)
|
|
81
|
+
next unless name
|
|
82
|
+
next if SKIPPED_HEADERS.include?(name.downcase)
|
|
83
|
+
next if value.nil? || value.to_s.empty?
|
|
84
|
+
|
|
85
|
+
acc[name] = value.to_s
|
|
55
86
|
end
|
|
56
87
|
end
|
|
57
88
|
|
|
89
|
+
def http_header_name(env_key)
|
|
90
|
+
if env_key.start_with?('HTTP_')
|
|
91
|
+
titleize_header(env_key.delete_prefix('HTTP_'))
|
|
92
|
+
elsif %w[CONTENT_TYPE CONTENT_LENGTH].include?(env_key)
|
|
93
|
+
titleize_header(env_key)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def titleize_header(rack_name)
|
|
98
|
+
rack_name.split('_').map(&:capitalize).join('-')
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Strip the routing key (the glob) and Rails internals so only real query params reach the executor.
|
|
58
102
|
def forwarded_query
|
|
59
103
|
params
|
|
60
|
-
.except(:
|
|
104
|
+
.except(:path, :controller, :action, :format)
|
|
61
105
|
.to_unsafe_h
|
|
62
106
|
end
|
|
63
107
|
|
|
64
|
-
def forwarded_body
|
|
65
|
-
return nil if
|
|
108
|
+
def forwarded_body
|
|
109
|
+
return nil if request.get? || request.head?
|
|
110
|
+
|
|
111
|
+
request.raw_post.presence
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Forward executor response headers (minus hop-by-hop) so executor-set headers survive the proxy.
|
|
115
|
+
def forward_response_headers(upstream_response)
|
|
116
|
+
upstream_response.headers.each do |name, value|
|
|
117
|
+
next if name.nil? || SKIPPED_HEADERS.include?(name.to_s.downcase)
|
|
118
|
+
|
|
119
|
+
forwarded = value.is_a?(Array) ? value.join(', ') : value.to_s
|
|
120
|
+
next if forwarded.empty?
|
|
66
121
|
|
|
67
|
-
|
|
68
|
-
|
|
122
|
+
response.headers[name.to_s] = forwarded
|
|
123
|
+
end
|
|
69
124
|
end
|
|
70
125
|
end
|
|
71
126
|
end
|
data/config/routes/actions.rb
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
ForestLiana.apimap.each do |collection|
|
|
2
2
|
if !collection.actions.empty?
|
|
3
3
|
collection.actions.each do |action|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
end
|
|
4
|
+
# Unconditional: clients probe /hooks/load even when no load hook is declared.
|
|
5
|
+
post action.endpoint.sub('forest', '') + '/hooks/load' => 'actions#load', action_name: ActiveSupport::Inflector.parameterize(action.name)
|
|
7
6
|
if action.hooks && action.hooks[:change].present?
|
|
8
7
|
post action.endpoint.sub('forest', '') + '/hooks/change' => 'actions#change', action_name: ActiveSupport::Inflector.parameterize(action.name)
|
|
9
8
|
end
|
data/config/routes.rb
CHANGED
|
@@ -23,10 +23,10 @@ ForestLiana::Engine.routes.draw do
|
|
|
23
23
|
# Scopes
|
|
24
24
|
post '/scope-cache-invalidation' => 'scopes#invalidate_scope_cache'
|
|
25
25
|
|
|
26
|
-
# Workflow executor proxy
|
|
26
|
+
# Workflow executor proxy: single catch-all forwarding every verb/sub-path verbatim.
|
|
27
|
+
# Mounted only when ForestLiana.workflow_executor_url is set.
|
|
27
28
|
if ForestLiana.workflow_executor_url.present?
|
|
28
|
-
|
|
29
|
-
post '_internal/workflow-executions/:run_id/trigger' => 'workflow_executions#trigger'
|
|
29
|
+
match '_internal/executor/*path' => 'workflow_executions#proxy', via: :all
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
# Stripe Integration
|
data/lib/forest_liana/version.rb
CHANGED
|
@@ -28,6 +28,19 @@ class Forest::Island
|
|
|
28
28
|
|
|
29
29
|
action 'test'
|
|
30
30
|
|
|
31
|
+
action 'static_form_action',
|
|
32
|
+
fields: [{
|
|
33
|
+
field: 'static_field',
|
|
34
|
+
type: 'String',
|
|
35
|
+
default_value: nil,
|
|
36
|
+
enums: nil,
|
|
37
|
+
is_required: false,
|
|
38
|
+
is_read_only: false,
|
|
39
|
+
reference: nil,
|
|
40
|
+
description: nil,
|
|
41
|
+
widget: nil
|
|
42
|
+
}]
|
|
43
|
+
|
|
31
44
|
action 'my_action',
|
|
32
45
|
fields: [foo],
|
|
33
46
|
hooks: {
|
|
@@ -130,6 +130,25 @@ describe 'Requesting Actions routes', :type => :request do
|
|
|
130
130
|
expect(response.status).to eq(422)
|
|
131
131
|
end
|
|
132
132
|
|
|
133
|
+
it 'should respond 200 with the static form when the action has no load hook' do
|
|
134
|
+
post '/forest/actions/static_form_action/hooks/load', params: JSON.dump(params), headers: headers
|
|
135
|
+
action = island.actions.select { |action| action.name == 'static_form_action' }.first
|
|
136
|
+
static_field = action.fields.select { |field| field[:field] == 'static_field' }.first
|
|
137
|
+
expect(response.status).to eq(200)
|
|
138
|
+
expect(JSON.parse(response.body)).to eq({'fields' => [static_field.merge({:value => nil}).transform_keys { |key| key.to_s.camelize(:lower) }.stringify_keys]})
|
|
139
|
+
|
|
140
|
+
first_body = response.body
|
|
141
|
+
post '/forest/actions/static_form_action/hooks/load', params: JSON.dump(params), headers: headers
|
|
142
|
+
expect(response.status).to eq(200)
|
|
143
|
+
expect(response.body).to eq(first_body)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
it 'should respond 200 with empty fields when the action has no form at all' do
|
|
147
|
+
post '/forest/actions/test/hooks/load', params: JSON.dump(params), headers: headers
|
|
148
|
+
expect(response.status).to eq(200)
|
|
149
|
+
expect(JSON.parse(response.body)).to eq({'fields' => []})
|
|
150
|
+
end
|
|
151
|
+
|
|
133
152
|
it 'should respond 500 with bad hook result type' do
|
|
134
153
|
post '/forest/actions/fail_action/hooks/load', params: JSON.dump(params), headers: headers
|
|
135
154
|
expect(response.status).to eq(500)
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
require 'rails_helper'
|
|
2
|
+
require 'zlib'
|
|
3
|
+
require 'stringio'
|
|
4
|
+
require 'socket'
|
|
2
5
|
|
|
3
6
|
describe 'Workflow executor proxy', type: :request do
|
|
4
7
|
let(:run_id) { 'run_abc123' }
|
|
@@ -31,7 +34,16 @@ describe 'Workflow executor proxy', type: :request do
|
|
|
31
34
|
instance_double(
|
|
32
35
|
HTTParty::Response,
|
|
33
36
|
parsed_response: { 'id' => run_id, 'state' => 'pending' },
|
|
34
|
-
code: 200
|
|
37
|
+
code: 200,
|
|
38
|
+
headers: {
|
|
39
|
+
'content-type' => 'application/json',
|
|
40
|
+
# arbitrary executor response header — must be forwarded untouched.
|
|
41
|
+
'x-executor-custom' => 'passthrough-value',
|
|
42
|
+
# hop-by-hop response header — must be dropped, not forwarded.
|
|
43
|
+
'transfer-encoding' => 'chunked',
|
|
44
|
+
# body-framing header — meaningless once the body is re-serialized; must be dropped.
|
|
45
|
+
'content-encoding' => 'gzip'
|
|
46
|
+
}
|
|
35
47
|
)
|
|
36
48
|
end
|
|
37
49
|
|
|
@@ -42,77 +54,130 @@ describe 'Workflow executor proxy', type: :request do
|
|
|
42
54
|
|
|
43
55
|
allow(HTTParty).to receive(:get).and_return(executor_response)
|
|
44
56
|
allow(HTTParty).to receive(:post).and_return(executor_response)
|
|
57
|
+
allow(HTTParty).to receive(:delete).and_return(executor_response)
|
|
45
58
|
end
|
|
46
59
|
|
|
47
|
-
describe '
|
|
48
|
-
it 'forwards GET
|
|
49
|
-
get "/forest/_internal/
|
|
60
|
+
describe 'generic forwarding' do
|
|
61
|
+
it 'forwards a run GET (caller includes runs/), preserving the sub-path and query verbatim' do
|
|
62
|
+
get "/forest/_internal/executor/runs/#{run_id}", params: { foo: 'bar' }, headers: auth_headers
|
|
50
63
|
|
|
51
64
|
expect(HTTParty).to have_received(:get).with(
|
|
52
65
|
"#{executor_url}/runs/#{run_id}",
|
|
66
|
+
hash_including(query: hash_including('foo' => 'bar'))
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
it 'forwards a POST trigger with the raw body untouched (no reshaping)' do
|
|
71
|
+
raw = { step: 'approve', value: 42 }.to_json
|
|
72
|
+
|
|
73
|
+
post(
|
|
74
|
+
"/forest/_internal/executor/runs/#{run_id}/trigger",
|
|
75
|
+
params: raw,
|
|
76
|
+
headers: auth_headers
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
expect(HTTParty).to have_received(:post).with(
|
|
80
|
+
"#{executor_url}/runs/#{run_id}/trigger",
|
|
81
|
+
hash_including(body: raw)
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
it 'forwards a non-runs route verbatim (no /runs prefix injected)' do
|
|
86
|
+
delete '/forest/_internal/executor/mcp-oauth-credentials', headers: auth_headers
|
|
87
|
+
|
|
88
|
+
expect(HTTParty).to have_received(:delete).with(
|
|
89
|
+
"#{executor_url}/mcp-oauth-credentials",
|
|
90
|
+
anything
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
it 'forwards client headers (e.g. Authorization / Cookie) to the executor' do
|
|
95
|
+
get "/forest/_internal/executor/runs/#{run_id}", headers: auth_headers
|
|
96
|
+
|
|
97
|
+
expect(HTTParty).to have_received(:get).with(
|
|
98
|
+
anything,
|
|
53
99
|
hash_including(
|
|
54
100
|
headers: hash_including(
|
|
55
101
|
'Authorization' => "Bearer #{bearer_token}",
|
|
56
102
|
'Cookie' => 'forest_session_token=session-xyz'
|
|
57
|
-
)
|
|
58
|
-
query: hash_including('foo' => 'bar')
|
|
103
|
+
)
|
|
59
104
|
)
|
|
60
105
|
)
|
|
61
106
|
end
|
|
62
107
|
|
|
108
|
+
it 'does not follow redirects (a 3xx Location must not escape the executor origin)' do
|
|
109
|
+
get "/forest/_internal/executor/runs/#{run_id}", headers: auth_headers
|
|
110
|
+
|
|
111
|
+
expect(HTTParty).to have_received(:get).with(
|
|
112
|
+
anything,
|
|
113
|
+
hash_including(follow_redirects: false)
|
|
114
|
+
)
|
|
115
|
+
end
|
|
116
|
+
|
|
63
117
|
it 'returns the executor status and body verbatim' do
|
|
64
|
-
get "/forest/_internal/
|
|
118
|
+
get "/forest/_internal/executor/runs/#{run_id}", headers: auth_headers
|
|
65
119
|
|
|
66
120
|
expect(response.status).to eq(200)
|
|
67
121
|
expect(JSON.parse(response.body)).to eq('id' => run_id, 'state' => 'pending')
|
|
68
122
|
end
|
|
69
123
|
|
|
70
|
-
it '
|
|
71
|
-
get "/forest/_internal/
|
|
124
|
+
it 'forwards executor response headers except hop-by-hop / encoding ones' do
|
|
125
|
+
get "/forest/_internal/executor/runs/#{run_id}", headers: auth_headers
|
|
72
126
|
|
|
73
|
-
expect(response.
|
|
74
|
-
expect(
|
|
127
|
+
expect(response.headers['x-executor-custom']).to eq('passthrough-value')
|
|
128
|
+
expect(response.headers['transfer-encoding']).to be_nil
|
|
129
|
+
# content-encoding would tell the client the re-serialized JSON is gzipped → corruption.
|
|
130
|
+
expect(response.headers['content-encoding']).to be_nil
|
|
75
131
|
end
|
|
76
|
-
end
|
|
77
132
|
|
|
78
|
-
|
|
79
|
-
|
|
133
|
+
it 'does not forward accept-encoding (would defeat transparent gzip decompression)' do
|
|
134
|
+
get "/forest/_internal/executor/runs/#{run_id}",
|
|
135
|
+
headers: auth_headers.merge('Accept-Encoding' => 'gzip, deflate')
|
|
80
136
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
params: trigger_body.to_json,
|
|
85
|
-
headers: auth_headers
|
|
137
|
+
expect(HTTParty).to have_received(:get).with(
|
|
138
|
+
anything,
|
|
139
|
+
hash_including(headers: hash_excluding('Accept-Encoding'))
|
|
86
140
|
)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
87
143
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
144
|
+
describe 'SSRF guard (cannot escape the executor origin)' do
|
|
145
|
+
[
|
|
146
|
+
'..',
|
|
147
|
+
'../mcp-oauth-credentials',
|
|
148
|
+
"#{'run_abc123'}/../../mcp-oauth-credentials",
|
|
149
|
+
'%2e%2e/mcp-oauth-credentials'
|
|
150
|
+
].each do |evil_path|
|
|
151
|
+
it "rejects #{evil_path.inspect} with 404 and never forwards" do
|
|
152
|
+
get "/forest/_internal/executor/#{evil_path}", headers: auth_headers
|
|
153
|
+
|
|
154
|
+
expect(response.status).to eq(404)
|
|
155
|
+
expect(HTTParty).not_to have_received(:get)
|
|
156
|
+
end
|
|
95
157
|
end
|
|
158
|
+
end
|
|
96
159
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
params: trigger_body.to_json,
|
|
101
|
-
headers: auth_headers
|
|
102
|
-
)
|
|
160
|
+
describe 'authentication' do
|
|
161
|
+
it 'rejects unauthenticated requests with 401' do
|
|
162
|
+
get "/forest/_internal/executor/runs/#{run_id}"
|
|
103
163
|
|
|
104
|
-
expect(response.status).to eq(
|
|
105
|
-
expect(
|
|
164
|
+
expect(response.status).to eq(401)
|
|
165
|
+
expect(HTTParty).not_to have_received(:get)
|
|
106
166
|
end
|
|
107
167
|
end
|
|
108
168
|
|
|
109
169
|
describe 'when the executor returns an error status' do
|
|
110
170
|
let(:executor_response) do
|
|
111
|
-
instance_double(
|
|
171
|
+
instance_double(
|
|
172
|
+
HTTParty::Response,
|
|
173
|
+
parsed_response: { 'error' => 'invalid_step' },
|
|
174
|
+
code: 422,
|
|
175
|
+
headers: { 'content-type' => 'application/json' }
|
|
176
|
+
)
|
|
112
177
|
end
|
|
113
178
|
|
|
114
179
|
it 'forwards the executor status and body to the client' do
|
|
115
|
-
get "/forest/_internal/
|
|
180
|
+
get "/forest/_internal/executor/runs/#{run_id}", headers: auth_headers
|
|
116
181
|
|
|
117
182
|
expect(response.status).to eq(422)
|
|
118
183
|
expect(JSON.parse(response.body)).to eq('error' => 'invalid_step')
|
|
@@ -125,13 +190,73 @@ describe 'Workflow executor proxy', type: :request do
|
|
|
125
190
|
end
|
|
126
191
|
|
|
127
192
|
it 'returns 503 service_unavailable' do
|
|
128
|
-
get "/forest/_internal/
|
|
193
|
+
get "/forest/_internal/executor/runs/#{run_id}", headers: auth_headers
|
|
194
|
+
|
|
195
|
+
expect(response.status).to eq(503)
|
|
196
|
+
expect(JSON.parse(response.body)).to eq('error' => 'workflow_executor_unreachable')
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
describe 'when the executor connection fails at the transport level (e.g. SSL)' do
|
|
201
|
+
before do
|
|
202
|
+
allow(HTTParty).to receive(:get).and_raise(OpenSSL::SSL::SSLError.new('cert verify failed'))
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
it 'returns 503 rather than a generic 500' do
|
|
206
|
+
get "/forest/_internal/executor/runs/#{run_id}", headers: auth_headers
|
|
129
207
|
|
|
130
208
|
expect(response.status).to eq(503)
|
|
131
209
|
expect(JSON.parse(response.body)).to eq('error' => 'workflow_executor_unreachable')
|
|
132
210
|
end
|
|
133
211
|
end
|
|
134
212
|
|
|
213
|
+
# Real local server + real HTTParty (no stub) to exercise actual gzip decompression.
|
|
214
|
+
describe 'when the executor gzip-compresses the response (nginx in front)' do
|
|
215
|
+
let(:gz_body) do
|
|
216
|
+
io = StringIO.new
|
|
217
|
+
writer = Zlib::GzipWriter.new(io)
|
|
218
|
+
writer.write({ 'id' => run_id, 'state' => 'pending' }.to_json)
|
|
219
|
+
writer.close
|
|
220
|
+
io.string
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
around do |example|
|
|
224
|
+
server = TCPServer.new('127.0.0.1', 0)
|
|
225
|
+
port = server.addr[1]
|
|
226
|
+
thread = Thread.new do
|
|
227
|
+
client = server.accept
|
|
228
|
+
client.readpartial(4096)
|
|
229
|
+
client.write(
|
|
230
|
+
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n" \
|
|
231
|
+
"Content-Encoding: gzip\r\nContent-Length: #{gz_body.bytesize}\r\n" \
|
|
232
|
+
"Connection: close\r\n\r\n"
|
|
233
|
+
)
|
|
234
|
+
client.write(gz_body)
|
|
235
|
+
client.close
|
|
236
|
+
rescue IOError, Errno::ECONNRESET, Errno::EPIPE
|
|
237
|
+
nil
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
original = ForestLiana.workflow_executor_url
|
|
241
|
+
ForestLiana.workflow_executor_url = "http://127.0.0.1:#{port}"
|
|
242
|
+
example.run
|
|
243
|
+
ensure
|
|
244
|
+
ForestLiana.workflow_executor_url = original
|
|
245
|
+
thread&.kill
|
|
246
|
+
server&.close
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
before { allow(HTTParty).to receive(:get).and_call_original }
|
|
250
|
+
|
|
251
|
+
it 'decompresses the body and drops the stale content-encoding' do
|
|
252
|
+
get "/forest/_internal/executor/runs/#{run_id}", headers: auth_headers
|
|
253
|
+
|
|
254
|
+
expect(response.status).to eq(200)
|
|
255
|
+
expect(JSON.parse(response.body)).to eq('id' => run_id, 'state' => 'pending')
|
|
256
|
+
expect(response.headers['content-encoding']).to be_nil
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
135
260
|
describe 'when ForestLiana.workflow_executor_url is blank' do
|
|
136
261
|
around do |example|
|
|
137
262
|
original = ForestLiana.workflow_executor_url
|
|
@@ -145,7 +270,7 @@ describe 'Workflow executor proxy', type: :request do
|
|
|
145
270
|
# Note: routes are mounted at boot based on workflow_executor_url being
|
|
146
271
|
# present. This test exercises the runtime guard inside the controller
|
|
147
272
|
# for scenarios where config is mutated after boot (e.g. tests).
|
|
148
|
-
get "/forest/_internal/
|
|
273
|
+
get "/forest/_internal/executor/runs/#{run_id}", headers: auth_headers
|
|
149
274
|
|
|
150
275
|
expect(response.status).to eq(404)
|
|
151
276
|
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.
|
|
4
|
+
version: 9.20.1
|
|
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-
|
|
11
|
+
date: 2026-07-23 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|