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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 92281c1baf13dfbbdbe418f2a11833ad15a665c1109f1e4ad0a5f395e32ab090
4
- data.tar.gz: 71946c62175f706e11528dbac2a3e7600f780a7847596be724c74675ef8299a0
3
+ metadata.gz: 7e6590ae4597f44757af9a98c3f4d8ba9e83652eedd857f45aeb1fe365e8a8ae
4
+ data.tar.gz: 5aa5f3150171dd65dac411007c311b9cb5409c9eb97d1a415d31e908001b0d34
5
5
  SHA512:
6
- metadata.gz: f6eced39681d8c424791ed625807cb3ce9e8e741a9ed7f1ca711f225219219d2415cafa222b8df4eaa99185f57c98ca2f8766df724719d36e791af695ad1f2ee
7
- data.tar.gz: 4df825ec3296694e8fcf7cdc0d7eb86a481667af477ce9b7e500ba69afdc1bb86d343049f2493d896c9a70dc19100f984dcd301e5da80703520cf17ae0121dcc
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].nil? ? nil : action.hooks[:change].keys
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
- # Call the user-defined load hook.
111
- result = action.hooks[:load].(context)
110
+ if action.hooks && action.hooks[:load].present?
111
+ # Call the user-defined load hook.
112
+ result = action.hooks[:load].(context)
112
113
 
113
- handle_result(result, action)
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
- FORWARDED_HEADERS = %w[Authorization Cookie].freeze
6
- UPSTREAM_TIMEOUT_IN_SECONDS = 10
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
- def show
17
- forward_to_executor(method: :get, suffix: '')
18
- end
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
- head :not_found
30
- return
31
- end
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
- method,
36
- url,
37
- headers: forwarded_headers,
39
+ request.request_method_symbol,
40
+ "#{normalized_base}#{path}",
41
+ headers: forwarded_request_headers,
38
42
  query: forwarded_query,
39
- body: forwarded_body(method),
43
+ body: forwarded_body,
40
44
  verify: Rails.env.production?,
41
- timeout: UPSTREAM_TIMEOUT_IN_SECONDS
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
- def forwarded_headers
51
- base = { 'Content-Type' => 'application/json' }
52
- FORWARDED_HEADERS.each_with_object(base) do |name, acc|
53
- value = request.headers[name]
54
- acc[name] = value if value.present?
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(:run_id, :controller, :action, :format)
104
+ .except(:path, :controller, :action, :format)
61
105
  .to_unsafe_h
62
106
  end
63
107
 
64
- def forwarded_body(method)
65
- return nil if method == :get
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
- raw = request.raw_post
68
- raw.presence
122
+ response.headers[name.to_s] = forwarded
123
+ end
69
124
  end
70
125
  end
71
126
  end
@@ -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
- if action.hooks && action.hooks[:load].present?
5
- post action.endpoint.sub('forest', '') + '/hooks/load' => 'actions#load', action_name: ActiveSupport::Inflector.parameterize(action.name)
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 (mounted only when ForestLiana.workflow_executor_url is set)
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
- get '_internal/workflow-executions/:run_id' => 'workflow_executions#show'
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
@@ -1,3 +1,3 @@
1
1
  module ForestLiana
2
- VERSION = "9.19.0"
2
+ VERSION = "9.20.1"
3
3
  end
@@ -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 'GET /forest/_internal/workflow-executions/:run_id' do
48
- it 'forwards GET to the executor /runs/:run_id endpoint' do
49
- get "/forest/_internal/workflow-executions/#{run_id}", params: { foo: 'bar' }, headers: auth_headers
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/workflow-executions/#{run_id}", headers: auth_headers
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 'rejects unauthenticated requests with 401' do
71
- get "/forest/_internal/workflow-executions/#{run_id}"
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.status).to eq(401)
74
- expect(HTTParty).not_to have_received(:get)
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
- describe 'POST /forest/_internal/workflow-executions/:run_id/trigger' do
79
- let(:trigger_body) { { step: 'approve', value: 42 } }
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
- it 'forwards POST to the executor /runs/:run_id/trigger endpoint with the body' do
82
- post(
83
- "/forest/_internal/workflow-executions/#{run_id}/trigger",
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
- expect(HTTParty).to have_received(:post).with(
89
- "#{executor_url}/runs/#{run_id}/trigger",
90
- hash_including(
91
- headers: hash_including('Authorization' => "Bearer #{bearer_token}"),
92
- body: trigger_body.to_json
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
- it 'returns the executor response' do
98
- post(
99
- "/forest/_internal/workflow-executions/#{run_id}/trigger",
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(200)
105
- expect(JSON.parse(response.body)).to eq('id' => run_id, 'state' => 'pending')
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(HTTParty::Response, parsed_response: { 'error' => 'invalid_step' }, code: 422)
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/workflow-executions/#{run_id}", headers: auth_headers
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/workflow-executions/#{run_id}", headers: auth_headers
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/workflow-executions/#{run_id}", headers: auth_headers
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.19.0
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-06-10 00:00:00.000000000 Z
11
+ date: 2026-07-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails