From 084cbf8c9b7c16d082f6cc7b4c80e8ae4b8dbc49 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 6 Aug 2026 09:53:35 +0200 Subject: [PATCH 1/9] fix: surface HTTP errors instead of silently skipping schema sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Faraday has no raise_error middleware configured, so a non-2xx response (e.g. 404 on an invalid envSecret) never raised — do_server_want_schema just parsed the JSON body, found no sendSchema key, and treated it as "nothing to send". Add ForestAdminApiRequester#raise_for_response! to check the response status explicitly and raise the same typed errors handle_response_error already maps, and call it from both do_server_want_schema and send_schema_to_server. Co-Authored-By: Claude Sonnet 5 --- .../builder/agent_factory.rb | 4 +- .../http/forest_admin_api_requester.rb | 35 +++++++--- .../builder/agent_factory_spec.rb | 67 ++++++++++++++++++- .../http/forest_admin_api_requester_spec.rb | 52 ++++++++++++++ 4 files changed, 146 insertions(+), 12 deletions(-) diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/builder/agent_factory.rb b/packages/forest_admin_agent/lib/forest_admin_agent/builder/agent_factory.rb index e4f7fba41..cb85ec26d 100644 --- a/packages/forest_admin_agent/lib/forest_admin_agent/builder/agent_factory.rb +++ b/packages/forest_admin_agent/lib/forest_admin_agent/builder/agent_factory.rb @@ -211,6 +211,7 @@ def do_server_want_schema(hash) begin response = client.post('/forest/apimaps/hashcheck', { schemaFileHash: hash }.to_json) + client.raise_for_response!(response) body = JSON.parse(response.body) body['sendSchema'] rescue JSON::ParserError => e @@ -281,7 +282,8 @@ def log_schema_skip def send_schema_to_server(api_map) ForestAdminAgent::Facades::Container.logger.log('Info', 'schema was updated, sending new version') client = ForestAdminAgent::Http::ForestAdminApiRequester.new - client.post('/forest/apimaps', api_map.to_json) + response = client.post('/forest/apimaps', api_map.to_json) + client.raise_for_response!(response) rescue Faraday::Error => e status = e.response[:status] if e.response if status diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/http/forest_admin_api_requester.rb b/packages/forest_admin_agent/lib/forest_admin_agent/http/forest_admin_api_requester.rb index 522476743..42afd35d6 100644 --- a/packages/forest_admin_agent/lib/forest_admin_agent/http/forest_admin_api_requester.rb +++ b/packages/forest_admin_agent/lib/forest_admin_agent/http/forest_admin_api_requester.rb @@ -41,33 +41,48 @@ def handle_response_error(error) ) end - if error.response[:status].zero? || error.response[:status] == 502 + raise_for_status(error.response[:status], cause: error) + end + + # Faraday does not raise on HTTP error statuses here (no raise_error middleware + # configured), so a response has to be checked explicitly to turn e.g. an + # invalid envSecret (404) into the same typed errors as handle_response_error. + def raise_for_response!(response) + return if response.success? + + raise_for_status(response.status) + end + + private + + def raise_for_status(status, cause: nil) + if status.zero? || status == 502 raise BadGatewayError.new( 'Failed to reach ForestAdmin server. Are you online?', - details: { status: error.response[:status] }, - cause: error + details: { status: status }, + cause: cause ) end - if error.response[:status] == 404 + if status == 404 raise NotFoundError.new( 'ForestAdmin server failed to find the project related to the envSecret you configured. Can you check that you copied it properly in the Forest initialization?', - details: { status: error.response[:status] } + details: { status: status } ) end - if error.response[:status] == 503 + if status == 503 raise ServiceUnavailableError.new( 'Forest is in maintenance for a few minutes. We are upgrading your experience in the forest. We just need a few more minutes to get it right.', - details: { status: error.response[:status] }, - cause: error + details: { status: status }, + cause: cause ) end raise InternalServerError.new( 'An unexpected error occurred while contacting the ForestAdmin server. Please contact support@forestadmin.com for further investigations.', - details: { status: error.response[:status], message: error.message }, - cause: error + details: { status: status }, + cause: cause ) end end diff --git a/packages/forest_admin_agent/spec/lib/forest_admin_agent/builder/agent_factory_spec.rb b/packages/forest_admin_agent/spec/lib/forest_admin_agent/builder/agent_factory_spec.rb index fea917258..f8d632349 100644 --- a/packages/forest_admin_agent/spec/lib/forest_admin_agent/builder/agent_factory_spec.rb +++ b/packages/forest_admin_agent/spec/lib/forest_admin_agent/builder/agent_factory_spec.rb @@ -543,12 +543,15 @@ module Builder end it 'logs success message and posts schema when successful' do - allow(client).to receive(:post).with('/forest/apimaps', api_map.to_json) + response = instance_double(Faraday::Response) + allow(client).to receive(:post).with('/forest/apimaps', api_map.to_json).and_return(response) + allow(client).to receive(:raise_for_response!).with(response) instance.send(:send_schema_to_server, api_map) expect(logger).to have_received(:log).with('Info', 'schema was updated, sending new version') expect(client).to have_received(:post).with('/forest/apimaps', api_map.to_json) + expect(client).to have_received(:raise_for_response!).with(response) end context 'when error occurs with HTTP status' do @@ -602,6 +605,68 @@ module Builder end end end + + describe 'do_server_want_schema' do + let(:instance) { described_class.instance } + let(:client) { instance_double(ForestAdminAgent::Http::ForestAdminApiRequester) } + + before do + allow(ForestAdminAgent::Http::ForestAdminApiRequester).to receive(:new).and_return(client) + end + + it 'returns true when the server asks for the schema' do + response = instance_double(Faraday::Response, body: { sendSchema: true }.to_json) + allow(client).to receive(:post).and_return(response) + allow(client).to receive(:raise_for_response!).with(response) + + expect(instance.send(:do_server_want_schema, 'abc123')).to be true + end + + it 'returns false when the server already has this schema' do + response = instance_double(Faraday::Response, body: { sendSchema: false }.to_json) + allow(client).to receive(:post).and_return(response) + allow(client).to receive(:raise_for_response!).with(response) + + expect(instance.send(:do_server_want_schema, 'abc123')).to be false + end + + it 'propagates the error raised by raise_for_response! (e.g. an invalid envSecret)' do + response = instance_double(Faraday::Response, status: 404, body: '{"errors":[]}') + allow(client).to receive(:post).and_return(response) + allow(client).to receive(:raise_for_response!).with(response).and_raise( + ForestAdminAgent::Http::Exceptions::NotFoundError.new( + 'ForestAdmin server failed to find the project related to the envSecret you configured. ' \ + 'Can you check that you copied it properly in the Forest initialization?' + ) + ) + + expect do + instance.send(:do_server_want_schema, 'abc123') + end.to raise_error(ForestAdminAgent::Http::Exceptions::NotFoundError, /envSecret/) + end + + it 'raises InternalServerError when the response body is not valid JSON' do + response = instance_double(Faraday::Response, status: 200, body: 'not json') + allow(client).to receive(:post).and_return(response) + allow(client).to receive(:raise_for_response!).with(response) + + expect do + instance.send(:do_server_want_schema, 'abc123') + end.to raise_error(ForestAdminAgent::Http::Exceptions::InternalServerError, /Invalid JSON response/) + end + + it 'delegates to handle_response_error when the connection itself fails' do + error = Faraday::ConnectionFailed.new('Failed to open TCP connection') + allow(client).to receive(:post).and_raise(error) + allow(client).to receive(:handle_response_error).with(error).and_raise( + ForestAdminAgent::Http::Exceptions::BadGatewayError.new('Failed to reach ForestAdmin server. Are you online?') + ) + + expect do + instance.send(:do_server_want_schema, 'abc123') + end.to raise_error(ForestAdminAgent::Http::Exceptions::BadGatewayError) + end + end end end end diff --git a/packages/forest_admin_agent/spec/lib/forest_admin_agent/http/forest_admin_api_requester_spec.rb b/packages/forest_admin_agent/spec/lib/forest_admin_agent/http/forest_admin_api_requester_spec.rb index 6ed704f2b..83353a1ba 100644 --- a/packages/forest_admin_agent/spec/lib/forest_admin_agent/http/forest_admin_api_requester_spec.rb +++ b/packages/forest_admin_agent/spec/lib/forest_admin_agent/http/forest_admin_api_requester_spec.rb @@ -92,6 +92,58 @@ module Http ) end end + + context 'when raise_for_response! is called' do + it 'does nothing when the response is successful' do + response = instance_double(Faraday::Response, success?: true) + + expect { forest_admin_api_requester.raise_for_response!(response) }.not_to raise_error + end + + it 'raises NotFoundError when the response status is 404 (e.g. an invalid envSecret)' do + response = instance_double(Faraday::Response, success?: false, status: 404) + + expect do + forest_admin_api_requester.raise_for_response!(response) + end.to raise_error( + ForestAdminAgent::Http::Exceptions::NotFoundError, + 'ForestAdmin server failed to find the project related to the envSecret you configured. Can you check that you copied it properly in the Forest initialization?' + ) + end + + it 'raises BadGatewayError when the response status is 502' do + response = instance_double(Faraday::Response, success?: false, status: 502) + + expect do + forest_admin_api_requester.raise_for_response!(response) + end.to raise_error( + ForestAdminAgent::Http::Exceptions::BadGatewayError, + 'Failed to reach ForestAdmin server. Are you online?' + ) + end + + it 'raises ServiceUnavailableError when the response status is 503' do + response = instance_double(Faraday::Response, success?: false, status: 503) + + expect do + forest_admin_api_requester.raise_for_response!(response) + end.to raise_error( + ForestAdminAgent::Http::Exceptions::ServiceUnavailableError, + 'Forest is in maintenance for a few minutes. We are upgrading your experience in the forest. We just need a few more minutes to get it right.' + ) + end + + it 'raises InternalServerError for any other error status' do + response = instance_double(Faraday::Response, success?: false, status: 500) + + expect do + forest_admin_api_requester.raise_for_response!(response) + end.to raise_error( + ForestAdminAgent::Http::Exceptions::InternalServerError, + 'An unexpected error occurred while contacting the ForestAdmin server. Please contact support@forestadmin.com for further investigations.' + ) + end + end end end end From 755831447cd4a0a2e20852359e28c739d07af3a9 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 6 Aug 2026 09:53:56 +0200 Subject: [PATCH 2/9] fix: stop silencing Forest setup errors in production ForestAdminErrorSubscriber#report returned early whenever is_production was true, so any ForestException caught by engine.rb's Rails.error.handle (e.g. a validation error raised during a customization) was reported to zero logs in production, not even at debug level. Log unconditionally and map the real Rails error severity to the matching Forest logger level instead of hardcoding 'Debug'. Also pass severity: :error explicitly on the Rails.error.handle call, since it defaulted to :warning for what are actually blocking setup failures. Co-Authored-By: Claude Sonnet 5 --- .../forest_admin_error_subscriber.rb | 11 +++-- .../lib/forest_admin_rails/engine.rb | 2 +- .../forest_admin_error_subscriber_spec.rb | 41 +++++++++++++++++++ 3 files changed, 50 insertions(+), 4 deletions(-) create mode 100644 packages/forest_admin_rails/spec/config/initializers/forest_admin_error_subscriber_spec.rb diff --git a/packages/forest_admin_rails/config/initializers/forest_admin_error_subscriber.rb b/packages/forest_admin_rails/config/initializers/forest_admin_error_subscriber.rb index b6413e2e7..3d758ceb2 100644 --- a/packages/forest_admin_rails/config/initializers/forest_admin_error_subscriber.rb +++ b/packages/forest_admin_rails/config/initializers/forest_admin_error_subscriber.rb @@ -1,7 +1,12 @@ class ForestAdminErrorSubscriber - def report(error, handled:, severity:, context:, source: nil) - return if ForestAdminAgent::Facades::Container.cache(:is_production) + SEVERITY_TO_LEVEL = { + error: 'Error', + warning: 'Warn', + info: 'Info' + }.freeze - ForestAdminAgent::Facades::Container.logger.log('Debug', error.full_message) + def report(error, handled:, severity:, context:, source: nil) + level = SEVERITY_TO_LEVEL.fetch(severity, 'Error') + ForestAdminAgent::Facades::Container.logger.log(level, "[ForestAdmin] #{error.full_message}") end end diff --git a/packages/forest_admin_rails/lib/forest_admin_rails/engine.rb b/packages/forest_admin_rails/lib/forest_admin_rails/engine.rb index ced010c7c..829667caf 100644 --- a/packages/forest_admin_rails/lib/forest_admin_rails/engine.rb +++ b/packages/forest_admin_rails/lib/forest_admin_rails/engine.rb @@ -29,7 +29,7 @@ class Engine < ::Rails::Engine end config.after_initialize do - Rails.error.handle(ForestAdminDatasourceToolkit::Exceptions::ForestException) do + Rails.error.handle(ForestAdminDatasourceToolkit::Exceptions::ForestException, severity: :error) do agent_factory = ForestAdminAgent::Builder::AgentFactory.instance agent_factory.setup(ForestAdminRails.config) load_configuration diff --git a/packages/forest_admin_rails/spec/config/initializers/forest_admin_error_subscriber_spec.rb b/packages/forest_admin_rails/spec/config/initializers/forest_admin_error_subscriber_spec.rb new file mode 100644 index 000000000..ffadfb69d --- /dev/null +++ b/packages/forest_admin_rails/spec/config/initializers/forest_admin_error_subscriber_spec.rb @@ -0,0 +1,41 @@ +require 'spec_helper' +require 'logger' + +require_relative '../../../config/initializers/forest_admin_error_subscriber' + +RSpec.describe ForestAdminErrorSubscriber do + subject(:subscriber) { described_class.new } + + let(:logger) { instance_double(Logger, log: nil) } + let(:error) { StandardError.new('envSecret invalid') } + + before do + logger_double = logger + container = Class.new + container.define_singleton_method(:logger) { logger_double } + stub_const('ForestAdminAgent::Facades::Container', container) + end + + it 'logs the error even when running in production' do + subscriber.report(error, handled: true, severity: :error, context: {}) + + expect(logger).to have_received(:log).with('Error', /envSecret invalid/) + end + + it 'maps each Rails error severity to the matching Forest logger level' do + subscriber.report(error, handled: true, severity: :error, context: {}) + expect(logger).to have_received(:log).with('Error', anything) + + subscriber.report(error, handled: true, severity: :warning, context: {}) + expect(logger).to have_received(:log).with('Warn', anything) + + subscriber.report(error, handled: true, severity: :info, context: {}) + expect(logger).to have_received(:log).with('Info', anything) + end + + it 'defaults to Error when given an unknown severity' do + subscriber.report(error, handled: true, severity: :unknown, context: {}) + + expect(logger).to have_received(:log).with('Error', anything) + end +end From 7195e262e8dc8a541e10b8b1750248d1db79bb25 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 6 Aug 2026 10:02:17 +0200 Subject: [PATCH 3/9] fix: preserve the diagnostic message on unrecognized HTTP errors raise_for_status dropped the original Faraday error message from the InternalServerError details for unmapped statuses. Default the message to cause&.message for handle_response_error's path, and pass the response's reason_phrase explicitly from raise_for_response!, since there is no exception there to read a message from. Co-Authored-By: Claude Sonnet 5 --- .../http/forest_admin_api_requester.rb | 6 ++--- .../http/forest_admin_api_requester_spec.rb | 22 +++++++++++++++---- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/http/forest_admin_api_requester.rb b/packages/forest_admin_agent/lib/forest_admin_agent/http/forest_admin_api_requester.rb index 42afd35d6..3d2e2cbf9 100644 --- a/packages/forest_admin_agent/lib/forest_admin_agent/http/forest_admin_api_requester.rb +++ b/packages/forest_admin_agent/lib/forest_admin_agent/http/forest_admin_api_requester.rb @@ -50,12 +50,12 @@ def handle_response_error(error) def raise_for_response!(response) return if response.success? - raise_for_status(response.status) + raise_for_status(response.status, message: response.reason_phrase) end private - def raise_for_status(status, cause: nil) + def raise_for_status(status, cause: nil, message: cause&.message) if status.zero? || status == 502 raise BadGatewayError.new( 'Failed to reach ForestAdmin server. Are you online?', @@ -81,7 +81,7 @@ def raise_for_status(status, cause: nil) raise InternalServerError.new( 'An unexpected error occurred while contacting the ForestAdmin server. Please contact support@forestadmin.com for further investigations.', - details: { status: status }, + details: { status: status, message: message }, cause: cause ) end diff --git a/packages/forest_admin_agent/spec/lib/forest_admin_agent/http/forest_admin_api_requester_spec.rb b/packages/forest_admin_agent/spec/lib/forest_admin_agent/http/forest_admin_api_requester_spec.rb index 83353a1ba..aaf371ddd 100644 --- a/packages/forest_admin_agent/spec/lib/forest_admin_agent/http/forest_admin_api_requester_spec.rb +++ b/packages/forest_admin_agent/spec/lib/forest_admin_agent/http/forest_admin_api_requester_spec.rb @@ -91,6 +91,12 @@ module Http 'An unexpected error occurred while contacting the ForestAdmin server. Please contact support@forestadmin.com for further investigations.' ) end + + it 'keeps the original error message in details for diagnostics' do + expect do + forest_admin_api_requester.handle_response_error(Faraday::ConnectionFailed.new('test', { status: 500 })) + end.to(raise_error { |error| expect(error.details[:message]).to eq('test') }) + end end context 'when raise_for_response! is called' do @@ -101,7 +107,7 @@ module Http end it 'raises NotFoundError when the response status is 404 (e.g. an invalid envSecret)' do - response = instance_double(Faraday::Response, success?: false, status: 404) + response = instance_double(Faraday::Response, success?: false, status: 404, reason_phrase: 'Not Found') expect do forest_admin_api_requester.raise_for_response!(response) @@ -112,7 +118,7 @@ module Http end it 'raises BadGatewayError when the response status is 502' do - response = instance_double(Faraday::Response, success?: false, status: 502) + response = instance_double(Faraday::Response, success?: false, status: 502, reason_phrase: 'Bad Gateway') expect do forest_admin_api_requester.raise_for_response!(response) @@ -123,7 +129,7 @@ module Http end it 'raises ServiceUnavailableError when the response status is 503' do - response = instance_double(Faraday::Response, success?: false, status: 503) + response = instance_double(Faraday::Response, success?: false, status: 503, reason_phrase: 'Service Unavailable') expect do forest_admin_api_requester.raise_for_response!(response) @@ -134,7 +140,7 @@ module Http end it 'raises InternalServerError for any other error status' do - response = instance_double(Faraday::Response, success?: false, status: 500) + response = instance_double(Faraday::Response, success?: false, status: 500, reason_phrase: 'Internal Server Error') expect do forest_admin_api_requester.raise_for_response!(response) @@ -143,6 +149,14 @@ module Http 'An unexpected error occurred while contacting the ForestAdmin server. Please contact support@forestadmin.com for further investigations.' ) end + + it 'keeps the response reason phrase in details for diagnostics' do + response = instance_double(Faraday::Response, success?: false, status: 500, reason_phrase: 'Internal Server Error') + + expect do + forest_admin_api_requester.raise_for_response!(response) + end.to(raise_error { |error| expect(error.details[:message]).to eq('Internal Server Error') }) + end end end end From 34e7935f8d647b8507038982f14f33a6e773c275 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 6 Aug 2026 11:27:29 +0200 Subject: [PATCH 4/9] fix: validate env_secret/auth_secret format before contacting Forest Mirrors the Node agent's OptionsValidator: env_secret must be a 64-character lowercase hex string and auth_secret must be a string, checked once at AgentFactory#setup (before any HTTP call is made). This catches the exact mistake seen in the wild -- an envSecret copied with the variable name still glued to the value -- instantly and without needing network access, instead of only surfacing once the hashcheck request comes back with a 404. Co-Authored-By: Claude Sonnet 5 --- .../builder/agent_factory.rb | 19 ++++++++++ .../builder/agent_factory_spec.rb | 38 +++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/builder/agent_factory.rb b/packages/forest_admin_agent/lib/forest_admin_agent/builder/agent_factory.rb index cb85ec26d..12e524c2d 100644 --- a/packages/forest_admin_agent/lib/forest_admin_agent/builder/agent_factory.rb +++ b/packages/forest_admin_agent/lib/forest_admin_agent/builder/agent_factory.rb @@ -13,6 +13,8 @@ class AgentFactory attr_reader :customizer, :container, :has_env_secret attr_accessor :schema_only_mode + ENV_SECRET_FORMAT = /\A[0-9a-f]{64}\z/ + def initialize super @reloading = false @@ -21,6 +23,7 @@ def initialize def setup(options) @options = options @has_env_secret = options.to_h.key?(:env_secret) + validate_secrets_format! if @has_env_secret @customizer = ForestAdminDatasourceCustomizer::DatasourceCustomizer.new build_container build_cache @@ -175,6 +178,22 @@ def write_schema_file(schema_path, schema) private + def validate_secrets_format! + env_secret = @options.to_h[:env_secret] + + unless env_secret.is_a?(String) && env_secret.match?(ENV_SECRET_FORMAT) + raise ForestAdminAgent::Http::Exceptions::ValidationError, + 'config.env_secret is invalid: it must be the 64-character hexadecimal secret from your ' \ + 'Forest Admin project settings.' + end + + auth_secret = @options.to_h[:auth_secret] + return if auth_secret.is_a?(String) + + raise ForestAdminAgent::Http::Exceptions::ValidationError, + 'config.auth_secret is invalid: it must be a string. Any long random value works.' + end + def container_replace(key, value) @container._container.delete(key.to_s) @container.register(key, value) diff --git a/packages/forest_admin_agent/spec/lib/forest_admin_agent/builder/agent_factory_spec.rb b/packages/forest_admin_agent/spec/lib/forest_admin_agent/builder/agent_factory_spec.rb index f8d632349..c853630e0 100644 --- a/packages/forest_admin_agent/spec/lib/forest_admin_agent/builder/agent_factory_spec.rb +++ b/packages/forest_admin_agent/spec/lib/forest_admin_agent/builder/agent_factory_spec.rb @@ -24,6 +24,44 @@ module Builder expect(described_class.instance.container.resolve(:logger)).not_to be_nil expect(described_class.instance.container.resolve(:logger)).to be_instance_of Services::LoggerService end + + context 'when env_secret is present but malformed' do + let(:instance) { described_class.instance } + let(:valid_options) do + { + auth_secret: 'cba803d01a4d43b55010cab41fa1ea1f1f51a95e', + env_secret: '89719c6d8e2e2de2694c2f220fe2dbf02d5289487364daf1e4c6b13733ed0cdb' + } + end + + it 'raises a ValidationError when env_secret is too short' do + expect do + instance.setup(valid_options.merge(env_secret: 'abc123')) + end.to raise_error(ForestAdminAgent::Http::Exceptions::ValidationError, /config\.env_secret is invalid/) + end + + it 'raises a ValidationError when env_secret contains the variable name (a common copy-paste mistake)' do + expect do + instance.setup(valid_options.merge(env_secret: "FOREST_ENV_SECRET=#{valid_options[:env_secret]}")) + end.to raise_error(ForestAdminAgent::Http::Exceptions::ValidationError, /config\.env_secret is invalid/) + end + + it 'raises a ValidationError when env_secret has uppercase characters' do + expect do + instance.setup(valid_options.merge(env_secret: valid_options[:env_secret].upcase)) + end.to raise_error(ForestAdminAgent::Http::Exceptions::ValidationError, /config\.env_secret is invalid/) + end + + it 'raises a ValidationError when auth_secret is not a string' do + expect do + instance.setup(valid_options.merge(auth_secret: 42)) + end.to raise_error(ForestAdminAgent::Http::Exceptions::ValidationError, /config\.auth_secret is invalid/) + end + + it 'does not raise when both secrets are well-formed' do + expect { instance.setup(valid_options) }.not_to raise_error + end + end end describe 'add_datasource' do From d7b15352481426bb7ccca1702b5b89bb3a02cf17 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 6 Aug 2026 12:01:27 +0200 Subject: [PATCH 5/9] fix: don't block dev boot on a malformed env_secret/auth_secret Blocking the whole Rails boot on a config mistake makes sense in production (see the previous commit), but is too disruptive in dev: a typo in a local secret used to boot fine before this PR, and should still. Warn loudly instead ([ForestAdmin] ... 'Warn' log lines) and skip the schema sync until it's fixed, without raising, in every non-production environment. Co-Authored-By: Claude Sonnet 5 --- .../builder/agent_factory.rb | 30 ++++++-- .../builder/agent_factory_spec.rb | 75 +++++++++++++------ 2 files changed, 77 insertions(+), 28 deletions(-) diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/builder/agent_factory.rb b/packages/forest_admin_agent/lib/forest_admin_agent/builder/agent_factory.rb index 12e524c2d..c3b04beeb 100644 --- a/packages/forest_admin_agent/lib/forest_admin_agent/builder/agent_factory.rb +++ b/packages/forest_admin_agent/lib/forest_admin_agent/builder/agent_factory.rb @@ -23,11 +23,12 @@ def initialize def setup(options) @options = options @has_env_secret = options.to_h.key?(:env_secret) - validate_secrets_format! if @has_env_secret + @secrets_format_invalid = false @customizer = ForestAdminDatasourceCustomizer::DatasourceCustomizer.new build_container build_cache build_logger + validate_secrets_format! if @has_env_secret end def add_datasource(datasource, options = {}) @@ -118,6 +119,7 @@ def send_schema(force: false) end return unless @has_env_secret + return if @secrets_format_invalid schema = generate_schema_file @@ -179,19 +181,33 @@ def write_schema_file(schema_path, schema) private def validate_secrets_format! + errors = secret_format_errors + return if errors.empty? + + raise ForestAdminAgent::Http::Exceptions::ValidationError, errors.join(' ') if @options.to_h[:is_production] + + # Don't block boot on a config mistake in dev: warn loudly and skip the schema + # sync instead, so the developer can still work on the rest of the app. + errors.each { |error| @logger.log('Warn', "[ForestAdmin] #{error}") } + @logger.log('Warn', '[ForestAdmin] Skipping schema sync until this is fixed.') + @secrets_format_invalid = true + end + + def secret_format_errors + errors = [] env_secret = @options.to_h[:env_secret] unless env_secret.is_a?(String) && env_secret.match?(ENV_SECRET_FORMAT) - raise ForestAdminAgent::Http::Exceptions::ValidationError, - 'config.env_secret is invalid: it must be the 64-character hexadecimal secret from your ' \ - 'Forest Admin project settings.' + errors << 'config.env_secret is invalid: it must be the 64-character hexadecimal secret from your ' \ + 'Forest Admin project settings.' end auth_secret = @options.to_h[:auth_secret] - return if auth_secret.is_a?(String) + unless auth_secret.is_a?(String) + errors << 'config.auth_secret is invalid: it must be a string. Any long random value works.' + end - raise ForestAdminAgent::Http::Exceptions::ValidationError, - 'config.auth_secret is invalid: it must be a string. Any long random value works.' + errors end def container_replace(key, value) diff --git a/packages/forest_admin_agent/spec/lib/forest_admin_agent/builder/agent_factory_spec.rb b/packages/forest_admin_agent/spec/lib/forest_admin_agent/builder/agent_factory_spec.rb index c853630e0..ba12ccb19 100644 --- a/packages/forest_admin_agent/spec/lib/forest_admin_agent/builder/agent_factory_spec.rb +++ b/packages/forest_admin_agent/spec/lib/forest_admin_agent/builder/agent_factory_spec.rb @@ -34,32 +34,65 @@ module Builder } end - it 'raises a ValidationError when env_secret is too short' do - expect do - instance.setup(valid_options.merge(env_secret: 'abc123')) - end.to raise_error(ForestAdminAgent::Http::Exceptions::ValidationError, /config\.env_secret is invalid/) + context 'when running in production' do + let(:prod_options) { valid_options.merge(is_production: true) } + + it 'raises a ValidationError when env_secret is too short' do + expect do + instance.setup(prod_options.merge(env_secret: 'abc123')) + end.to raise_error(ForestAdminAgent::Http::Exceptions::ValidationError, /config\.env_secret is invalid/) + end + + it 'raises a ValidationError when env_secret contains the variable name (a common copy-paste mistake)' do + expect do + instance.setup(prod_options.merge(env_secret: "FOREST_ENV_SECRET=#{valid_options[:env_secret]}")) + end.to raise_error(ForestAdminAgent::Http::Exceptions::ValidationError, /config\.env_secret is invalid/) + end + + it 'raises a ValidationError when env_secret has uppercase characters' do + expect do + instance.setup(prod_options.merge(env_secret: valid_options[:env_secret].upcase)) + end.to raise_error(ForestAdminAgent::Http::Exceptions::ValidationError, /config\.env_secret is invalid/) + end + + it 'raises a ValidationError when auth_secret is not a string' do + expect do + instance.setup(prod_options.merge(auth_secret: 42)) + end.to raise_error(ForestAdminAgent::Http::Exceptions::ValidationError, /config\.auth_secret is invalid/) + end + + it 'does not raise when both secrets are well-formed' do + expect { instance.setup(prod_options) }.not_to raise_error + end end - it 'raises a ValidationError when env_secret contains the variable name (a common copy-paste mistake)' do - expect do - instance.setup(valid_options.merge(env_secret: "FOREST_ENV_SECRET=#{valid_options[:env_secret]}")) - end.to raise_error(ForestAdminAgent::Http::Exceptions::ValidationError, /config\.env_secret is invalid/) - end + context 'when running outside production' do + let(:dev_options) { valid_options.merge(is_production: false) } - it 'raises a ValidationError when env_secret has uppercase characters' do - expect do - instance.setup(valid_options.merge(env_secret: valid_options[:env_secret].upcase)) - end.to raise_error(ForestAdminAgent::Http::Exceptions::ValidationError, /config\.env_secret is invalid/) - end + it 'does not raise, warns instead, and skips the schema sync' do + logger = instance_spy(Services::LoggerService) + allow(Services::LoggerService).to receive(:new).and_return(logger) - it 'raises a ValidationError when auth_secret is not a string' do - expect do - instance.setup(valid_options.merge(auth_secret: 42)) - end.to raise_error(ForestAdminAgent::Http::Exceptions::ValidationError, /config\.auth_secret is invalid/) - end + expect { instance.setup(dev_options.merge(env_secret: 'abc123')) }.not_to raise_error + + expect(logger).to have_received(:log).with('Warn', /config\.env_secret is invalid/) + expect(logger).to have_received(:log).with('Warn', /Skipping schema sync/) + + allow(Facades::Container).to receive(:cache).with(:skip_schema_update).and_return(false) + allow(instance).to receive(:generate_schema_file) + instance.send_schema + + expect(instance).not_to have_received(:generate_schema_file) + end + + it 'does not raise and does not warn when both secrets are well-formed' do + logger = instance_spy(Services::LoggerService) + allow(Services::LoggerService).to receive(:new).and_return(logger) + + expect { instance.setup(dev_options) }.not_to raise_error - it 'does not raise when both secrets are well-formed' do - expect { instance.setup(valid_options) }.not_to raise_error + expect(logger).not_to have_received(:log).with('Warn', /is invalid/) + end end end end From a2b40812711a9033fdfbb7021a7babbfe2bfb0fc Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 6 Aug 2026 15:16:32 +0200 Subject: [PATCH 6/9] fix: don't validate secrets that were never actually configured @has_env_secret checked whether the :env_secret key was present in the options hash, which is always true for Dry::Configurable settings even when the value is nil. Any app bundling forest_admin_rpc_agent as a Gemfile dependency without configuring it (env_secret/auth_secret left at their nil default) would trigger the new format validation and crash boot in production, despite never having set up that agent. Check for a non-nil value instead, restoring the original silent no-op for a genuinely unconfigured secret. Co-Authored-By: Claude Sonnet 5 --- .../forest_admin_agent/builder/agent_factory.rb | 2 +- .../builder/agent_factory_spec.rb | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/builder/agent_factory.rb b/packages/forest_admin_agent/lib/forest_admin_agent/builder/agent_factory.rb index c3b04beeb..e56eae4e8 100644 --- a/packages/forest_admin_agent/lib/forest_admin_agent/builder/agent_factory.rb +++ b/packages/forest_admin_agent/lib/forest_admin_agent/builder/agent_factory.rb @@ -22,7 +22,7 @@ def initialize def setup(options) @options = options - @has_env_secret = options.to_h.key?(:env_secret) + @has_env_secret = !options.to_h[:env_secret].nil? @secrets_format_invalid = false @customizer = ForestAdminDatasourceCustomizer::DatasourceCustomizer.new build_container diff --git a/packages/forest_admin_agent/spec/lib/forest_admin_agent/builder/agent_factory_spec.rb b/packages/forest_admin_agent/spec/lib/forest_admin_agent/builder/agent_factory_spec.rb index ba12ccb19..ed1356230 100644 --- a/packages/forest_admin_agent/spec/lib/forest_admin_agent/builder/agent_factory_spec.rb +++ b/packages/forest_admin_agent/spec/lib/forest_admin_agent/builder/agent_factory_spec.rb @@ -25,6 +25,22 @@ module Builder expect(described_class.instance.container.resolve(:logger)).to be_instance_of Services::LoggerService end + context 'when env_secret key is present but nil (e.g. an unconfigured sibling agent, like ' \ + 'ForestAdminRpcAgent bundled but never set up)' do + it 'sets @has_env_secret to false and skips validation entirely, without warning or raising' do + instance = described_class.instance + logger = instance_spy(Services::LoggerService) + allow(Services::LoggerService).to receive(:new).and_return(logger) + + expect do + instance.setup(auth_secret: nil, env_secret: nil, is_production: true) + end.not_to raise_error + + expect(instance.has_env_secret).to be false + expect(logger).not_to have_received(:log).with('Warn', anything) + end + end + context 'when env_secret is present but malformed' do let(:instance) { described_class.instance } let(:valid_options) do From edf5fdc4ec3b8e18b2945f7bafe629e29ae0b662 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 6 Aug 2026 15:37:48 +0200 Subject: [PATCH 7/9] fix: don't validate secrets when running in schema-only mode validate_secrets_format! ran during AgentFactory#setup, before callers get a chance to set schema_only_mode. rake forest_admin:schema:generate sets it right after setup, so a malformed (or placeholder) env_secret would raise in production and block offline schema generation even though that mode never syncs to the server and never makes an HTTP request. Moved the check into send_schema, which schema-only mode never calls, and dropped the now-unneeded @secrets_format_invalid ivar in favor of a plain return value. Co-Authored-By: Claude Sonnet 5 --- .../builder/agent_factory.rb | 12 +- .../builder/agent_factory_spec.rb | 155 +++++++++++------- 2 files changed, 105 insertions(+), 62 deletions(-) diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/builder/agent_factory.rb b/packages/forest_admin_agent/lib/forest_admin_agent/builder/agent_factory.rb index e56eae4e8..38772c9f6 100644 --- a/packages/forest_admin_agent/lib/forest_admin_agent/builder/agent_factory.rb +++ b/packages/forest_admin_agent/lib/forest_admin_agent/builder/agent_factory.rb @@ -23,12 +23,10 @@ def initialize def setup(options) @options = options @has_env_secret = !options.to_h[:env_secret].nil? - @secrets_format_invalid = false @customizer = ForestAdminDatasourceCustomizer::DatasourceCustomizer.new build_container build_cache build_logger - validate_secrets_format! if @has_env_secret end def add_datasource(datasource, options = {}) @@ -119,7 +117,7 @@ def send_schema(force: false) end return unless @has_env_secret - return if @secrets_format_invalid + return unless secrets_format_valid? schema = generate_schema_file @@ -180,9 +178,11 @@ def write_schema_file(schema_path, schema) private - def validate_secrets_format! + # Checked from send_schema rather than setup, so that schema-only mode (which never + # syncs to the server) never fails on a secret it doesn't actually need. + def secrets_format_valid? errors = secret_format_errors - return if errors.empty? + return true if errors.empty? raise ForestAdminAgent::Http::Exceptions::ValidationError, errors.join(' ') if @options.to_h[:is_production] @@ -190,7 +190,7 @@ def validate_secrets_format! # sync instead, so the developer can still work on the rest of the app. errors.each { |error| @logger.log('Warn', "[ForestAdmin] #{error}") } @logger.log('Warn', '[ForestAdmin] Skipping schema sync until this is fixed.') - @secrets_format_invalid = true + false end def secret_format_errors diff --git a/packages/forest_admin_agent/spec/lib/forest_admin_agent/builder/agent_factory_spec.rb b/packages/forest_admin_agent/spec/lib/forest_admin_agent/builder/agent_factory_spec.rb index ed1356230..32149fdb1 100644 --- a/packages/forest_admin_agent/spec/lib/forest_admin_agent/builder/agent_factory_spec.rb +++ b/packages/forest_admin_agent/spec/lib/forest_admin_agent/builder/agent_factory_spec.rb @@ -31,84 +31,127 @@ module Builder instance = described_class.instance logger = instance_spy(Services::LoggerService) allow(Services::LoggerService).to receive(:new).and_return(logger) + allow(Facades::Container).to receive(:cache).with(:skip_schema_update).and_return(false) expect do instance.setup(auth_secret: nil, env_secret: nil, is_production: true) end.not_to raise_error expect(instance.has_env_secret).to be false + + instance.send_schema + expect(logger).not_to have_received(:log).with('Warn', anything) end end - context 'when env_secret is present but malformed' do - let(:instance) { described_class.instance } - let(:valid_options) do - { - auth_secret: 'cba803d01a4d43b55010cab41fa1ea1f1f51a95e', - env_secret: '89719c6d8e2e2de2694c2f220fe2dbf02d5289487364daf1e4c6b13733ed0cdb' - } + context 'with schema_only_mode enabled (offline schema generation, e.g. ' \ + 'rake forest_admin:schema:generate)' do + it 'never validates secrets, even a malformed env_secret in production' do + instance = described_class.instance + + expect do + instance.setup( + auth_secret: 'cba803d01a4d43b55010cab41fa1ea1f1f51a95e', + env_secret: 'not-a-valid-secret', + is_production: true + ) + end.not_to raise_error + + instance.schema_only_mode = true + allow(instance).to receive(:generate_schema_only) + + expect { instance.build }.not_to raise_error + expect(instance).to have_received(:generate_schema_only) + ensure + instance.schema_only_mode = false + end + end + end + + context 'when env_secret is present but malformed' do + let(:instance) { described_class.instance } + let(:valid_options) do + { + auth_secret: 'cba803d01a4d43b55010cab41fa1ea1f1f51a95e', + env_secret: '89719c6d8e2e2de2694c2f220fe2dbf02d5289487364daf1e4c6b13733ed0cdb' + } + end + + before do + allow(Facades::Container).to receive(:cache).with(:skip_schema_update).and_return(false) + end + + context 'when running in production' do + let(:prod_options) { valid_options.merge(is_production: true) } + + it 'raises a ValidationError when env_secret is too short' do + instance.setup(prod_options.merge(env_secret: 'abc123')) + + expect { instance.send_schema }.to raise_error( + ForestAdminAgent::Http::Exceptions::ValidationError, /config\.env_secret is invalid/ + ) + end + + it 'raises a ValidationError when env_secret contains the variable name (a common copy-paste mistake)' do + instance.setup(prod_options.merge(env_secret: "FOREST_ENV_SECRET=#{valid_options[:env_secret]}")) + + expect { instance.send_schema }.to raise_error( + ForestAdminAgent::Http::Exceptions::ValidationError, /config\.env_secret is invalid/ + ) + end + + it 'raises a ValidationError when env_secret has uppercase characters' do + instance.setup(prod_options.merge(env_secret: valid_options[:env_secret].upcase)) + + expect { instance.send_schema }.to raise_error( + ForestAdminAgent::Http::Exceptions::ValidationError, /config\.env_secret is invalid/ + ) end - context 'when running in production' do - let(:prod_options) { valid_options.merge(is_production: true) } - - it 'raises a ValidationError when env_secret is too short' do - expect do - instance.setup(prod_options.merge(env_secret: 'abc123')) - end.to raise_error(ForestAdminAgent::Http::Exceptions::ValidationError, /config\.env_secret is invalid/) - end - - it 'raises a ValidationError when env_secret contains the variable name (a common copy-paste mistake)' do - expect do - instance.setup(prod_options.merge(env_secret: "FOREST_ENV_SECRET=#{valid_options[:env_secret]}")) - end.to raise_error(ForestAdminAgent::Http::Exceptions::ValidationError, /config\.env_secret is invalid/) - end - - it 'raises a ValidationError when env_secret has uppercase characters' do - expect do - instance.setup(prod_options.merge(env_secret: valid_options[:env_secret].upcase)) - end.to raise_error(ForestAdminAgent::Http::Exceptions::ValidationError, /config\.env_secret is invalid/) - end - - it 'raises a ValidationError when auth_secret is not a string' do - expect do - instance.setup(prod_options.merge(auth_secret: 42)) - end.to raise_error(ForestAdminAgent::Http::Exceptions::ValidationError, /config\.auth_secret is invalid/) - end - - it 'does not raise when both secrets are well-formed' do - expect { instance.setup(prod_options) }.not_to raise_error - end + it 'raises a ValidationError when auth_secret is not a string' do + instance.setup(prod_options.merge(auth_secret: 42)) + + expect { instance.send_schema }.to raise_error( + ForestAdminAgent::Http::Exceptions::ValidationError, /config\.auth_secret is invalid/ + ) end - context 'when running outside production' do - let(:dev_options) { valid_options.merge(is_production: false) } + it 'does not raise when both secrets are well-formed' do + instance.setup(prod_options) + allow(instance).to receive_messages(generate_schema_file: { meta: {}, collections: [] }, post_schema: nil) + allow(Facades::Container).to receive(:cache).with(:append_schema_path).and_return(nil) - it 'does not raise, warns instead, and skips the schema sync' do - logger = instance_spy(Services::LoggerService) - allow(Services::LoggerService).to receive(:new).and_return(logger) + expect { instance.send_schema }.not_to raise_error + end + end - expect { instance.setup(dev_options.merge(env_secret: 'abc123')) }.not_to raise_error + context 'when running outside production' do + let(:dev_options) { valid_options.merge(is_production: false) } - expect(logger).to have_received(:log).with('Warn', /config\.env_secret is invalid/) - expect(logger).to have_received(:log).with('Warn', /Skipping schema sync/) + it 'does not raise, warns instead, and skips the schema sync' do + logger = instance_spy(Services::LoggerService) + allow(Services::LoggerService).to receive(:new).and_return(logger) + instance.setup(dev_options.merge(env_secret: 'abc123')) + allow(instance).to receive(:generate_schema_file) - allow(Facades::Container).to receive(:cache).with(:skip_schema_update).and_return(false) - allow(instance).to receive(:generate_schema_file) - instance.send_schema + expect { instance.send_schema }.not_to raise_error - expect(instance).not_to have_received(:generate_schema_file) - end + expect(logger).to have_received(:log).with('Warn', /config\.env_secret is invalid/) + expect(logger).to have_received(:log).with('Warn', /Skipping schema sync/) + expect(instance).not_to have_received(:generate_schema_file) + end - it 'does not raise and does not warn when both secrets are well-formed' do - logger = instance_spy(Services::LoggerService) - allow(Services::LoggerService).to receive(:new).and_return(logger) + it 'does not raise and does not warn when both secrets are well-formed' do + logger = instance_spy(Services::LoggerService) + allow(Services::LoggerService).to receive(:new).and_return(logger) + instance.setup(dev_options) + allow(instance).to receive_messages(generate_schema_file: { meta: {}, collections: [] }, post_schema: nil) + allow(Facades::Container).to receive(:cache).with(:append_schema_path).and_return(nil) - expect { instance.setup(dev_options) }.not_to raise_error + expect { instance.send_schema }.not_to raise_error - expect(logger).not_to have_received(:log).with('Warn', /is invalid/) - end + expect(logger).not_to have_received(:log).with('Warn', /is invalid/) end end end From be4b0dcf18dc0094f33dbd8a7d4ed92b9e9ee20c Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 6 Aug 2026 15:58:54 +0200 Subject: [PATCH 8/9] fix: don't crash dev boot when a well-formed secret is rejected by the server secrets_format_valid? only guarded against a malformed env_secret/ auth_secret; a syntactically valid but wrong (revoked, wrong project, copy-pasted from another env) secret still surfaces as a raised error from the actual HTTP call and crashed dev boot the same way it crashes production, even though the whole point of the earlier dev/prod split was to never block dev on a Forest connectivity problem. Wrap the schema generation/send in send_schema with the same rule: re-raise in production, warn and move on everywhere else. Also switched both prod/dev checks to read Facades::Container.cache(:is_production) instead of @options.to_h[:is_production] directly, for consistency with the rest of the class and to fix two existing specs that stub the cache directly without going through a full setup call. Co-Authored-By: Claude Sonnet 5 --- .../builder/agent_factory.rb | 12 ++- .../builder/agent_factory_spec.rb | 76 ++++++++++++++++--- 2 files changed, 78 insertions(+), 10 deletions(-) diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/builder/agent_factory.rb b/packages/forest_admin_agent/lib/forest_admin_agent/builder/agent_factory.rb index 38772c9f6..630d51d7a 100644 --- a/packages/forest_admin_agent/lib/forest_admin_agent/builder/agent_factory.rb +++ b/packages/forest_admin_agent/lib/forest_admin_agent/builder/agent_factory.rb @@ -131,6 +131,14 @@ def send_schema(force: false) end post_schema(schema, force) + rescue StandardError => e + # A well-formed secret can still be rejected by the server (wrong project, revoked + # secret, network down, ...). Same rule as an invalid format: block boot in + # production, but never in dev - warn and move on instead. + raise e if Facades::Container.cache(:is_production) + + @logger.log('Warn', "[ForestAdmin] #{e.message}") + @logger.log('Warn', '[ForestAdmin] Schema sync failed, continuing without it.') end # Generates or loads the schema and writes it to file (in development mode). @@ -184,7 +192,9 @@ def secrets_format_valid? errors = secret_format_errors return true if errors.empty? - raise ForestAdminAgent::Http::Exceptions::ValidationError, errors.join(' ') if @options.to_h[:is_production] + if Facades::Container.cache(:is_production) + raise ForestAdminAgent::Http::Exceptions::ValidationError, errors.join(' ') + end # Don't block boot on a config mistake in dev: warn loudly and skip the schema # sync instead, so the developer can still work on the rest of the app. diff --git a/packages/forest_admin_agent/spec/lib/forest_admin_agent/builder/agent_factory_spec.rb b/packages/forest_admin_agent/spec/lib/forest_admin_agent/builder/agent_factory_spec.rb index 32149fdb1..a365751ed 100644 --- a/packages/forest_admin_agent/spec/lib/forest_admin_agent/builder/agent_factory_spec.rb +++ b/packages/forest_admin_agent/spec/lib/forest_admin_agent/builder/agent_factory_spec.rb @@ -74,14 +74,12 @@ module Builder let(:valid_options) do { auth_secret: 'cba803d01a4d43b55010cab41fa1ea1f1f51a95e', - env_secret: '89719c6d8e2e2de2694c2f220fe2dbf02d5289487364daf1e4c6b13733ed0cdb' + env_secret: '89719c6d8e2e2de2694c2f220fe2dbf02d5289487364daf1e4c6b13733ed0cdb', + skip_schema_update: false, + append_schema_path: nil } end - before do - allow(Facades::Container).to receive(:cache).with(:skip_schema_update).and_return(false) - end - context 'when running in production' do let(:prod_options) { valid_options.merge(is_production: true) } @@ -120,7 +118,6 @@ module Builder it 'does not raise when both secrets are well-formed' do instance.setup(prod_options) allow(instance).to receive_messages(generate_schema_file: { meta: {}, collections: [] }, post_schema: nil) - allow(Facades::Container).to receive(:cache).with(:append_schema_path).and_return(nil) expect { instance.send_schema }.not_to raise_error end @@ -147,7 +144,6 @@ module Builder allow(Services::LoggerService).to receive(:new).and_return(logger) instance.setup(dev_options) allow(instance).to receive_messages(generate_schema_file: { meta: {}, collections: [] }, post_schema: nil) - allow(Facades::Container).to receive(:cache).with(:append_schema_path).and_return(nil) expect { instance.send_schema }.not_to raise_error @@ -156,6 +152,45 @@ module Builder end end + context 'when the secret is well-formed but rejected by the server' do + let(:instance) { described_class.instance } + let(:valid_options) do + { + auth_secret: 'cba803d01a4d43b55010cab41fa1ea1f1f51a95e', + env_secret: '89719c6d8e2e2de2694c2f220fe2dbf02d5289487364daf1e4c6b13733ed0cdb', + skip_schema_update: false + } + end + let(:not_found_error) do + ForestAdminAgent::Http::Exceptions::NotFoundError.new( + 'ForestAdmin server failed to find the project related to the envSecret you configured.' + ) + end + + context 'when running in production' do + it 'raises the error the server returned' do + instance.setup(valid_options.merge(is_production: true)) + allow(instance).to receive(:generate_schema_file).and_raise(not_found_error) + + expect { instance.send_schema }.to raise_error(ForestAdminAgent::Http::Exceptions::NotFoundError) + end + end + + context 'when running outside production' do + it 'does not raise, warns instead, and continues without the schema' do + logger = instance_spy(Services::LoggerService) + allow(Services::LoggerService).to receive(:new).and_return(logger) + instance.setup(valid_options.merge(is_production: false)) + allow(instance).to receive(:generate_schema_file).and_raise(not_found_error) + + expect { instance.send_schema }.not_to raise_error + + expect(logger).to have_received(:log).with('Warn', /failed to find the project/) + expect(logger).to have_received(:log).with('Warn', /Schema sync failed/) + end + end + end + describe 'add_datasource' do it 'add collections to the customizer datasource' do datasource = ForestAdminDatasourceToolkit::Datasource.new @@ -443,13 +478,34 @@ module Builder expect(instance).to have_received(:post_schema).with(hash_including(collections: [{ name: 'Main' }, { name: 'Extra' }]), anything) end - it 'raises error if append_schema file cannot be loaded' do + it 'raises error if append_schema file cannot be loaded, in production' do instance = described_class.instance instance.instance_variable_set(:@has_env_secret, true) datasource = instance_double(ForestAdminDatasourceToolkit::Datasource) instance.container.register(:datasource, datasource) + allow(Facades::Container).to receive(:cache).with(:skip_schema_update).and_return(false) + allow(Facades::Container).to receive(:cache).with(:schema_path).and_return('/path/to/schema.json') + allow(Facades::Container).to receive(:cache).with(:is_production).and_return(true) + allow(Facades::Container).to receive(:cache).with(:append_schema_path).and_return('/path/to/append.json') + allow(ForestAdminAgent::Utils::Schema::SchemaEmitter).to receive_messages(generate: [], meta: {}) + allow(File).to receive(:exist?).with('/path/to/schema.json').and_return(true) + allow(File).to receive(:read).with('/path/to/schema.json').and_return({ meta: {}, collections: [] }.to_json) + allow(File).to receive(:read).with('/path/to/append.json').and_raise(Errno::ENOENT) + + expect { instance.send_schema }.to raise_error(/Can't load additional schema/) + end + + it 'warns instead of raising if append_schema file cannot be loaded, outside production' do + instance = described_class.instance + instance.instance_variable_set(:@has_env_secret, true) + logger = instance_spy(Services::LoggerService) + instance.instance_variable_set(:@logger, logger) + + datasource = instance_double(ForestAdminDatasourceToolkit::Datasource) + instance.container.register(:datasource, datasource) + allow(Facades::Container).to receive(:cache).with(:skip_schema_update).and_return(false) allow(Facades::Container).to receive(:cache).with(:schema_path).and_return('/path/to/schema.json') allow(Facades::Container).to receive(:cache).with(:is_production).and_return(false) @@ -458,7 +514,9 @@ module Builder allow(File).to receive(:write) allow(File).to receive(:read).with('/path/to/append.json').and_raise(Errno::ENOENT) - expect { instance.send_schema }.to raise_error(/Can't load additional schema/) + expect { instance.send_schema }.not_to raise_error + + expect(logger).to have_received(:log).with('Warn', /Can't load additional schema/) end context 'with skip_schema_update enabled' do From 9225c8ed5e015269d0c44b42b28117aee698fae2 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 6 Aug 2026 16:09:27 +0200 Subject: [PATCH 9/9] fix: scope the dev/prod rescue in send_schema to the server sync only The previous rescue wrapped the whole method body, so a local bug (a broken customization, an unwritable schema_path, a malformed append_schema file) was silently logged as a generic Forest warning outside production instead of surfacing to the developer. Move the rescue to wrap only post_schema (the actual hashcheck/send HTTP call), so generate_schema_file and the append_schema merge keep raising unconditionally, exactly as they did before this PR, while a well-formed-but-rejected secret still degrades gracefully in dev. Co-Authored-By: Claude Sonnet 5 --- .../builder/agent_factory.rb | 22 ++++---- .../builder/agent_factory_spec.rb | 51 +++++++++---------- 2 files changed, 36 insertions(+), 37 deletions(-) diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/builder/agent_factory.rb b/packages/forest_admin_agent/lib/forest_admin_agent/builder/agent_factory.rb index 630d51d7a..1974d6baa 100644 --- a/packages/forest_admin_agent/lib/forest_admin_agent/builder/agent_factory.rb +++ b/packages/forest_admin_agent/lib/forest_admin_agent/builder/agent_factory.rb @@ -130,15 +130,19 @@ def send_schema(force: false) end end - post_schema(schema, force) - rescue StandardError => e - # A well-formed secret can still be rejected by the server (wrong project, revoked - # secret, network down, ...). Same rule as an invalid format: block boot in - # production, but never in dev - warn and move on instead. - raise e if Facades::Container.cache(:is_production) - - @logger.log('Warn', "[ForestAdmin] #{e.message}") - @logger.log('Warn', '[ForestAdmin] Schema sync failed, continuing without it.') + begin + post_schema(schema, force) + rescue StandardError => e + # A well-formed secret can still be rejected by the server (wrong project, revoked + # secret, network down, ...). Same rule as an invalid format: block boot in + # production, but never in dev - warn and move on instead. Scoped to this call + # only, so a local bug (a broken customization, an unwritable schema_path, ...) + # still surfaces immediately instead of being logged as a generic warning. + raise e if Facades::Container.cache(:is_production) + + @logger.log('Warn', "[ForestAdmin] #{e.message}") + @logger.log('Warn', '[ForestAdmin] Schema sync failed, continuing without it.') + end end # Generates or loads the schema and writes it to file (in development mode). diff --git a/packages/forest_admin_agent/spec/lib/forest_admin_agent/builder/agent_factory_spec.rb b/packages/forest_admin_agent/spec/lib/forest_admin_agent/builder/agent_factory_spec.rb index a365751ed..0382c327f 100644 --- a/packages/forest_admin_agent/spec/lib/forest_admin_agent/builder/agent_factory_spec.rb +++ b/packages/forest_admin_agent/spec/lib/forest_admin_agent/builder/agent_factory_spec.rb @@ -158,7 +158,8 @@ module Builder { auth_secret: 'cba803d01a4d43b55010cab41fa1ea1f1f51a95e', env_secret: '89719c6d8e2e2de2694c2f220fe2dbf02d5289487364daf1e4c6b13733ed0cdb', - skip_schema_update: false + skip_schema_update: false, + append_schema_path: nil } end let(:not_found_error) do @@ -170,7 +171,8 @@ module Builder context 'when running in production' do it 'raises the error the server returned' do instance.setup(valid_options.merge(is_production: true)) - allow(instance).to receive(:generate_schema_file).and_raise(not_found_error) + allow(instance).to receive_messages(generate_schema_file: { meta: {}, collections: [] }) + allow(instance).to receive(:post_schema).and_raise(not_found_error) expect { instance.send_schema }.to raise_error(ForestAdminAgent::Http::Exceptions::NotFoundError) end @@ -181,7 +183,8 @@ module Builder logger = instance_spy(Services::LoggerService) allow(Services::LoggerService).to receive(:new).and_return(logger) instance.setup(valid_options.merge(is_production: false)) - allow(instance).to receive(:generate_schema_file).and_raise(not_found_error) + allow(instance).to receive_messages(generate_schema_file: { meta: {}, collections: [] }) + allow(instance).to receive(:post_schema).and_raise(not_found_error) expect { instance.send_schema }.not_to raise_error @@ -191,6 +194,21 @@ module Builder end end + context 'when generate_schema_file raises a local error (e.g. a broken customization)' do + it 'still raises it outside production, instead of swallowing it as a Forest warning' do + instance = described_class.instance + instance.setup( + auth_secret: 'cba803d01a4d43b55010cab41fa1ea1f1f51a95e', + env_secret: '89719c6d8e2e2de2694c2f220fe2dbf02d5289487364daf1e4c6b13733ed0cdb', + skip_schema_update: false, + is_production: false + ) + allow(instance).to receive(:generate_schema_file).and_raise(ArgumentError, 'broken chart definition') + + expect { instance.send_schema }.to raise_error(ArgumentError, 'broken chart definition') + end + end + describe 'add_datasource' do it 'add collections to the customizer datasource' do datasource = ForestAdminDatasourceToolkit::Datasource.new @@ -478,30 +496,9 @@ module Builder expect(instance).to have_received(:post_schema).with(hash_including(collections: [{ name: 'Main' }, { name: 'Extra' }]), anything) end - it 'raises error if append_schema file cannot be loaded, in production' do - instance = described_class.instance - instance.instance_variable_set(:@has_env_secret, true) - - datasource = instance_double(ForestAdminDatasourceToolkit::Datasource) - instance.container.register(:datasource, datasource) - - allow(Facades::Container).to receive(:cache).with(:skip_schema_update).and_return(false) - allow(Facades::Container).to receive(:cache).with(:schema_path).and_return('/path/to/schema.json') - allow(Facades::Container).to receive(:cache).with(:is_production).and_return(true) - allow(Facades::Container).to receive(:cache).with(:append_schema_path).and_return('/path/to/append.json') - allow(ForestAdminAgent::Utils::Schema::SchemaEmitter).to receive_messages(generate: [], meta: {}) - allow(File).to receive(:exist?).with('/path/to/schema.json').and_return(true) - allow(File).to receive(:read).with('/path/to/schema.json').and_return({ meta: {}, collections: [] }.to_json) - allow(File).to receive(:read).with('/path/to/append.json').and_raise(Errno::ENOENT) - - expect { instance.send_schema }.to raise_error(/Can't load additional schema/) - end - - it 'warns instead of raising if append_schema file cannot be loaded, outside production' do + it 'raises error if append_schema file cannot be loaded, regardless of environment' do instance = described_class.instance instance.instance_variable_set(:@has_env_secret, true) - logger = instance_spy(Services::LoggerService) - instance.instance_variable_set(:@logger, logger) datasource = instance_double(ForestAdminDatasourceToolkit::Datasource) instance.container.register(:datasource, datasource) @@ -514,9 +511,7 @@ module Builder allow(File).to receive(:write) allow(File).to receive(:read).with('/path/to/append.json').and_raise(Errno::ENOENT) - expect { instance.send_schema }.not_to raise_error - - expect(logger).to have_received(:log).with('Warn', /Can't load additional schema/) + expect { instance.send_schema }.to raise_error(/Can't load additional schema/) end context 'with skip_schema_update enabled' do